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">
|
||||
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 = {
|
||||
expanded?: boolean;
|
||||
|
|
@ -9,15 +18,104 @@
|
|||
let { expanded = false, onToggle }: Props = $props();
|
||||
|
||||
let asideEl: HTMLElement;
|
||||
let listEl = $state<HTMLDivElement | null>(null);
|
||||
let inputEl = $state<HTMLTextAreaElement | 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) {
|
||||
return new Date(iso).toLocaleTimeString([], {
|
||||
const messages = $derived(chatStore.messages);
|
||||
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",
|
||||
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(() => {
|
||||
if (!expanded) return;
|
||||
function handleClick(e: MouseEvent) {
|
||||
|
|
@ -70,36 +168,60 @@
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto space-y-5 pb-4">
|
||||
{#each chatMessages as msg}
|
||||
<div
|
||||
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>
|
||||
<!-- Header: pfp, name, time -->
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
{#if author.picture}
|
||||
<img
|
||||
src={msg.author.picture}
|
||||
alt={msg.author.name}
|
||||
class="h-6 w-6 shrink-0 rounded-full"
|
||||
src={author.picture}
|
||||
alt=""
|
||||
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"
|
||||
>{formatTime(msg.createdAt)}</span
|
||||
>
|
||||
</div>
|
||||
<!-- Content + reactions -->
|
||||
<div class="mt-0.5">
|
||||
<p class=" text-gray-700 leading-5">{msg.content}</p>
|
||||
<div class="mt-1.5 flex items-center gap-2">
|
||||
{#each msg.reactions ?? [] as r}
|
||||
<span class="flex items-center gap-0.5 text-xs text-gray-500">
|
||||
{r.emoji}
|
||||
{r.count}
|
||||
</span>
|
||||
{/each}
|
||||
{#if msg.zaps}
|
||||
<span class="flex items-center gap-0.5 text-xs text-gray-500">
|
||||
⚡{msg.zaps}
|
||||
</span>
|
||||
{#if msg.replyToId}
|
||||
<div
|
||||
class="mb-1 border-l-2 border-gray-300 pl-2 text-xs text-gray-500"
|
||||
>
|
||||
{#if parent}
|
||||
<span class="font-medium">{parentAuthor?.name}</span>:
|
||||
{truncate(parent.content)}
|
||||
{:else}
|
||||
<span class="italic">Replying to a message</span>
|
||||
{/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">
|
||||
<button
|
||||
onclick={(e) => {
|
||||
|
|
@ -124,13 +246,22 @@
|
|||
<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"
|
||||
>
|
||||
<button class="w-full px-3 py-1.5 text-left hover:bg-gray-50"
|
||||
>React</button
|
||||
<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"
|
||||
>Zap</button
|
||||
<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
|
||||
>
|
||||
</div>
|
||||
|
|
@ -141,12 +272,44 @@
|
|||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-200 pt-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Message..."
|
||||
class="w-full rounded border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-brand"
|
||||
/>
|
||||
{#if replyTarget}
|
||||
{@const replyAuthor = resolveAuthor(replyTarget.pubkey)}
|
||||
<div
|
||||
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>
|
||||
</aside>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
import { auth, restoreSession } from "$lib/auth.svelte";
|
||||
import { loadGroup } from "$lib/group.svelte";
|
||||
import { seedProfiles } from "$lib/profiles.svelte";
|
||||
import { startChat } from "$lib/chat.svelte";
|
||||
import { MODE } from "$lib/config";
|
||||
|
||||
let { children } = $props();
|
||||
|
|
@ -22,6 +23,7 @@
|
|||
onMount(async () => {
|
||||
await Promise.all([restoreSession(), loadGroup()]);
|
||||
seedProfiles(auth.user?.pubkey ?? null);
|
||||
if (chatEnabled) startChat();
|
||||
});
|
||||
|
||||
let chatExpanded = $state(false);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue