Implement a minimal chat system for the sidebar
This commit is contained in:
parent
e94764b865
commit
83c366d75b
3 changed files with 383 additions and 70 deletions
148
src/lib/chat.svelte.ts
Normal file
148
src/lib/chat.svelte.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
import { SimplePool, type Event } from "@nostr/tools";
|
||||||
|
import { loadNostrUser, type NostrUser } from "@nostr/gadgets/metadata";
|
||||||
|
import { RELAY_URL, GROUP_ID } from "$lib/config";
|
||||||
|
import { auth } from "$lib/auth.svelte";
|
||||||
|
import { ingestNostrUser } from "$lib/profiles.svelte";
|
||||||
|
|
||||||
|
export type ChatMessageData = {
|
||||||
|
id: string;
|
||||||
|
pubkey: string;
|
||||||
|
createdAt: number;
|
||||||
|
content: string;
|
||||||
|
replyToId?: string;
|
||||||
|
replyToPubkey?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
let messages = $state<ChatMessageData[]>([]);
|
||||||
|
let profiles = $state<Record<string, NostrUser>>({});
|
||||||
|
let started = false;
|
||||||
|
let livePool: SimplePool | null = null;
|
||||||
|
let liveSub: { close(): void } | null = null;
|
||||||
|
|
||||||
|
export const chatStore = {
|
||||||
|
get messages() {
|
||||||
|
return messages;
|
||||||
|
},
|
||||||
|
get profiles() {
|
||||||
|
return profiles;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getChatMessage(id: string): ChatMessageData | undefined {
|
||||||
|
return messages.find((m) => m.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadProfile(pubkey: string) {
|
||||||
|
if (profiles[pubkey]) return;
|
||||||
|
try {
|
||||||
|
const user = await loadNostrUser(pubkey);
|
||||||
|
profiles = { ...profiles, [pubkey]: user };
|
||||||
|
ingestNostrUser(user);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[chat] profile load failed", pubkey, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventToMessage(ev: Event): ChatMessageData {
|
||||||
|
const qTag = ev.tags.find((t) => t[0] === "q");
|
||||||
|
return {
|
||||||
|
id: ev.id,
|
||||||
|
pubkey: ev.pubkey,
|
||||||
|
createdAt: ev.created_at,
|
||||||
|
content: ev.content,
|
||||||
|
replyToId: qTag?.[1],
|
||||||
|
replyToPubkey: qTag?.[3],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function ingestEvent(ev: Event) {
|
||||||
|
if (messages.some((m) => m.id === ev.id)) return;
|
||||||
|
const m = eventToMessage(ev);
|
||||||
|
let idx = messages.length;
|
||||||
|
while (idx > 0 && messages[idx - 1].createdAt > m.createdAt) idx--;
|
||||||
|
messages = [...messages.slice(0, idx), m, ...messages.slice(idx)];
|
||||||
|
loadProfile(ev.pubkey);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startChat() {
|
||||||
|
if (started) return;
|
||||||
|
started = true;
|
||||||
|
|
||||||
|
const pool = new SimplePool();
|
||||||
|
try {
|
||||||
|
const events = await pool.querySync([RELAY_URL], {
|
||||||
|
kinds: [9],
|
||||||
|
"#h": [GROUP_ID],
|
||||||
|
limit: 100,
|
||||||
|
});
|
||||||
|
for (const ev of events) ingestEvent(ev);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[chat] initial load failed", e);
|
||||||
|
} finally {
|
||||||
|
pool.close([RELAY_URL]);
|
||||||
|
}
|
||||||
|
|
||||||
|
livePool = new SimplePool();
|
||||||
|
liveSub = livePool.subscribeMany(
|
||||||
|
[RELAY_URL],
|
||||||
|
{
|
||||||
|
kinds: [9],
|
||||||
|
"#h": [GROUP_ID],
|
||||||
|
since: Math.floor(Date.now() / 1000),
|
||||||
|
},
|
||||||
|
{ onevent: (ev) => ingestEvent(ev) },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopChat() {
|
||||||
|
liveSub?.close();
|
||||||
|
livePool?.close([RELAY_URL]);
|
||||||
|
liveSub = null;
|
||||||
|
livePool = null;
|
||||||
|
started = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendChatMessage(
|
||||||
|
content: string,
|
||||||
|
replyTo?: { id: string; pubkey: string },
|
||||||
|
) {
|
||||||
|
if (!auth.signer) throw new Error("Not logged in");
|
||||||
|
const ownPubkey = await auth.signer.getPublicKey();
|
||||||
|
|
||||||
|
const previousRefs = messages
|
||||||
|
.filter((m) => m.pubkey !== ownPubkey)
|
||||||
|
.slice(-3)
|
||||||
|
.map((m) => m.id.slice(0, 8));
|
||||||
|
|
||||||
|
const tags: string[][] = [["h", GROUP_ID]];
|
||||||
|
if (replyTo) {
|
||||||
|
tags.push(["q", replyTo.id, RELAY_URL, replyTo.pubkey]);
|
||||||
|
if (replyTo.pubkey !== ownPubkey) tags.push(["p", replyTo.pubkey]);
|
||||||
|
}
|
||||||
|
if (previousRefs.length > 0) tags.push(["previous", ...previousRefs]);
|
||||||
|
|
||||||
|
const signed = await auth.signer.signEvent({
|
||||||
|
kind: 9,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags,
|
||||||
|
content,
|
||||||
|
});
|
||||||
|
|
||||||
|
const pool = new SimplePool();
|
||||||
|
try {
|
||||||
|
const timeout = new Promise<never>((_, reject) =>
|
||||||
|
setTimeout(
|
||||||
|
() => reject(new Error("Relay did not respond in time")),
|
||||||
|
8000,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await Promise.race([
|
||||||
|
Promise.all(pool.publish([RELAY_URL], signed)),
|
||||||
|
timeout,
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
pool.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
ingestEvent(signed);
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,14 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { chatMessages } from "$lib/mock";
|
import { tick } from "svelte";
|
||||||
|
import {
|
||||||
|
chatStore,
|
||||||
|
getChatMessage,
|
||||||
|
sendChatMessage,
|
||||||
|
type ChatMessageData,
|
||||||
|
} from "$lib/chat.svelte";
|
||||||
|
import { auth, openLogin } from "$lib/auth.svelte";
|
||||||
|
import { withJoin } from "$lib/join.svelte";
|
||||||
|
import type { NostrUser } from "@nostr/gadgets/metadata";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
expanded?: boolean;
|
expanded?: boolean;
|
||||||
|
|
@ -9,15 +18,104 @@
|
||||||
let { expanded = false, onToggle }: Props = $props();
|
let { expanded = false, onToggle }: Props = $props();
|
||||||
|
|
||||||
let asideEl: HTMLElement;
|
let asideEl: HTMLElement;
|
||||||
|
let listEl = $state<HTMLDivElement | null>(null);
|
||||||
|
let inputEl = $state<HTMLTextAreaElement | null>(null);
|
||||||
let openMenuId = $state<string | null>(null);
|
let openMenuId = $state<string | null>(null);
|
||||||
|
let replyTarget = $state<ChatMessageData | null>(null);
|
||||||
|
let inputValue = $state("");
|
||||||
|
let sending = $state(false);
|
||||||
|
let sendError = $state<string | null>(null);
|
||||||
|
|
||||||
function formatTime(iso: string) {
|
const messages = $derived(chatStore.messages);
|
||||||
return new Date(iso).toLocaleTimeString([], {
|
const profiles = $derived(chatStore.profiles);
|
||||||
|
|
||||||
|
function resolveAuthor(pubkey: string) {
|
||||||
|
const u: NostrUser | undefined = profiles[pubkey];
|
||||||
|
return {
|
||||||
|
name: u?.shortName ?? pubkey.slice(0, 8),
|
||||||
|
picture: u?.metadata?.picture,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(ts: number) {
|
||||||
|
return new Date(ts * 1000).toLocaleTimeString([], {
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function truncate(s: string, n = 60) {
|
||||||
|
const t = s.replace(/\s+/g, " ").trim();
|
||||||
|
return t.length > n ? t.slice(0, n) + "…" : t;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startReply(msg: ChatMessageData) {
|
||||||
|
replyTarget = msg;
|
||||||
|
openMenuId = null;
|
||||||
|
tick().then(() => inputEl?.focus());
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelReply() {
|
||||||
|
replyTarget = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
const content = inputValue.trim();
|
||||||
|
if (!content) return;
|
||||||
|
if (!auth.user) {
|
||||||
|
openLogin();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sending = true;
|
||||||
|
sendError = null;
|
||||||
|
const reply = replyTarget
|
||||||
|
? { id: replyTarget.id, pubkey: replyTarget.pubkey }
|
||||||
|
: undefined;
|
||||||
|
try {
|
||||||
|
await withJoin(async () => {
|
||||||
|
await sendChatMessage(content, reply);
|
||||||
|
inputValue = "";
|
||||||
|
replyTarget = null;
|
||||||
|
userScrolledUp = false;
|
||||||
|
await tick();
|
||||||
|
if (listEl) listEl.scrollTop = listEl.scrollHeight;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
sendError = e instanceof Error ? e.message : "Failed to send";
|
||||||
|
} finally {
|
||||||
|
sending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
submit();
|
||||||
|
} else if (e.key === "Escape" && replyTarget) {
|
||||||
|
e.preventDefault();
|
||||||
|
cancelReply();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track whether user scrolled away from the bottom to read older messages.
|
||||||
|
let userScrolledUp = false;
|
||||||
|
|
||||||
|
function onListScroll() {
|
||||||
|
if (!listEl) return;
|
||||||
|
const distance =
|
||||||
|
listEl.scrollHeight - listEl.scrollTop - listEl.clientHeight;
|
||||||
|
userScrolledUp = distance > 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anchor to bottom on new messages, unless user is reading older ones.
|
||||||
|
$effect(() => {
|
||||||
|
messages.length;
|
||||||
|
if (!listEl || userScrolledUp) return;
|
||||||
|
tick().then(() => {
|
||||||
|
if (listEl) listEl.scrollTop = listEl.scrollHeight;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!expanded) return;
|
if (!expanded) return;
|
||||||
function handleClick(e: MouseEvent) {
|
function handleClick(e: MouseEvent) {
|
||||||
|
|
@ -70,36 +168,60 @@
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 overflow-y-auto space-y-5 pb-4">
|
<div
|
||||||
{#each chatMessages as msg}
|
bind:this={listEl}
|
||||||
|
onscroll={onListScroll}
|
||||||
|
class="flex-1 overflow-y-auto flex flex-col"
|
||||||
|
>
|
||||||
|
{#if messages.length === 0}
|
||||||
|
<div class="m-auto text-center text-sm text-gray-400 py-8">
|
||||||
|
No messages yet.
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="mt-auto space-y-4 pb-4">
|
||||||
|
{#each messages as msg (msg.id)}
|
||||||
|
{@const author = resolveAuthor(msg.pubkey)}
|
||||||
|
{@const parent = msg.replyToId ? getChatMessage(msg.replyToId) : null}
|
||||||
|
{@const parentAuthor = parent ? resolveAuthor(parent.pubkey) : null}
|
||||||
<div>
|
<div>
|
||||||
<!-- Header: pfp, name, time -->
|
|
||||||
<div class="flex items-center gap-2 mb-1">
|
<div class="flex items-center gap-2 mb-1">
|
||||||
|
{#if author.picture}
|
||||||
<img
|
<img
|
||||||
src={msg.author.picture}
|
src={author.picture}
|
||||||
alt={msg.author.name}
|
alt=""
|
||||||
class="h-6 w-6 shrink-0 rounded-full"
|
class="h-6 w-6 shrink-0 rounded-full object-cover"
|
||||||
/>
|
/>
|
||||||
<span class="font-medium text-gray-600">{msg.author.name}</span>
|
{:else}
|
||||||
|
<span
|
||||||
|
class="h-6 w-6 shrink-0 rounded-full bg-gray-200 flex items-center justify-center text-xs font-semibold text-gray-500"
|
||||||
|
>
|
||||||
|
{author.name[0].toUpperCase()}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
<span class="font-medium text-gray-600">{author.name}</span>
|
||||||
<span class="ml-auto text-xs text-gray-400"
|
<span class="ml-auto text-xs text-gray-400"
|
||||||
>{formatTime(msg.createdAt)}</span
|
>{formatTime(msg.createdAt)}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<!-- Content + reactions -->
|
|
||||||
<div class="mt-0.5">
|
<div class="mt-0.5">
|
||||||
<p class=" text-gray-700 leading-5">{msg.content}</p>
|
{#if msg.replyToId}
|
||||||
<div class="mt-1.5 flex items-center gap-2">
|
<div
|
||||||
{#each msg.reactions ?? [] as r}
|
class="mb-1 border-l-2 border-gray-300 pl-2 text-xs text-gray-500"
|
||||||
<span class="flex items-center gap-0.5 text-xs text-gray-500">
|
>
|
||||||
{r.emoji}
|
{#if parent}
|
||||||
{r.count}
|
<span class="font-medium">{parentAuthor?.name}</span>:
|
||||||
</span>
|
{truncate(parent.content)}
|
||||||
{/each}
|
{:else}
|
||||||
{#if msg.zaps}
|
<span class="italic">Replying to a message</span>
|
||||||
<span class="flex items-center gap-0.5 text-xs text-gray-500">
|
|
||||||
⚡{msg.zaps}
|
|
||||||
</span>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<p
|
||||||
|
class="text-gray-700 leading-5 whitespace-pre-wrap break-words"
|
||||||
|
>
|
||||||
|
{msg.content}
|
||||||
|
</p>
|
||||||
|
<div class="mt-0.5 flex items-center gap-2">
|
||||||
<div class="relative ml-auto">
|
<div class="relative ml-auto">
|
||||||
<button
|
<button
|
||||||
onclick={(e) => {
|
onclick={(e) => {
|
||||||
|
|
@ -124,13 +246,22 @@
|
||||||
<div
|
<div
|
||||||
class="absolute right-0 bottom-6 z-20 w-36 rounded-lg border border-gray-100 bg-white py-1 shadow-lg text-sm"
|
class="absolute right-0 bottom-6 z-20 w-36 rounded-lg border border-gray-100 bg-white py-1 shadow-lg text-sm"
|
||||||
>
|
>
|
||||||
<button class="w-full px-3 py-1.5 text-left hover:bg-gray-50"
|
<button
|
||||||
>React</button
|
disabled
|
||||||
|
class="w-full px-3 py-1.5 text-left text-gray-400 cursor-not-allowed"
|
||||||
|
aria-disabled="true">React</button
|
||||||
>
|
>
|
||||||
<button class="w-full px-3 py-1.5 text-left hover:bg-gray-50"
|
<button
|
||||||
>Zap</button
|
disabled
|
||||||
|
class="w-full px-3 py-1.5 text-left text-gray-400 cursor-not-allowed"
|
||||||
|
aria-disabled="true">Zap</button
|
||||||
>
|
>
|
||||||
<button class="w-full px-3 py-1.5 text-left hover:bg-gray-50"
|
<button
|
||||||
|
onclick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
startReply(msg);
|
||||||
|
}}
|
||||||
|
class="w-full px-3 py-1.5 text-left hover:bg-gray-50"
|
||||||
>Reply</button
|
>Reply</button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -141,12 +272,44 @@
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="border-t border-gray-200 pt-4">
|
<div class="border-t border-gray-200 pt-4">
|
||||||
<input
|
{#if replyTarget}
|
||||||
type="text"
|
{@const replyAuthor = resolveAuthor(replyTarget.pubkey)}
|
||||||
placeholder="Message..."
|
<div
|
||||||
class="w-full rounded border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-brand"
|
class="mb-2 flex items-start gap-2 rounded bg-gray-50 px-2 py-1.5 text-xs text-gray-600"
|
||||||
/>
|
>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<span class="text-gray-400">↳ Reply to </span>
|
||||||
|
<span class="font-medium">{replyAuthor.name}</span>:
|
||||||
|
<span class="text-gray-500">{truncate(replyTarget.content, 80)}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onclick={cancelReply}
|
||||||
|
class="shrink-0 text-gray-400 hover:text-gray-600"
|
||||||
|
aria-label="Cancel reply"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if sendError}
|
||||||
|
<div
|
||||||
|
class="mb-2 rounded bg-red-50 px-2 py-1.5 text-xs text-red-700 border border-red-200"
|
||||||
|
>
|
||||||
|
{sendError}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<textarea
|
||||||
|
bind:this={inputEl}
|
||||||
|
bind:value={inputValue}
|
||||||
|
onkeydown={onKeydown}
|
||||||
|
rows="1"
|
||||||
|
disabled={sending}
|
||||||
|
placeholder={auth.user ? "Message..." : "Login to send messages"}
|
||||||
|
class="w-full resize-none rounded border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-brand disabled:opacity-50"
|
||||||
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
import { auth, restoreSession } from "$lib/auth.svelte";
|
import { auth, restoreSession } from "$lib/auth.svelte";
|
||||||
import { loadGroup } from "$lib/group.svelte";
|
import { loadGroup } from "$lib/group.svelte";
|
||||||
import { seedProfiles } from "$lib/profiles.svelte";
|
import { seedProfiles } from "$lib/profiles.svelte";
|
||||||
|
import { startChat } from "$lib/chat.svelte";
|
||||||
import { MODE } from "$lib/config";
|
import { MODE } from "$lib/config";
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
@ -22,6 +23,7 @@
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
await Promise.all([restoreSession(), loadGroup()]);
|
await Promise.all([restoreSession(), loadGroup()]);
|
||||||
seedProfiles(auth.user?.pubkey ?? null);
|
seedProfiles(auth.user?.pubkey ?? null);
|
||||||
|
if (chatEnabled) startChat();
|
||||||
});
|
});
|
||||||
|
|
||||||
let chatExpanded = $state(false);
|
let chatExpanded = $state(false);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue