This commit is contained in:
pablof7z 2023-02-10 19:23:51 +07:00
commit e798a42618
27 changed files with 9318 additions and 0 deletions

10
.gitignore vendored Normal file
View file

@ -0,0 +1,10 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

1
.npmrc Normal file
View file

@ -0,0 +1 @@
engine-strict=true

26
README.md Normal file
View file

@ -0,0 +1,26 @@
# What is NostriChat?
Nostri.chat is a chat widget you can easily embed in websites.
It uses Nostr as the underlying protocol, which permits a few pretty cool features.
## Operation Modes
### Classic chat: 1-to-1 encrypted chats
This mode implements the typical chat widget flow present in most websites. The visitor writes in the website and someone associated with the website responds.
No one else sees this communication
### Global chat: Topic/Website-based communication
In this mode, the user engages in a conversation around a topic and everybody connected to the same relays can see the communication happening and interact with it.
The communication can be scoped to one or multiple topics. (e.g. _#fasting_, _#bitcoin_, or your specific website).
When a visitor interacts with this mode, the chat widget is populated with the prior conversations that have already occurred around this topic.
> Imagine visiting a website about #fasting, and you can immediately interact with anyone interested in that topic; you can ask questions and receive immediate responses from others
# Features
- [x] NostrConnect key delegation
- [x] Ephemeral keys
- [x] Encrypted DMs mode
- [x] Tag-scoped chats mode
- [ ] In-thread replies

17
jsconfig.json Normal file
View file

@ -0,0 +1,17 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
// "checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true
}
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias and https://kit.svelte.dev/docs/configuration#files
//
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
// from the referenced tsconfig.json - TypeScript does not merge them in
}

7601
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

48
package.json Normal file
View file

@ -0,0 +1,48 @@
{
"name": "nostri.chat",
"version": "0.0.1",
"scripts": {
"dev": "vite dev",
"build": "svelte-kit sync && svelte-package",
"prepublishOnly": "echo 'Did you mean to publish `./package/`, instead of `./`?' && exit 1"
},
"devDependencies": {
"@rollup/plugin-node-resolve": "^15.0.1",
"@sveltejs/adapter-auto": "^1.0.0",
"@sveltejs/kit": "^1.0.0",
"@sveltejs/package": "^1.0.0",
"autoprefixer": "^10.4.13",
"debug": "^4.3.4",
"postcss": "^8.4.21",
"rollup-plugin-svelte": "^7.1.0",
"sirv-cli": "^2.0.2",
"svelte": "^3.54.0",
"tailwindcss": "^3.2.4",
"tslib": "^2.4.1",
"typescript": "^4.9.3",
"vite": "^4.0.0"
},
"type": "module",
"dependencies": {
"@nostr-connect/connect": "^0.2.3",
"@rollup/plugin-commonjs": "^24.0.1",
"@rollup/plugin-typescript": "^11.0.0",
"@sveltejs/adapter-node": "^1.1.7",
"@tailwindcss/forms": "^0.5.3",
"@tailwindcss/typography": "^0.5.9",
"eventemitter3": "^5.0.0",
"nostr": "^0.2.7",
"nostr-tools": "^1.2.1",
"rollup-plugin-css-only": "^4.3.0",
"rollup-plugin-esformatter": "^3.0.0",
"rollup-plugin-livereload": "^2.0.5",
"rollup-plugin-postcss": "^4.0.2",
"rollup-plugin-serve": "^2.0.2",
"rollup-plugin-terser": "^7.0.2",
"svelte-preprocess": "^5.0.1",
"svelte-qr": "^1.0.0",
"svelte-scrollto": "^0.2.0",
"uuid": "^9.0.0",
"ws": "^8.12.0"
}
}

6
postcss.config.cjs Normal file
View file

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

67
rollup.config.js Normal file
View file

@ -0,0 +1,67 @@
import svelte from "rollup-plugin-svelte";
import sveltePreprocess from "svelte-preprocess";
import { vitePreprocess } from '@sveltejs/kit/vite';
import tailwindcss from "tailwindcss";
import autoprefixer from "autoprefixer";
import resolve from "@rollup/plugin-node-resolve";
import commonjs from "@rollup/plugin-commonjs";
import css from "rollup-plugin-css-only";
import serve from "rollup-plugin-serve";
import livereload from "rollup-plugin-livereload";
import { terser } from "rollup-plugin-terser";
import esformatter from "rollup-plugin-esformatter";
import postcss from "rollup-plugin-postcss";
const production = true; //false;// !process.env.ROLLUP_WATCH;
export default {
input: "src/widget.js",
output: {
file: "public/bundle.js",
format: "iife",
name: "app",
sourcemap: production,
},
plugins: [
// compile svelte with tailwindcss as preprocess (including autoprefixer)
svelte({
preprocess: [
sveltePreprocess({
sourceMap: !production,
postcss: {
plugins: [tailwindcss(), autoprefixer()],
},
}),
vitePreprocess(),
],
}),
// resolve external dependencies from NPM
resolve({
browser: true,
dedupe: ["svelte"],
preferBuiltins: false
}),
commonjs(),
// export CSS in separate file for better performance
css({ output: "bundle.css" }),
// postcss({
// config: {
// path: "./postcss.config.js",
// },
// extensions: [".css"],
// minimize: true,
// inject: {
// insertAt: "top",
// },
// }),
// start a local livereload server on public/ folder
!production && serve("public/"),
!production && livereload("public/"),
// minify bundles in production mode
// production && terser(),
],
};

293
src/ConnectedWidget.svelte Normal file
View file

@ -0,0 +1,293 @@
<script>
import { chatAdapter, chatData, selectedMessage } from './lib/store';
import { onMount } from 'svelte';
import NostrNote from './NostrNote.svelte';
import * as animateScroll from "svelte-scrollto";
let events = [];
let responseEvents = [];
let responses = {};
let profiles = {};
export let websiteOwnerPubkey;
export let chatConfiguration;
let prevChatConfiguration;
$: {
if (chatConfiguration !== prevChatConfiguration && prevChatConfiguration && $chatAdapter) {
$chatAdapter.setChatConfiguration(chatConfiguration.chatType, chatConfiguration.chatTags, chatConfiguration.chatReferenceTags);
events = [];
responses = {};
rootNoteId = null;
localStorage.removeItem('rootNoteId');
// rootNoteId = localStorage.getItem('rootNoteId');
// if (rootNoteId) {
// $chatAdapter.subscribeToEventAndResponses(rootNoteId);
// }
}
prevChatConfiguration = chatConfiguration;
}
function getEventById(eventId) {
let event = events.find(e => e.id === eventId);
event = event || responseEvents.find(e => e.id === eventId);
return event;
}
async function sendMessage() {
const input = document.getElementById('message-input');
const message = input.value;
input.value = '';
let extraParams = { tags: [] };
// if this is the rootLevel we want to tag the owner of the site's pubkey
if (!rootNoteId) { extraParams.tagPubKeys = [websiteOwnerPubkey] }
// if we are responding to an event, we want to tag the event and the pubkey
if ($selectedMessage) {
extraParams.tags.push(['e', $selectedMessage]);
extraParams.tagPubKeys.push(getEventById($selectedMessage).pubkey);
}
// if (rootNoteId) {
// // mark it as a response to the most recent event
// const mostRecentEvent = events[events.length - 1];
// // go through all the tags and add them to the new message
// if (mostRecentEvent) {
// mostRecentEvent.tags.forEach(tag => {
// if (tag[0] === 'e') {
// extraParams.tags.push(tag);
// }
// })
// extraParams.tags.push(['e', mostRecentEvent.id]);
// extraParams.tags.push(['p', mostRecentEvent.pubkey]);
// }
// }
const noteId = await $chatAdapter.send(message, extraParams);
if (!rootNoteId) {
rootNoteId = noteId;
localStorage.setItem('rootNoteId', rootNoteId);
}
}
async function inputKeyDown(event) {
if (event.key === 'Enter') {
sendMessage();
event.preventDefault();
}
}
function messageReceived(message) {
const messageLastEventTag = message.tags.filter(tag => tag[0] === 'e').pop();
let isThread;
if (chatConfiguration.chatType === 'GLOBAL') {
isThread = message.tags.filter(tag => tag[0] === 'e').length >= 1;
} else {
const pubkeysTagged = message.tags.filter(tag => tag[0] === 'p').map(tag => tag[1]);
isThread = new Set(pubkeysTagged).size >= 2;
}
responses[message.id] = [];
if (isThread) {
const lastETag = message.tags.filter(tag => tag[0] === 'e').pop();
if (lastETag && lastETag[1] && responses[lastETag[1]]) {
responses[lastETag[1]].push(message);
}
responseEvents.push(message);
responseEvents = responseEvents;
} else {
// insert message so that it's chronologically ordered by created_at
let index = 0;
while (index < events.length && events[index].created_at < message.created_at) {
index++;
}
events.splice(index, 0, message);
events = events;
}
responses = responses;
scrollDown()
}
function scrollDown() {
animateScroll.scrollToBottom({
container: document.getElementById('messages-container'),
offset: 500,
duration: 50
})
}
function reactionReceived(reaction) {
const event = events.find(event => event.id === reaction.id);
if (!event) { return; }
event.reactions = event.reactions || [];
event.reactions.push(reaction);
events = events;
}
let rootNoteId;
onMount(() => {
$chatAdapter.on('message', messageReceived);
$chatAdapter.on('connectivity', (e) => {
connectivityStatus = e;
})
$chatAdapter.on('reaction', reactionReceived);
$chatAdapter.on('deleted', (deletedEvents) => {
deletedEvents.forEach(deletedEventId => {
const index = events.findIndex(event => event.id === deletedEventId);
if (index !== -1) {
events[index].deleted = true;
events = events;
}
})
});
$chatAdapter.on('profile', ({pubkey, profile}) => {
let profiles = $chatData.profiles;
profiles[pubkey] = profile;
chatData.set({ profiles, ...$chatData })
})
});
let connectivityStatus = {};
let connectedRelays = 0;
let totalRelays = 0;
$: {
connectedRelays = Object.values(connectivityStatus).filter(status => status === 'connected').length;
totalRelays = Object.values(connectivityStatus).length;
}
$: profiles = $chatData.profiles;
function selectParent() {
// get the last tagged event in the tags array of the current $selectedMessage
const lastETag = getEventById($selectedMessage).tags.filter(tag => tag[0] === 'e').pop();
const lastETagId = lastETag && lastETag[1];
$selectedMessage = lastETagId;
scrollDown()
}
</script>
<div class="
bg-purple-700 text-white
-m-5 mb-3
px-5 py-3
overflow-clip
flex flex-row justify-between items-center
">
<div class="text-lg font-semibold">
{#if $chatAdapter?.pubkey}
{profiles[$chatAdapter.pubkey]?.display_name || $chatAdapter.pubkey}
{/if}
</div>
<span class="text-xs flex flex-col items-end mt-2 text-gray-200 gap-1">
<div class="flex flex-row gap-1 overflow-clip">
{#each Array(totalRelays) as _, i}
<span class="
inline-block
rounded-full
{connectedRelays > i ? 'bg-green-500' : 'bg-gray-300'}
w-2 h-2
"></span>
{/each}
</div>
{connectedRelays}/{totalRelays} relays
</span>
</div>
{#if $selectedMessage}
{#if !getEventById($selectedMessage)}
<h1>Couldn't find event with ID {$selectedMessage}</h1>
{:else}
<div class="flex flex-row mb-3">
<a href='#' on:click|preventDefault={selectParent}>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 12h-15m0 0l6.75 6.75M4.5 12l6.75-6.75" />
</svg>
</a>
<div class="flex flex-col ml-2">
<span class="text-lg text-black overflow-hidden whitespace-nowrap text-ellipsis">
{getEventById($selectedMessage).content}
</span>
</div>
</div>
{/if}
{/if}
<div id="messages-container" class="overflow-scroll">
{#if $selectedMessage}
<NostrNote event={getEventById($selectedMessage)} {responses} {websiteOwnerPubkey} />
{:else}
{#each events as event}
<NostrNote {event} {responses} {websiteOwnerPubkey} />
{#if event.deleted}
👆 deleted
{/if}
{/each}
{/if}
</div>
<div class="flex flex-col">
<div class="
border-y border-y-slate-200
-mx-5 my-2 bg-slate-100 text-black text-sm
px-5 py-2
">
{#if chatConfiguration.chatType === 'DM'}
<b>Encrypted chat:</b>
only your chat partner can see these messages.
{:else}
<b>Public chat:</b>
anyone can see these messages.
{/if}
</div>
<div class="flex flex-row gap-2 -mx-1">
<textarea
type="text"
id="message-input"
class="
-mb-2
p-2
w-full
resize-none
rounded-xl
text-gray-600
border
" placeholder="Say hello!"
rows=1
on:keydown={inputKeyDown}
></textarea>
<button type="button" class="inline-flex items-center rounded-full border border-transparent bg-purple-700 p-3 text-white shadow-sm hover:bg-purple-600 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2" on:click|preventDefault={sendMessage}>
<!-- Heroicon name: outline/plus -->
<svg aria-hidden="true" class="w-6 h-6 rotate-90" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path d="M10.894 2.553a1 1 0 00-1.788 0l-7 14a1 1 0 001.169 1.409l5-1.429A1 1 0 009 15.571V11a1 1 0 112 0v4.571a1 1 0 00.725.962l5 1.428a1 1 0 001.17-1.408l-7-14z"></path></svg>
</button>
</div>
</div>
<style>
@tailwind base;
@tailwind components;
@tailwind utilities;
</style>

24
src/Container.svelte Normal file
View file

@ -0,0 +1,24 @@
<script>
import { chatAdapter } from './lib/store';
import KeyPrompt from './KeyPrompt.svelte';
import ConnectedWidget from './ConnectedWidget.svelte';
export let websiteOwnerPubkey;
export let chatStarted;
export let chatConfiguration;
export let relays;
$: chatStarted = !!$chatAdapter
</script>
{#if !chatStarted}
<KeyPrompt {websiteOwnerPubkey} {chatConfiguration} {relays} />
{:else}
<ConnectedWidget {websiteOwnerPubkey} {chatConfiguration} {relays} />
{/if}
<style>
@tailwind base;
@tailwind components;
@tailwind utilities;
</style>

189
src/KeyPrompt.svelte Normal file
View file

@ -0,0 +1,189 @@
<script>
import { onMount } from "svelte";
import QR from 'svelte-qr';
import { chatAdapter } from './lib/store';
import NstrAdapterNip07 from './lib/adapters/nip07.js';
import NstrAdapterNip46 from './lib/adapters/nip46.js';
import NstrAdapterDiscadableKeys from './lib/adapters/discardable-keys.js';
export let websiteOwnerPubkey;
export let chatConfiguration;
export let relays;
let hasNostrNip07 = true;
let publicKey = null;
let nip46URI;
let adapterConfig;
onMount(() => {
// hasNostrNip07 = !!window.nostr;
const type = localStorage.getItem('nostrichat-type');
if (type === 'nip07') {
useNip07();
} else if (type === 'nip-46') {
useNip46();
}
adapterConfig = {
type: chatConfiguration.chatType,
tags: chatConfiguration.chatTags,
referenceTags: chatConfiguration.chatReferenceTags,
websiteOwnerPubkey,
relays
}
});
function useNip07() {
window.nostr.getPublicKey().then((pubkey) => {
localStorage.setItem('nostrichat-type', 'nip07');
chatAdapter.set(new NstrAdapterNip07(pubkey, adapterConfig))
});
}
import { generatePrivateKey, getPublicKey } from 'nostr-tools';
import { Connect, ConnectURI } from '@nostr-connect/connect';
async function useDiscardableKeys() {
chatAdapter.set(new NstrAdapterDiscadableKeys(adapterConfig))
}
async function useNip46() {
let key = localStorage.getItem('nostrichat-nostr-connect-key');
let publicKey = localStorage.getItem('nostrichat-nostr-connect-public-key');
if (key) {
chatAdapter.set(new NstrAdapterNip46(publicKey, key, adapterConfig))
return;
}
key = generatePrivateKey();
const connect = new Connect({ secretKey: key, relay: 'wss://nostr.vulpem.com' });
connect.events.on('connect', (connectedPubKey) => {
localStorage.setItem('nostrichat-nostr-connect-key', key);
localStorage.setItem('nostrichat-nostr-connect-public-key', connectedPubKey);
localStorage.setItem('nostrichat-type', 'nip-46');
console.log('connected to nostr connect relay')
publicKey = connectedPubKey;
chatAdapter.set(new NstrAdapterNip46(publicKey, key))
nip46URI = null;
});
connect.events.on('disconnect', () => {
console.log('disconnected from nostr connect relay')
})
await connect.init();
const connectURI = new ConnectURI({
target: getPublicKey(key),
relay: 'wss://nostr.vulpem.com',
metadata: {
name: 'PSBT.io',
description: '🔉🔉🔉',
url: 'https://psbt.io',
icons: ['https://example.com/icon.png'],
},
});
nip46URI = connectURI.toString();
}
function Nip46Copy() {
navigator.clipboard.writeText(nip46URI);
}
</script>
<h1 class="font-bold text-xl mb-3">
How would you like to connect?
</h1>
{#if publicKey}
<p class="text-gray-400 mb-3 font-bold">
Nostr Connect is a WIP, not fully implemented yet!
</p>
<p class="text-gray-400 mb-3">
You are currently connected with the following public key:
<span>{publicKey}</span>
</p>
{/if}
{#if nip46URI}
<p class="text-gray-600 mb-3">
Scan this with your Nostr Connect (click to copy to clipboard)
</p>
<div class="bg-white w-full p-3"
on:click|preventDefault={Nip46Copy}>
<QR text={nip46URI} />
</div>
<button class="
bg-purple-900
hover:bg-purple-700
w-full
p-2
rounded-xl
text-center
font-regular
text-white
" on:click|preventDefault={() => { nip46URI = null; }}>
Cancel
</button>
{:else if !publicKey}
<div class="flex flex-col gap-1">
{#if hasNostrNip07}
<button class="
bg-purple-900
hover:bg-purple-700
w-full
p-4
rounded-xl
text-center
font-regular
text-gray-200
" on:click|preventDefault={useNip07}>
Browser Extension (NIP-07)
</button>
{/if}
<button class="
bg-purple-900
hover:bg-purple-700
w-full
p-4
rounded-xl
text-center
font-regular
text-gray-200
" on:click|preventDefault={useNip46}>
Nostr Connect (NIP-46)
</button>
<button class="
bg-purple-900
hover:bg-purple-700
w-full
p-4
rounded-xl
text-center
font-regular
text-gray-200
" on:click|preventDefault={useDiscardableKeys}>
Anonymous
<span class="text-xs text-gray-300">
(Ephemeral Keys)
</span>
</button>
</div>
{/if}
<style>
@tailwind base;
@tailwind components;
@tailwind utilities;
</style>

97
src/NostrNote.svelte Normal file
View file

@ -0,0 +1,97 @@
<script>
import { selectedMessage } from './lib/store';
import { chatData } from './lib/store';
export let event;
export let responses;
export let websiteOwnerPubkey;
let profiles = {};
let profilePicture;
function selectMessage() {
if ($selectedMessage === event.id) {
$selectedMessage = null;
} else {
$selectedMessage = event.id;
}
}
const byWebsiteOwner = !!websiteOwnerPubkey === event.pubkey;
$: profiles = $chatData.profiles;
$: displayName = profiles[event.pubkey] && profiles[event.pubkey].display_name || event.pubkey;
$: nip05 = profiles[event.pubkey] && profiles[event.pubkey].nip05;
$: profilePicture = profiles[event.pubkey] && profiles[event.pubkey].picture || `https://robohash.org/${event.pubkey}.png?set=set1`;
const repliedIds = event.tags.filter(e => e[0] === 'e').map(e => e[1]);
let timestamp = new Date(event.created_at * 1000);
</script>
<div
class="
block p-2-lg mb-3
text-wrap
"
>
<div class="flex flex-row gap-4">
<div class="min-w-fit">
<img src="{profilePicture}" class="
block w-10 h-10 rounded-full
{byWebsiteOwner ? 'ring-purple-700 ring-4' : 'ring-gray-300 ring-2'}
" alt="" />
<!-- <span class="text-base font-semibold text-clip">{displayName}</span>
{#if nip05}
<span class="text-sm text-gray-400">{nip05}</span>
{/if} -->
</div>
<div class="w-full overflow-hidden">
<div class="flex flex-row justify-between text-center overflow-clip text-clip w-full">
</div>
<div class="
max-h-64 text-base
cursor-pointer
border border-slate-200
{$selectedMessage === event.id ? 'bg-purple-700 text-white' : 'bg-slate-50 text-gray-500 hover:bg-slate-100'}
p-4 py-2 overflow-scroll rounded-2xl
" on:click|preventDefault={()=>{selectMessage(event.id)}}>
{event.content}
</div>
<div class="flex flex-row-reverse justify-between mt-1 overflow-clip items-center">
<div class="text-xs text-gray-400 text-ellipsis overflow-clip whitespace-nowrap">
<span class="py-2">
{timestamp.toLocaleString()}
</span>
</div>
{#if byWebsiteOwner}
<div class="text-purple-500 text-xs">
Website owner
</div>
{:else}
<div class="text-xs text-gray-400">
{displayName}
</div>
{/if}
</div>
</div>
</div>
</div>
{#if responses[event.id].length > 0}
<div class="pl-5 border-l border-l-gray-400 mb-10">
{#each responses[event.id] as response}
<svelte:self {websiteOwnerPubkey} event={response} {responses} />
{/each}
</div>
{/if}
<style>
@tailwind base;
@tailwind components;
@tailwind utilities;
</style>

113
src/Widget.svelte Normal file
View file

@ -0,0 +1,113 @@
<script>
import Container from './Container.svelte';
export let websiteOwnerPubkey;
export let chatType;
export let chatTags;
export let chatReferenceTags;
export let relays;
let showChat = false;
let dismissedIntro = true;
let minimizeChat = false;
function toggleChat() {
if (showChat) {
minimizeChat = !minimizeChat;
} else {
showChat = !showChat;
}
}
function dismissIntro() {
dismissedIntro = true;
}
</script>
<div class="fixed bottom-5 right-5 mb-5 flex flex-col item-end font-sans">
{#if showChat}
<div class="
shadow-2xl
bg-white mb-5 w-96 max-w-screen-sm text-black rounded-xl p-5 overflow-scroll
{minimizeChat ? 'hidden' : ''}
" style="max-height: 80vh;">
{#if !dismissedIntro}
<h1 class="
font-bold
text-2xl
text-purple-700">
NostriChat
</h1>
<p class="text-gray-700 mb-3">
This is a FOSS chat app built on top of the Nostr protocol.
</p>
<p class="text-gray-700 mb-3">
Choose how you would like to chat:
</p>
<p class="text-gray-700 mb-3">
You can use it to ask for help
<span class="font-bold">PSBT.io</span>
to the creators of this site or to
anyone willing to help.
</p>
<p class="text-gray-700 mb-3">
Keep in mind that this chat is public,
anyone can read it, so don't exchange
private information and use common-sense.
</p>
<button class="
bg-purple-900
hover:bg-purple-700
w-full
p-2
py-4
text-xl
mt-3
rounded-xl
text-center
font-semibold
tracking-wide
uppercase
text-white
" on:click={dismissIntro}>
Continue
</button>
{:else}
<Container
{websiteOwnerPubkey}
chatConfiguration={{
chatType,
chatTags,
chatReferenceTags,
}}
{relays}
/>
{/if}
</div>
{/if}
<div class="self-end">
<a href="#" class="text-white bg-purple-900 hover:bg-purple-700 w-full p-5 rounded-full flex-shrink-1 text-center font-semibold flex flex-row items-center gap-4" on:click|preventDefault={toggleChat}>
<span class="tracking-wider">
<span class="
text-white
">Nostri</span><span class="text-orange-400 text-6xl -mx-1" style="line-height: 1px;">.</span><span class="text-purple-300">Chat</span>
</span>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6 inline-block">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12.76c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.076-4.076a1.526 1.526 0 011.037-.443 48.282 48.282 0 005.68-.494c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z" />
</svg>
</a>
</div>
</div>
<style>
@tailwind base;
@tailwind components;
@tailwind utilities;
</style>

15
src/app.html Normal file
View file

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<!-- bg-white dark:bg-black -->
<body class="
">
<div>%sveltekit.body%</div>
</body>
</html>

View file

@ -0,0 +1,41 @@
import { generatePrivateKey, signEvent, getPublicKey, nip04 } from 'nostr-tools';
import NstrAdapter from './index.js';
class NstrAdapterDiscadableKeys extends NstrAdapter {
#privateKey;
constructor(adapterConfig={}) {
let key = localStorage.getItem('nostrichat-discardable-key');
let publicKey = localStorage.getItem('nostrichat-discardable-public-key');
if (!key) {
key = generatePrivateKey();
console.log('generated key', key);
publicKey = getPublicKey(key);
}
localStorage.setItem('nostrichat-discardable-key', key);
localStorage.setItem('nostrichat-discardable-public-key', publicKey);
super(publicKey, adapterConfig);
this.#privateKey = key;
console.log(key);
}
async signEvent(event) {
event.sig = await signEvent(event, this.#privateKey);
return event;
}
async encrypt(destPubkey, message) {
console.log(this.#privateKey);
return await nip04.encrypt(this.#privateKey, destPubkey, message);
}
async decrypt(destPubkey, message) {
return await nip04.decrypt(this.#privateKey, destPubkey, message);
}
}
export default NstrAdapterDiscadableKeys;

348
src/lib/adapters/index.js Normal file
View file

@ -0,0 +1,348 @@
import { chatData } from '../store';
import { getEventHash, relayInit } from 'nostr-tools';
import RelayPool from 'nostr/lib/relay-pool';
import { createEventDispatcher } from 'svelte';
import EventEmitter from 'events';
import * as uuid from 'uuid';
import debug from 'debug';
const log = new debug('nostr:adapter');
const profilesLog = new debug('nostr:adapter:profiles');
const writeLog = new debug('nostr:adapter:write');
class NstrAdapter {
relayStatus = {};
#pool = null;
#messages = {};
#eventEmitter = new EventEmitter();
#handlers = {}
tags;
referenceTags;
type;
#websiteOwnerPubkey;
relayUrls = [];
#profileRequestQueue = [];
#requestedProfiles = [];
#profileRequestTimer;
constructor(clientPubkey, {tags, referenceTags, type='DM', websiteOwnerPubkey, relays} = {}) {
this.pubkey = clientPubkey;
this.#websiteOwnerPubkey = websiteOwnerPubkey;
this.relayUrls = relays
if (type) {
this.setChatConfiguration(type, tags, referenceTags);
}
}
setChatConfiguration(type, tags, referenceTags) {
log('chatConfiguration', {type, tags, referenceTags});
this.type = type;
this.tags = tags;
this.referenceTags = referenceTags;
// handle connection
if (this.#pool) { this.#disconnect() }
this.#connect()
let filters = [];
console.log('this.tags', this.tags);
console.log('this.referenceTags', this.referenceTags);
// handle subscriptions
// if this is DM type then subscribe to chats with this website owner
switch (this.type) {
case 'DM':
filters.push({
kinds: [4],
'#p': [this.pubkey, this.#websiteOwnerPubkey],
'authors': [this.pubkey, this.#websiteOwnerPubkey]
});
break;
case 'GLOBAL':
if (this.tags && this.tags.length > 0) {
filters.push({kinds: [1], '#t': this.tags, limit: 20});
}
if (this.referenceTags && this.referenceTags.length > 0) {
filters.push({kinds: [1], '#r': this.referenceTags, limit: 20});
}
break;
}
console.log('filters', filters);
if (filters && filters.length > 0) {
this.subscribe(filters, (e) => { this.#emitMessage(e) })
}
}
async getPubKey() {
return this.pubkey;
}
on(event, callback) {
this.#eventEmitter.on(event, callback);
}
/**
* Send a message to the relay
* @param {String} message - The message to send
*/
async send(message, {tagPubKeys, tags} = {}) {
let event;
if (!tags) { tags = []}
if (this.type === 'DM') {
event = await this.sendKind4(message, {tagPubKeys, tags});
} else {
event = await this.sendKind1(message, {tagPubKeys, tags});
}
event.id = getEventHash(event)
const signedEvent = await this.signEvent(event)
this.#_publish(signedEvent);
return event.id;
}
async sendKind4(message, {tagPubKeys, tags} = {}) {
let ciphertext = await this.encrypt(this.#websiteOwnerPubkey, message);
let event = {
kind: 4,
pubkey: this.pubkey,
created_at: Math.floor(Date.now() / 1000),
content: ciphertext,
tags: [
['p', this.#websiteOwnerPubkey],
...tags
],
}
return event;
}
async sendKind1(message, {tagPubKeys, tags} = {}) {
if (!tags) { tags = []; }
if (this.tags) {
this.tags.forEach((t) => tags.push(['t', t]));
}
if (this.referenceTags) {
this.referenceTags.forEach((t) => tags.push(['r', t]));
}
let event = {
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags,
content: message,
pubkey: this.pubkey,
}
if (tagPubKeys) {
for (let pubkey of tagPubKeys) {
event.tags.push(['p', pubkey]);
}
}
event.id = getEventHash(event)
this.subscribeToEventAndResponses(event.id);
return event;
}
async #_publish(event) {
writeLog('publish', event);
this.#pool.send([ 'EVENT', event ]);
}
async onEvent(event, messageCallback) {
this.#addProfileRequest(event.pubkey);
messageCallback(event)
}
async subscribe(filters, messageCallback=null) {
if (!messageCallback) { messageCallback = (e) => { this.#emitMessage(e) } }
return this.#_subscribe(filters, messageCallback)
}
async #_subscribe(filters, messageCallback) {
const subId = uuid.v4();
this.#handlers[subId] = messageCallback;
if (!Array.isArray(filters)) { filters = [filters] }
this.#pool.subscribe(subId, filters);
this.#pool.on('event', (relay, recSubId, e) => {
this.onEvent(e, this.#handlers[recSubId])
});
return subId;
}
async #emitMessage(event) {
// has already been emitted
if (this.#messages[event.id]) {
return;
}
this.#messages[event.id] = true;
// decrypt
if (event.kind === 4) {
event.content = await this.decrypt(this.#websiteOwnerPubkey, event.content);
}
// if we have tags we were filtering for, filter here in case the relay doesn't support filtering
if (this.tags && this.tags.length > 0) {
if (!event.tags.find(t => t[0] === 't' && this.tags.includes(t[1]))) {
console.log(`discarded event not tagged with [${this.tags.join(', ')}], tags: ${event.tags.filter(t => t[0] === 't').map(t => t[1]).join(', ')}`);
return;
}
}
if (event.kind === 1) {
if (!event.tags.find(t => t[0] === 'e')) {
// a top level message that we should subscribe to since responses won't tag the url
this.subscribe({ kinds: [1], '#e': [event.id] })
}
}
let deletedEvents = []
if (event.kind === 5) {
deletedEvents = event.tags.filter(tag => tag[0] === 'e').map(tag => tag[1]);
}
switch (event.kind) {
case 1: this.#eventEmitter.emit('message', event); break;
case 4: this.#eventEmitter.emit('message', event); break;
case 5: this.#eventEmitter.emit('deleted', deletedEvents); break;
case 7: this.#eventEmitter.emit('reaction', event); break;
default:
// alert('unknown event kind ' + event.kind)
console.log('unknown event kind', event.kind, event);
}
}
subscribeToEventAndResponses(eventId) {
this.subscribe([
{ids: [eventId]},
{'#e': [eventId]},
], (e) => {
this.#emitMessage(e);
// this.subscribeToResponses(e)
})
}
subscribeToResponses(event) {
this.subscribe([
{'#e': [event.id]},
], (e) => {
this.#emitMessage(e);
this.subscribeToResponses(e)
})
}
/**
* Connect to the relay
*/
#connect() {
this.relayUrls.forEach((url) => {
this.relayStatus[url] = 'disconnected';
});
this.#eventEmitter.emit('connectivity', this.relayStatus);
// console.log('connecting to relay', this.relayUrls);
this.#pool = new RelayPool(this.relayUrls)
this.#pool.on('open', (relay) => {
// console.log(`connected to ${relay.url}`, new Date())
this.relayStatus[relay.url] = 'connected';
this.#eventEmitter.emit('connectivity', this.relayStatus);
})
this.#pool.on('error', (relay, r, e) => {
this.relayStatus[relay.url] = 'error';
this.#eventEmitter.emit('connectivity', this.relayStatus);
console.log('error from relay', relay.url, r, e)
})
this.#pool.on('close', (relay, r) => {
this.relayStatus[relay.url] = 'closed';
this.#eventEmitter.emit('connectivity', this.relayStatus);
console.log('error from relay', relay.url, r)
})
this.#pool.on('notice', (relay, r) => {
console.log('notice', relay.url, r)
})
}
#disconnect() {
this.relayUrls.forEach((url) => {
this.relayStatus[url] = 'disconnected';
});
this.#eventEmitter.emit('connectivity', this.relayStatus);
this.#pool.close();
this.#pool = null;
}
//
//
// Profiles
//
//
#addProfileRequest(pubkey, event=null) {
if (this.#profileRequestQueue.includes(pubkey)) { return; }
if (this.#requestedProfiles.includes(pubkey)) { return; }
this.#profileRequestQueue.push(pubkey);
this.#requestedProfiles.push(pubkey);
if (!this.#profileRequestTimer) {
this.#profileRequestTimer = setTimeout(() => {
this.#profileRequestTimer = null;
this.#requestProfiles();
}, 500);
}
}
/**
* Send request for all queued profiles
*/
async #requestProfiles() {
if (this.#profileRequestQueue.length > 0) {
profilesLog('requesting profiles', this.#profileRequestQueue);
// send request
const subId = await this.subscribe({ kinds: [0], authors: this.#profileRequestQueue }, (e) => {
this.#processReceivedProfile(e);
});
profilesLog('subscribed to request', {subId})
this.#profileRequestQueue = [];
setTimeout(() => {
profilesLog('unsubscribing from request', {subId})
this.#pool.unsubscribe(subId);
}, 5000);
}
}
#processReceivedProfile(event) {
profilesLog('received profile', event)
let profile;
try {
profile = JSON.parse(event.content);
} catch (e) {
profilesLog('failed to parse profile', event);
return;
}
this.#eventEmitter.emit('profile', {pubkey: event.pubkey, profile});
}
}
export default NstrAdapter;

21
src/lib/adapters/nip07.js Normal file
View file

@ -0,0 +1,21 @@
import NstrAdapter from './index.js';
class NstrAdapterNip07 extends NstrAdapter {
constructor(pubkey, adapterConfig={}) {
super(pubkey, adapterConfig);
}
async signEvent(event) {
return await window.nostr.signEvent(event);
}
async encrypt(destPubkey, message) {
return await window.nostr.nip04.encrypt(destPubkey, message);
}
async decrypt(destPubkey, message) {
return await window.nostr.nip04.decrypt(destPubkey, message);
}
}
export default NstrAdapterNip07;

24
src/lib/adapters/nip46.js Normal file
View file

@ -0,0 +1,24 @@
import NstrAdapter from './index.js';
import { Connect } from '@nostr-connect/connect';
class NstrAdapterNip46 extends NstrAdapter {
#secretKey = null;
constructor(pubkey, secretKey, adapterConfig = {}) {
super(pubkey, adapterConfig);
this.#secretKey = secretKey;
}
async signEvent(event) {
const connect = new Connect({
secretKey: this.#secretKey,
target: this.pubkey,
});
await connect.init();
event.sig = await connect.signEvent('12323423434');
return event;
}
}
export default NstrAdapterNip46;

1
src/lib/index.js Normal file
View file

@ -0,0 +1 @@
// Reexport your entry components here

5
src/lib/store.js Normal file
View file

@ -0,0 +1,5 @@
import { writable } from 'svelte/store';
export const chatAdapter = writable(null);
export const chatData = writable({ events: [], profiles: {}});
export const selectedMessage = writable(null);

288
src/routes/+page.svelte Normal file
View file

@ -0,0 +1,288 @@
<script>
import Container from '../Container.svelte';
import { chatAdapter } from '$lib/store';
let chatStarted;
let chatType = 'GLOBAL';
let websiteOwnerPubkey = 'fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52';
let chatTags = ['nostrica'];
let chatReferenceTags = [];
const relays = [
'wss://relay.f7z.io',
'wss://nos.lol',
'wss://relay.nostr.info',
'wss://nostr-pub.wellorder.net',
'wss://relay.current.fyi',
'wss://relay.nostr.band',
];
$: currentTopic = [...chatTags, ...chatReferenceTags][0]
function currentTopic(topic) {
return [...chatTags, ...chatReferenceTags].includes(topic)
}
</script>
<svelte:head>
<title>Nostri.chat -- A NOSTR chat widget you control</title>
<meta property="og:url" content="https://nostri.chat/">
<meta name="description" content="A chat widget you own, powered by nostr" />
<meta property="og:description" content="A chat widget you own, powered by nostr" />
</svelte:head>
<section class="
min-h-screen
text-white
bg-gradient-to-b from-orange-500 to-orange-800
">
<div class="min-h-screen mx-auto w-full lg:max-w-7xl py-5 xl:py-10
flex flex-col lg:flex-row
gap-20 items-center px-4 lg:px-0
relative
">
<div class="
md:w-3/5 grid grid-cols-1 gap-10
">
<section id="hero" style="min-height: 50vh;">
<h1 class="
text-4xl md:text-6xl
font-black
my-2
">Nostri.chat</h1>
<h2 class="
text-2xl lg:text-4xl
text-bold
">A chat widget for your site, powered by nostr</h2>
<p class="
max-w-prose
text-2xl
text-gray-200
tracking-wide
leading-9
my-5
">
Simple, interoperable
communication with your visitors, in a way
that gives you and them complete ownership
over the data.
</p>
</section>
</div>
<div class="
flex flex-row items-center justify-center
min-h-screen fixed
" style="margin-left: 50%;">
<div class="
shadow-2xl
bg-white mb-5 w-96 max-w-screen-sm text-black rounded-3xl p-5 overflow-scroll
flex flex-col justify-end
" style="{chatStarted ? 'max-height: 80vh;' : 'padding: 4rem 2rem !important;'}">
<Container chatConfiguration={{
chatType,
chatTags,
chatReferenceTags,
}} {websiteOwnerPubkey} {relays} bind:chatStarted={chatStarted} />
</div>
</div>
</div>
</section>
<section class="
min-h-screen
py-5
lg:py-16
" style="min-height: 50vh;">
<div class="mx-auto w-full lg:max-w-7xl py-5 xl:py-10
flex flex-col lg:flex-row
gap-20 px-4 lg:px-0
" style="min-height: 50vh;">
<div class="md:w-8/12 lg:w-3/5 grid grid-cols-1 gap-8">
<div>
<h1 class="text-7xl font-black">
Innovative modes
</h1>
<p class="
text-2xl font-extralight
">
Because we use Nostr for communicating,
<b>Nostri.chat</b>
can use some new, creative approaches to using chat widget,
depending on what you want to achieve.
</p>
</div>
<div class="flex flex-col gap-3">
<h2 class="text-3xl text-orange-600 font-black">
Classic mode
<span class="text-2xl text-slate-500 font-extralight block">encrypted 1-on-1 chats</span>
</h2>
<p class="
text-xl text-gray-500 text-justify
font-light
leading-8
">
Lorem ipsum dolor sit, amet consectetur adipisicing elit. Sapiente quae eveniet placeat, obcaecati nesciunt nam iure. Culpa omnis hic eaque illum alias iure autem atque? Distinctio facilis recusandae omnis expedita.
</p>
{#if $chatAdapter}
{#if chatType === 'DM'}
<button class="px-4 rounded border-2 border-orange-700 py-2 text-orange-700 text-lg w-full font-semibold">
Active
</button>
{:else}
<button class="px-4 rounded bg-orange-700 py-2 text-white text-lg w-full font-semibold" on:click={()=>{ chatType='DM'; chatTags=[]; chatReferenceTags=[] }}>
Try it
</button>
{/if}
{/if}
</div>
<div class="flex flex-col gap-3">
<h2 class="text-3xl text-orange-600 font-black">
<div class="flex flex-row gap-2">
<span>🔖</span>
<span class="flex flex-col">
<span>Tagged Global Chat</span>
<span class="text-2xl text-slate-500 font-extralight block">public discussion/support</span>
</span>
</div>
</h2>
<p class="
text-xl text-gray-500 text-justify
font-light
leading-8
">
Imagine having a global chat on your website about a certain topic.
Anyone can participate, from your website or from any Nostr client.
</p>
<div class="flex flex-col lg:flex-row justify-between mt-10 gap-10">
<div class="flex flex-col items-center gap-4 border p-4 shadow-md rounded-lg w-fit lg:w-full">
<h3 class="
text-black
text-lg
font-semibold
">🔖 Topic-based chats</h3>
<span class="inline-flex rounded-md">
<button type="button" class="
inline-flex items-center rounded-l-md border px-4 py-2 text-md font-medium
{currentTopic === 'nostrica' ?
'text-white bg-orange-700 border-orange-900'
:
'border-gray-300 bg-white text-gray-700'}
" on:click={()=>{ chatType='GLOBAL'; chatTags=['nostrica']; chatReferenceTags=[] }}>
#nostrica
</button>
<button type="button" class="
inline-flex items-center rounded-r-md border px-4 py-2 text-md font-medium
{currentTopic === 'bitcoin' ?
'text-white bg-orange-700 border-orange-900'
:
'border-gray-300 bg-white text-gray-700'}
" on:click={()=>{ chatType='GLOBAL'; chatTags=['bitcoin']; chatReferenceTags=[] }}>
#bitcoin
</button>
</span>
</div>
<div class="flex flex-col items-center gap-4 border p-4 shadow-md rounded-lg w-fit lg:w-full">
<h3 class="
text-black
text-lg
font-semibold
">🌎 Website-based chats</h3>
<span class="inline-flex rounded-md">
<button type="button" class="
inline-flex items-center rounded-l-md border px-4 py-2 text-md font-medium
{currentTopic === 'https://nostri.chat' ?
'text-white bg-orange-700 border-orange-900'
:
'border-gray-300 bg-white text-gray-700'}
:ring-indigo-500"
on:click={()=>{ chatType='GLOBAL'; chatTags=[]; chatReferenceTags=['https://nostri.chat'] }}
>
<span class="opacity-50 font-normal">https://</span>nostri.chat
</button>
<button type="button" class="
inline-flex items-center rounded-r-md border px-4 py-2 text-md font-medium
{currentTopic === 'https://psbt.io' ?
'text-white bg-orange-700 border-orange-900'
:
'border-gray-300 bg-white text-gray-700'}
:ring-indigo-500"
on:click={()=>{ chatType='GLOBAL'; chatTags=[]; chatReferenceTags=['https://psbt.io'] }}
>
<span class="opacity-50 font-normal">https://</span>psbt.io
</button>
</span>
</div>
</div>
</div>
</div>
</section>
<section class="
min-h-screen
py-5
lg:py-16
bg-slate-100
" style="min-height: 50vh;">
<div class="mx-auto w-full lg:max-w-7xl py-5 xl:py-10
flex flex-col lg:flex-row
gap-20 items-center px-4 lg:px-0
" style="min-height: 50vh;">
<div class="md:w-3/5 grid grid-cols-1 gap-8">
<div>
<h1 class="text-7xl font-black">
Easy-peasy setup
</h1>
<p class="
text-2xl font-extralight
">
Just drop this snippet on your website and you're good to go.
</p>
</div>
<pre class ="
p-4
bg-white
overflow-scroll
">
&lt;script
src="https://nostri.chat/public/bundle.js"
<span class="text-green-600">&lt;!-- YOUR PUBKEY IN HEX FORMAT --&gt;</span>
<b>data-website-owner-pubkey</b>="<span class="text-orange-500">YOUR_PUBKEY"</span>
<span class="text-green-600">&lt;!-- THE TYPE OF CHAT YOU WANT: GLOBAL or DMs --&gt;</span>
<b>data-chat-type</b>="<span class="text-orange-500">GLOBAL" </span>
<span class="text-green-600">&lt;!-- If you use GLOBAL you can choose set a comma-separated list of hashtags--&gt;</span>
<b>data-chat-tags</b>="<span class="text-orange-500">#nostrica,#bitcoin"</span>
<span class="text-green-600">&lt;!-- Relays you'd like to use --&gt;</span>
<b>data-relays</b>="<span class="text-orange-500">wss://relay.f7z.io,wss://nos.lol,wss://relay.nostr.info,wss://nostr-pub.wellorder.net,wss://relay.current.fyi,wss://relay.nostr.band"</span>
&gt;&lt;/script&gt;
&lt;link rel="stylesheet" href="https://nostri.chat/public/bundle.css"&gt;</pre>
</div>
</div>
</section>
<style>
/* div { border: solid red 1px; } */
@tailwind base;
@tailwind components;
@tailwind utilities;
</style>

View file

@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<meta charset="UTF-8">
<title>Demo page</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="stylesheet" href="public/bundle.css">
<body>
<h1>Svelte embedding demo</h1>
<p>Below,we have inserted a <code>script</code> tag that should
renter a Svelte component upon loading this page.</p>
<script
src="/public/bundle.js"
data-website-owner-pubkey="fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52"
data-chat-type="GLOBAL"
data-chat-tags="#nostrica"
data-relays="wss://relay.f7z.io,wss://nos.lol,wss://relay.nostr.info,wss://nostr-pub.wellorder.net,wss://relay.current.fyi,wss://relay.nostr.band"
></script>
<p>This text will come after the embedded content.</p>
</body>
</html>

3
src/tailwind.css Normal file
View file

@ -0,0 +1,3 @@
@import 'tailwindcss/base';
@import 'tailwindcss/components';
@import 'tailwindcss/utilities';

29
src/widget.js Normal file
View file

@ -0,0 +1,29 @@
import Widget from './Widget.svelte';
var div = document.createElement('DIV');
var script = document.currentScript;
const websiteOwnerPubkey = script.getAttribute('data-website-owner-pubkey');
const chatType = script.getAttribute('data-chat-type');
let chatTags = script.getAttribute('data-chat-tags');
let chatReferenceTags = script.getAttribute('data-chat-reference-tags');
let relays = script.getAttribute('data-relays');
script.parentNode.insertBefore(div, script);
if (!relays) {
relays = 'wss://relay.f7z.io,wss://nos.lol,wss://relay.nostr.info,wss://nostr-pub.wellorder.net,wss://relay.current.fyi,wss://relay.nostr.band'
}
relays = relays.split(',');
chatTags = chatTags ? chatTags.split(',') : [];
chatReferenceTags = chatReferenceTags ? chatReferenceTags.split(',') : [];
const embed = new Widget({
target: div,
props: {
websiteOwnerPubkey,
chatType,
chatTags,
chatReferenceTags,
relays
},
});

12
svelte.config.js Normal file
View file

@ -0,0 +1,12 @@
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/kit/vite';
/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: {
adapter: adapter({ out: 'build' }),
},
preprocess: vitePreprocess()
};
export default config;

8
tailwind.config.cjs Normal file
View file

@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./src/**/*.{html,js,svelte,ts}'],
theme: {
extend: {},
},
plugins: [],
}

7
vite.config.js Normal file
View file

@ -0,0 +1,7 @@
import { sveltekit } from '@sveltejs/kit/vite';
const config = {
plugins: [sveltekit()]
};
export default config;