diff --git a/.env.example b/.env.example index 95a0d92..c366e73 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,6 @@ PUBLIC_RELAY_URL=ws://localhost:3334 PUBLIC_GROUP_ID=mygrouprandomid +PUBLIC_TITLE= # top-bar title (full mode); empty falls back to group name PUBLIC_MODE=simple # simple | full PUBLIC_JOINCODE=no # yes | no — show invite-code field on join failure PUBLIC_LABELS= # comma-separated labels (e.g., bug,feature,question) diff --git a/src/lib/active.svelte.ts b/src/lib/active.svelte.ts new file mode 100644 index 0000000..83433a2 --- /dev/null +++ b/src/lib/active.svelte.ts @@ -0,0 +1,17 @@ +import { GROUP_ID, MODE } from "$lib/config"; + +// The group the user is currently acting within (posting, chatting, joining). +// Simple mode has exactly one group; full mode follows the room or thread being +// viewed. GROUP_ID is never assumed in full mode — this is the single source of +// truth for "which group". +let current = $state(MODE === "full" ? "" : GROUP_ID); + +export const activeGroup = { + get id() { + return current; + }, +}; + +export function setActiveGroup(id: string) { + if (id) current = id; +} diff --git a/src/lib/admins.svelte.ts b/src/lib/admins.svelte.ts new file mode 100644 index 0000000..0e9fe89 --- /dev/null +++ b/src/lib/admins.svelte.ts @@ -0,0 +1,41 @@ +import { SimplePool } from "@nostr/tools"; +import { RELAY_URL } from "$lib/config"; + +// room id -> admin pubkeys (NIP-29 kind 39001 `p` tags), across all rooms. +let byRoom = $state>({}); +let loaded = $state(false); +let loadedKey = ""; + +export const roomAdminsStore = { + get byRoom() { + return byRoom; + }, + get loaded() { + return loaded; + }, +}; + +export async function loadRoomAdmins(roomIds: string[]) { + if (roomIds.length === 0) return; + const key = [...roomIds].sort().join(","); + if (key === loadedKey) return; + loadedKey = key; + + const pool = new SimplePool(); + try { + const events = await pool.querySync([RELAY_URL], { + kinds: [39001], + "#d": roomIds, + }); + const map: Record = {}; + for (const e of events) { + const d = e.tags.find((t) => t[0] === "d")?.[1]; + if (!d) continue; + map[d] = e.tags.filter((t) => t[0] === "p" && t[1]).map((t) => t[1]); + } + byRoom = map; + } finally { + loaded = true; + pool.close([RELAY_URL]); + } +} diff --git a/src/lib/chat.svelte.ts b/src/lib/chat.svelte.ts index 49f42e8..33343c7 100644 --- a/src/lib/chat.svelte.ts +++ b/src/lib/chat.svelte.ts @@ -1,6 +1,6 @@ import { SimplePool, type Event } from "@nostr/tools"; import { loadNostrUser, type NostrUser } from "@nostr/gadgets/metadata"; -import { RELAY_URL, GROUP_ID } from "$lib/config"; +import { RELAY_URL } from "$lib/config"; import { auth } from "$lib/auth.svelte"; import { ingestNostrUser } from "$lib/profiles.svelte"; import { extractMentionPubkeys, buildPTagHints } from "$lib/mentions"; @@ -16,7 +16,8 @@ export type ChatMessageData = { let messages = $state([]); let profiles = $state>({}); -let started = false; +let currentGroup: string | null = null; +let chatReq = 0; // supersedes an in-flight load when the room changes let livePool: SimplePool | null = null; let liveSub: { close(): void } | null = null; @@ -65,17 +66,28 @@ function ingestEvent(ev: Event) { loadProfile(ev.pubkey); } -export async function startChat() { - if (started) return; - started = true; +// (Re)start chat for a group. Switching rooms tears down the previous live +// subscription, clears its messages, and reloads — a req token discards a load +// that was superseded mid-flight. +export async function startChat(groupId: string) { + if (!groupId || groupId === currentGroup) return; + currentGroup = groupId; + const req = ++chatReq; + + liveSub?.close(); + livePool?.close([RELAY_URL]); + liveSub = null; + livePool = null; + messages = []; const pool = new SimplePool(); try { const events = await pool.querySync([RELAY_URL], { kinds: [9], - "#h": [GROUP_ID], + "#h": [groupId], limit: 100, }); + if (req !== chatReq) return; for (const ev of events) ingestEvent(ev); } catch (e) { console.error("[chat] initial load failed", e); @@ -83,12 +95,14 @@ export async function startChat() { pool.close([RELAY_URL]); } + if (req !== chatReq) return; + livePool = new SimplePool(); liveSub = livePool.subscribeMany( [RELAY_URL], { kinds: [9], - "#h": [GROUP_ID], + "#h": [groupId], since: Math.floor(Date.now() / 1000), }, { onevent: (ev) => ingestEvent(ev) }, @@ -96,11 +110,13 @@ export async function startChat() { } export function stopChat() { + chatReq++; liveSub?.close(); livePool?.close([RELAY_URL]); liveSub = null; livePool = null; - started = false; + currentGroup = null; + messages = []; } export async function sendChatMessage( @@ -108,6 +124,7 @@ export async function sendChatMessage( replyTo?: { id: string; pubkey: string }, ) { if (!auth.signer) throw new Error("Not logged in"); + if (!currentGroup) throw new Error("No room selected"); const ownPubkey = await auth.signer.getPublicKey(); const previousRefs = messages @@ -123,7 +140,7 @@ export async function sendChatMessage( const hints = await buildPTagHints(notifyPubkeys); - const tags: string[][] = [["h", GROUP_ID]]; + const tags: string[][] = [["h", currentGroup]]; if (replyTo) { tags.push(["q", replyTo.id, RELAY_URL, replyTo.pubkey]); } diff --git a/src/lib/components/ChatSidebar.svelte b/src/lib/components/ChatSidebar.svelte index 1434df8..90fe62d 100644 --- a/src/lib/components/ChatSidebar.svelte +++ b/src/lib/components/ChatSidebar.svelte @@ -8,6 +8,7 @@ } from "$lib/chat.svelte"; import { auth, openLogin } from "$lib/auth.svelte"; import { withJoin } from "$lib/join.svelte"; + import { activeGroup } from "$lib/active.svelte"; import type { NostrUser } from "@nostr/gadgets/metadata"; import MentionAutocomplete from "$lib/components/MentionAutocomplete.svelte"; import ChatContent from "$lib/components/ChatContent.svelte"; @@ -80,7 +81,7 @@ ? { id: replyTarget.id, pubkey: replyTarget.pubkey } : undefined; try { - await withJoin(async () => { + await withJoin(activeGroup.id, async () => { await sendChatMessage(content, reply); inputValue = ""; replyTarget = null; diff --git a/src/lib/components/DiscussionsFeed.svelte b/src/lib/components/DiscussionsFeed.svelte new file mode 100644 index 0000000..2cf2657 --- /dev/null +++ b/src/lib/components/DiscussionsFeed.svelte @@ -0,0 +1,136 @@ + + + + {title} + + +
+
+

{title}

+
+ + +
+
+ +
+ {#each rows as thread} + + {/each} +
+ + {#if threadStore.loading && rows.length === 0} +

+ Loading discussions… +

+ {:else if !threadStore.exhausted} +
+ +
+ {:else if rows.length > 0} +

No more discussions

+ {/if} +
diff --git a/src/lib/components/LatestDiscussions.svelte b/src/lib/components/LatestDiscussions.svelte new file mode 100644 index 0000000..959bbf7 --- /dev/null +++ b/src/lib/components/LatestDiscussions.svelte @@ -0,0 +1,62 @@ + + + diff --git a/src/lib/components/LeftSidebar.svelte b/src/lib/components/LeftSidebar.svelte index f78f719..13224db 100644 --- a/src/lib/components/LeftSidebar.svelte +++ b/src/lib/components/LeftSidebar.svelte @@ -1,7 +1,7 @@