Enable full mode with multiple rooms

This commit is contained in:
dtonon 2026-05-19 13:15:36 +01:00
parent a8b7eaf20e
commit f6bea700f0
24 changed files with 767 additions and 286 deletions

View file

@ -1,5 +1,6 @@
PUBLIC_RELAY_URL=ws://localhost:3334 PUBLIC_RELAY_URL=ws://localhost:3334
PUBLIC_GROUP_ID=mygrouprandomid PUBLIC_GROUP_ID=mygrouprandomid
PUBLIC_TITLE= # top-bar title (full mode); empty falls back to group name
PUBLIC_MODE=simple # simple | full PUBLIC_MODE=simple # simple | full
PUBLIC_JOINCODE=no # yes | no — show invite-code field on join failure PUBLIC_JOINCODE=no # yes | no — show invite-code field on join failure
PUBLIC_LABELS= # comma-separated labels (e.g., bug,feature,question) PUBLIC_LABELS= # comma-separated labels (e.g., bug,feature,question)

17
src/lib/active.svelte.ts Normal file
View file

@ -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<string>(MODE === "full" ? "" : GROUP_ID);
export const activeGroup = {
get id() {
return current;
},
};
export function setActiveGroup(id: string) {
if (id) current = id;
}

41
src/lib/admins.svelte.ts Normal file
View file

@ -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<Record<string, string[]>>({});
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<string, string[]> = {};
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]);
}
}

View file

@ -1,6 +1,6 @@
import { SimplePool, type Event } from "@nostr/tools"; import { SimplePool, type Event } from "@nostr/tools";
import { loadNostrUser, type NostrUser } from "@nostr/gadgets/metadata"; 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 { auth } from "$lib/auth.svelte";
import { ingestNostrUser } from "$lib/profiles.svelte"; import { ingestNostrUser } from "$lib/profiles.svelte";
import { extractMentionPubkeys, buildPTagHints } from "$lib/mentions"; import { extractMentionPubkeys, buildPTagHints } from "$lib/mentions";
@ -16,7 +16,8 @@ export type ChatMessageData = {
let messages = $state<ChatMessageData[]>([]); let messages = $state<ChatMessageData[]>([]);
let profiles = $state<Record<string, NostrUser>>({}); let profiles = $state<Record<string, NostrUser>>({});
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 livePool: SimplePool | null = null;
let liveSub: { close(): void } | null = null; let liveSub: { close(): void } | null = null;
@ -65,17 +66,28 @@ function ingestEvent(ev: Event) {
loadProfile(ev.pubkey); loadProfile(ev.pubkey);
} }
export async function startChat() { // (Re)start chat for a group. Switching rooms tears down the previous live
if (started) return; // subscription, clears its messages, and reloads — a req token discards a load
started = true; // 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(); const pool = new SimplePool();
try { try {
const events = await pool.querySync([RELAY_URL], { const events = await pool.querySync([RELAY_URL], {
kinds: [9], kinds: [9],
"#h": [GROUP_ID], "#h": [groupId],
limit: 100, limit: 100,
}); });
if (req !== chatReq) return;
for (const ev of events) ingestEvent(ev); for (const ev of events) ingestEvent(ev);
} catch (e) { } catch (e) {
console.error("[chat] initial load failed", e); console.error("[chat] initial load failed", e);
@ -83,12 +95,14 @@ export async function startChat() {
pool.close([RELAY_URL]); pool.close([RELAY_URL]);
} }
if (req !== chatReq) return;
livePool = new SimplePool(); livePool = new SimplePool();
liveSub = livePool.subscribeMany( liveSub = livePool.subscribeMany(
[RELAY_URL], [RELAY_URL],
{ {
kinds: [9], kinds: [9],
"#h": [GROUP_ID], "#h": [groupId],
since: Math.floor(Date.now() / 1000), since: Math.floor(Date.now() / 1000),
}, },
{ onevent: (ev) => ingestEvent(ev) }, { onevent: (ev) => ingestEvent(ev) },
@ -96,11 +110,13 @@ export async function startChat() {
} }
export function stopChat() { export function stopChat() {
chatReq++;
liveSub?.close(); liveSub?.close();
livePool?.close([RELAY_URL]); livePool?.close([RELAY_URL]);
liveSub = null; liveSub = null;
livePool = null; livePool = null;
started = false; currentGroup = null;
messages = [];
} }
export async function sendChatMessage( export async function sendChatMessage(
@ -108,6 +124,7 @@ export async function sendChatMessage(
replyTo?: { id: string; pubkey: string }, replyTo?: { id: string; pubkey: string },
) { ) {
if (!auth.signer) throw new Error("Not logged in"); if (!auth.signer) throw new Error("Not logged in");
if (!currentGroup) throw new Error("No room selected");
const ownPubkey = await auth.signer.getPublicKey(); const ownPubkey = await auth.signer.getPublicKey();
const previousRefs = messages const previousRefs = messages
@ -123,7 +140,7 @@ export async function sendChatMessage(
const hints = await buildPTagHints(notifyPubkeys); const hints = await buildPTagHints(notifyPubkeys);
const tags: string[][] = [["h", GROUP_ID]]; const tags: string[][] = [["h", currentGroup]];
if (replyTo) { if (replyTo) {
tags.push(["q", replyTo.id, RELAY_URL, replyTo.pubkey]); tags.push(["q", replyTo.id, RELAY_URL, replyTo.pubkey]);
} }

View file

@ -8,6 +8,7 @@
} from "$lib/chat.svelte"; } from "$lib/chat.svelte";
import { auth, openLogin } from "$lib/auth.svelte"; import { auth, openLogin } from "$lib/auth.svelte";
import { withJoin } from "$lib/join.svelte"; import { withJoin } from "$lib/join.svelte";
import { activeGroup } from "$lib/active.svelte";
import type { NostrUser } from "@nostr/gadgets/metadata"; import type { NostrUser } from "@nostr/gadgets/metadata";
import MentionAutocomplete from "$lib/components/MentionAutocomplete.svelte"; import MentionAutocomplete from "$lib/components/MentionAutocomplete.svelte";
import ChatContent from "$lib/components/ChatContent.svelte"; import ChatContent from "$lib/components/ChatContent.svelte";
@ -80,7 +81,7 @@
? { id: replyTarget.id, pubkey: replyTarget.pubkey } ? { id: replyTarget.id, pubkey: replyTarget.pubkey }
: undefined; : undefined;
try { try {
await withJoin(async () => { await withJoin(activeGroup.id, async () => {
await sendChatMessage(content, reply); await sendChatMessage(content, reply);
inputValue = ""; inputValue = "";
replyTarget = null; replyTarget = null;

View file

@ -0,0 +1,136 @@
<script lang="ts">
import {
threadStore,
loadThreads,
loadMore,
type ThreadData,
type SortMode,
} from "$lib/threads.svelte";
import ThreadItem, {
type ThreadRow,
type Author,
} from "$lib/components/ThreadItem.svelte";
import SortToggle from "$lib/components/SortToggle.svelte";
import type { NostrUser } from "@nostr/gadgets/metadata";
import { auth, openLogin } from "$lib/auth.svelte";
import { openDraft } from "$lib/draft.svelte";
import { sortPref } from "$lib/sort.svelte";
import { page } from "$app/state";
type Props = {
groupId: string;
title: string;
};
let { groupId, title }: Props = $props();
function parseSort(v: string | null): SortMode | null {
return v === "new" ? "new" : v === "active" ? "active" : null;
}
// URL param is the explicit override; otherwise fall back to the saved
// preference so the sort survives Home/room navigation.
const urlSort = $derived(parseSort(page.url.searchParams.get("sort")));
const sort = $derived<SortMode>(urlSort ?? sortPref.value);
// Remember any explicit choice that arrives via the URL.
$effect(() => {
if (urlSort) sortPref.value = urlSort;
});
// Re-runs on mount and whenever the group or effective sort changes.
$effect(() => {
loadThreads(groupId, sort);
});
function onNewTopic() {
if (!auth.user) {
openLogin();
return;
}
openDraft();
}
function relativeTime(ts: number): string {
const diff = Math.floor(Date.now() / 1000) - ts;
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h`;
return `${Math.floor(diff / 86400)}d`;
}
function resolveAuthor(
pubkey: string,
profiles: Record<string, NostrUser>,
): Author {
const user = profiles[pubkey];
return {
pubkey,
name: user?.shortName ?? pubkey.slice(0, 8),
picture: user?.metadata.picture,
};
}
function toRow(
t: ThreadData,
profiles: Record<string, NostrUser>,
): ThreadRow {
return {
id: t.id,
title: t.title,
labels: t.labels,
author: resolveAuthor(t.authorPubkey, profiles),
replyCount: t.replyCount,
repliers: t.replierPubkeys.map((pk) => resolveAuthor(pk, profiles)),
lastActiveAuthor: resolveAuthor(t.latestPubkey, profiles),
lastActivity: relativeTime(t.latestAt),
};
}
const rows = $derived(
threadStore.threads.map((t) => toRow(t, threadStore.profiles)),
);
</script>
<svelte:head>
<title>{title}</title>
</svelte:head>
<div class="mx-auto max-w-6xl">
<div class="flex flex-wrap items-center justify-between gap-2 py-2">
<h1 class="text-[1.65rem] text-brand leading-7">{title}</h1>
<div class="flex items-center gap-2">
<button
onclick={onNewTopic}
class="rounded bg-brand px-4 py-1.5 md:text-sm font-medium text-white hover:bg-brand-hover md:px-6"
>
New discussion
</button>
<SortToggle {sort} />
</div>
</div>
<div>
{#each rows as thread}
<ThreadItem {thread} />
{/each}
</div>
{#if threadStore.loading && rows.length === 0}
<p class="py-6 text-center text-sm text-neutral-400">
Loading discussions…
</p>
{:else if !threadStore.exhausted}
<div class="flex justify-center py-6">
<button
onclick={() => loadMore(groupId)}
disabled={threadStore.loadingMore}
aria-busy={threadStore.loadingMore}
class="rounded border border-neutral-200 px-6 py-1.5 text-sm font-medium text-neutral-700 hover:bg-neutral-50 disabled:opacity-50"
>
{threadStore.loadingMore ? "Loading…" : "Show more"}
</button>
</div>
{:else if rows.length > 0}
<p class="py-6 text-center text-sm text-neutral-400">No more discussions</p>
{/if}
</div>

View file

@ -0,0 +1,62 @@
<script lang="ts">
import { overviewStore } from "$lib/overview.svelte";
import { groupsStore } from "$lib/groups.svelte";
import type { NostrUser } from "@nostr/gadgets/metadata";
function roomName(id: string) {
return groupsStore.list.find((g) => g.id === id)?.name ?? id;
}
function authorOf(pubkey: string) {
const u: NostrUser | undefined = overviewStore.profiles[pubkey];
return {
name: u?.shortName ?? pubkey.slice(0, 8),
picture: u?.metadata?.picture,
};
}
</script>
<aside aria-label="Latest discussions">
<h2 class="pb-2 text-[1.5rem] leading-7 text-brand">Latest discussions</h2>
{#if overviewStore.recent.length === 0}
<p class="text-sm text-neutral-400">
{overviewStore.loading ? "Loading…" : "Nothing yet."}
</p>
{:else}
<ul class="divide-y divide-neutral-100">
{#each overviewStore.recent as t}
{@const author = authorOf(t.authorPubkey)}
<li>
<a href="/thread/{t.id}" class="group block py-3">
<p
class="line-clamp-2 text-lg text-neutral-700 leading-5 group-hover:text-brand"
>
{t.title}
</p>
<div
class="mt-1.5 flex items-center gap-1.5 text-xs text-neutral-400"
>
<span>by</span>
{#if author.picture}
<img
src={author.picture}
alt=""
class="h-5 w-5 rounded-full object-cover"
/>
{:else}
<span
class="flex h-5 w-5 items-center justify-center rounded-full bg-neutral-200 text-[10px] font-semibold text-neutral-500"
aria-hidden="true"
>
{author.name[0].toUpperCase()}
</span>
{/if}
<span aria-hidden="true">in</span>
<span class="truncate">{roomName(t.groupId)}</span>
</div>
</a>
</li>
{/each}
</ul>
{/if}
</aside>

View file

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { rooms } from "$lib/mock";
import { auth, openLogin, logout } from "$lib/auth.svelte"; import { auth, openLogin, logout } from "$lib/auth.svelte";
import { groupStore } from "$lib/group.svelte"; import { groupStore } from "$lib/group.svelte";
import { groupsStore } from "$lib/groups.svelte";
import { draftState, resumeDraft } from "$lib/draft.svelte"; import { draftState, resumeDraft } from "$lib/draft.svelte";
type Props = { type Props = {
@ -11,7 +11,10 @@
let { mode, activeRoom }: Props = $props(); let { mode, activeRoom }: Props = $props();
const groups = [...new Set(rooms.map((r) => r.group))]; // The description of the room currently being viewed (full mode).
const activeAbout = $derived(
groupsStore.list.find((g) => g.id === activeRoom)?.about ?? "",
);
</script> </script>
<aside <aside
@ -20,7 +23,7 @@
<div> <div>
<a <a
href="/" href="/"
class="flex items-center gap-2 py-1 hover:bg-neutral-100 text-neutral-700}" class="flex items-center gap-2 py-1 hover:bg-neutral-100 text-neutral-700"
> >
Home Home
</a> </a>
@ -30,26 +33,30 @@
<p class="text-sm text-neutral-500">{groupStore.data?.about ?? ""}</p> <p class="text-sm text-neutral-500">{groupStore.data?.about ?? ""}</p>
</div> </div>
{:else} {:else}
<nav class="flex-auto mt-6"> <nav class="flex-auto mt-6" aria-label="Rooms">
{#each groups as group}
<div class="mb-8">
<p <p
class="pb-1 text-xs font-semibold uppercase tracking-wider text-neutral-400" class="pb-1 text-xs font-semibold uppercase tracking-wider text-neutral-400"
> >
{group} Rooms
</p> </p>
{#each rooms.filter((r) => r.group === group) as room} {#if groupsStore.list.length === 0 && !groupsStore.loaded}
<p class="py-1 text-sm text-neutral-400">Loading rooms…</p>
{/if}
{#each groupsStore.list as room}
<a <a
href="/room/{room.slug}" href="/room/{room.id}"
class="flex items-center gap-2 py-1 hover:bg-neutral-100 class="flex items-center gap-2 py-1 hover:bg-neutral-100
{activeRoom === room.slug ? ' text-brand' : 'text-neutral-700'}" {activeRoom === room.id ? ' text-brand' : 'text-neutral-700'}"
> >
{room.name} {room.name}
</a> </a>
{/each} {/each}
</div>
{/each}
</nav> </nav>
{#if activeAbout}
<div class="mt-6">
<p class="text-sm text-neutral-500">{activeAbout}</p>
</div>
{/if}
{/if} {/if}
<nav class="flex-auto mt-6"> <nav class="flex-auto mt-6">

View file

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { fly, fade } from "svelte/transition"; import { fly, fade } from "svelte/transition";
import { rooms } from "$lib/mock"; import { groupsStore } from "$lib/groups.svelte";
import { auth, openLogin, logout } from "$lib/auth.svelte"; import { auth, openLogin, logout } from "$lib/auth.svelte";
import { draftState, resumeDraft } from "$lib/draft.svelte"; import { draftState, resumeDraft } from "$lib/draft.svelte";
@ -13,8 +13,6 @@
let { open, onClose, mode, activeRoom }: Props = $props(); let { open, onClose, mode, activeRoom }: Props = $props();
const groups = [...new Set(rooms.map((r) => r.group))];
function onLogin() { function onLogin() {
onClose(); onClose();
openLogin(); openLogin();
@ -115,26 +113,25 @@
> >
{#if mode === "full"} {#if mode === "full"}
<nav class="mt-4"> <nav class="mt-4" aria-label="Rooms">
{#each groups as group}
<div class="mb-5">
<p <p
class="pb-1 text-xs font-semibold uppercase tracking-wider text-neutral-400" class="pb-1 text-xs font-semibold uppercase tracking-wider text-neutral-400"
> >
{group} Rooms
</p> </p>
{#each rooms.filter((r) => r.group === group) as room} {#if groupsStore.list.length === 0 && !groupsStore.loaded}
<p class="py-1.5 text-base text-neutral-400">Loading rooms…</p>
{/if}
{#each groupsStore.list as room}
<a <a
href="/room/{room.slug}" href="/room/{room.id}"
onclick={onClose} onclick={onClose}
class="block py-1.5 text-lg class="block py-1.5 text-lg
{activeRoom === room.slug ? 'text-brand' : 'text-neutral-700 hover:text-brand'}" {activeRoom === room.id ? 'text-brand' : 'text-neutral-700 hover:text-brand'}"
> >
{room.name} {room.name}
</a> </a>
{/each} {/each}
</div>
{/each}
</nav> </nav>
{/if} {/if}

View file

@ -1,11 +1,11 @@
<script lang="ts"> <script lang="ts">
import { groupStore } from "$lib/group.svelte"; import { groupStore } from "$lib/group.svelte";
import { GROUP_ID } from "$lib/config"; import { GROUP_ID, TITLE } from "$lib/config";
type Props = { onMenuToggle: () => void }; type Props = { onMenuToggle: () => void };
let { onMenuToggle }: Props = $props(); let { onMenuToggle }: Props = $props();
const name = $derived(groupStore.data?.name ?? GROUP_ID); const name = $derived(TITLE || groupStore.data?.name || GROUP_ID);
</script> </script>
<header <header

View file

@ -9,8 +9,10 @@
removeLabel, removeLabel,
publishDraft, publishDraft,
} from "$lib/draft.svelte"; } from "$lib/draft.svelte";
import { LABELS } from "$lib/config"; import { LABELS, MODE } from "$lib/config";
import { groupStore } from "$lib/group.svelte"; import { groupStore } from "$lib/group.svelte";
import { groupsStore } from "$lib/groups.svelte";
import { activeGroup } from "$lib/active.svelte";
import MessageEditor from "$lib/components/MessageEditor.svelte"; import MessageEditor from "$lib/components/MessageEditor.svelte";
let labelInput = $state(""); let labelInput = $state("");
@ -18,6 +20,14 @@
let suggestOpen = $state(false); let suggestOpen = $state(false);
let titleEl = $state<HTMLInputElement | null>(null); let titleEl = $state<HTMLInputElement | null>(null);
// The discussion posts to the active room, so show that room's name.
const targetName = $derived(
MODE === "full"
? (groupsStore.list.find((g) => g.id === activeGroup.id)?.name ??
groupStore.data?.name)
: groupStore.data?.name,
);
const available = $derived( const available = $derived(
LABELS.filter((l) => !draftState.labels.includes(l)), LABELS.filter((l) => !draftState.labels.includes(l)),
); );
@ -138,8 +148,8 @@
<!-- Header --> <!-- Header -->
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">
<div> <div>
{#if groupStore.data?.name} {#if targetName}
<p class="text-sm text-neutral-700">{groupStore.data.name}</p> <p class="text-sm text-neutral-700">{targetName}</p>
{/if} {/if}
<h2 id="newdisc-title" class="text-2xl text-brand">New discussion</h2> <h2 id="newdisc-title" class="text-2xl text-brand">New discussion</h2>
</div> </div>

View file

@ -40,7 +40,7 @@
> >
<!-- Col 1: title + byline --> <!-- Col 1: title + byline -->
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="text-lg mb-1.5 text-neutral-900 leading-6">{thread.title}</div> <div class="text-lg mb-1.5 text-neutral-900 leading-5">{thread.title}</div>
<div class="flex items-center gap-1.5 text-sm text-neutral-500"> <div class="flex items-center gap-1.5 text-sm text-neutral-500">
<span>by</span> <span>by</span>
{@render avatar(thread.author, "h-5 w-5 rounded-full object-cover")} {@render avatar(thread.author, "h-5 w-5 rounded-full object-cover")}

View file

@ -1,6 +1,7 @@
import { import {
PUBLIC_RELAY_URL, PUBLIC_RELAY_URL,
PUBLIC_GROUP_ID, PUBLIC_GROUP_ID,
PUBLIC_TITLE,
PUBLIC_MODE, PUBLIC_MODE,
PUBLIC_JOINCODE, PUBLIC_JOINCODE,
PUBLIC_LABELS, PUBLIC_LABELS,
@ -9,6 +10,7 @@ import {
export const RELAY_URL = PUBLIC_RELAY_URL; export const RELAY_URL = PUBLIC_RELAY_URL;
export const GROUP_ID = PUBLIC_GROUP_ID; export const GROUP_ID = PUBLIC_GROUP_ID;
export const TITLE = PUBLIC_TITLE ?? "";
export const MODE: "simple" | "full" = export const MODE: "simple" | "full" =
PUBLIC_MODE === "full" ? "full" : "simple"; PUBLIC_MODE === "full" ? "full" : "simple";
export const JOINCODE_REQUIRED = PUBLIC_JOINCODE === "yes"; export const JOINCODE_REQUIRED = PUBLIC_JOINCODE === "yes";

View file

@ -1,7 +1,8 @@
import { SimplePool } from "@nostr/tools"; import { SimplePool } from "@nostr/tools";
import { auth } from "$lib/auth.svelte"; import { auth } from "$lib/auth.svelte";
import { withJoin } from "$lib/join.svelte"; import { withJoin } from "$lib/join.svelte";
import { GROUP_ID, RELAY_URL } from "$lib/config"; import { activeGroup } from "$lib/active.svelte";
import { RELAY_URL } from "$lib/config";
import { extractMentionPubkeys, buildPTagHints } from "$lib/mentions"; import { extractMentionPubkeys, buildPTagHints } from "$lib/mentions";
let modalOpen = $state(false); let modalOpen = $state(false);
@ -81,6 +82,11 @@ export async function publishDraft(): Promise<{ ok: boolean; threadId?: string }
publishError = "Title and content are required"; publishError = "Title and content are required";
return { ok: false }; return { ok: false };
} }
const groupId = activeGroup.id;
if (!groupId) {
publishError = "No room selected";
return { ok: false };
}
publishing = true; publishing = true;
publishError = null; publishError = null;
@ -91,9 +97,9 @@ export async function publishDraft(): Promise<{ ok: boolean; threadId?: string }
const hints = await buildPTagHints(mentionPubkeys); const hints = await buildPTagHints(mentionPubkeys);
try { try {
const success = await withJoin(async () => { const success = await withJoin(groupId, async () => {
const tags: string[][] = [ const tags: string[][] = [
["h", GROUP_ID], ["h", groupId],
["title", t], ["title", t],
]; ];
for (const l of labels) tags.push(["t", l]); for (const l of labels) tags.push(["t", l]);

48
src/lib/groups.svelte.ts Normal file
View file

@ -0,0 +1,48 @@
import { SimplePool } from "@nostr/tools";
import { RELAY_URL } from "$lib/config";
export type GroupSummary = {
id: string; // NIP-29 group id (the `d` tag) — also the room URL slug
name: string;
picture?: string;
about?: string;
createdAt: number;
};
let list = $state<GroupSummary[]>([]);
let loaded = $state(false);
export const groupsStore = {
get list() {
return list;
},
get loaded() {
return loaded;
},
};
// Fetch every group the relay hosts. NIP-29 publishes one kind 39000 metadata
// event per group, so an unfiltered query enumerates them all.
export async function loadGroups() {
const pool = new SimplePool();
try {
const events = await pool.querySync([RELAY_URL], { kinds: [39000] });
list = events
.map((e) => {
const id = e.tags.find((t) => t[0] === "d")?.[1] ?? "";
return {
id,
name: e.tags.find((t) => t[0] === "name")?.[1] ?? id,
picture: e.tags.find((t) => t[0] === "picture")?.[1],
about: e.tags.find((t) => t[0] === "about")?.[1],
createdAt: e.created_at,
};
})
.filter((g) => g.id)
// Alphabetical for now; a per-group position tag will drive order later.
.sort((a, b) => a.name.localeCompare(b.name));
} finally {
loaded = true;
pool.close([RELAY_URL]);
}
}

View file

@ -1,17 +1,17 @@
import { Relay, SimplePool } from "@nostr/tools"; import { Relay, SimplePool } from "@nostr/tools";
import { auth } from "$lib/auth.svelte"; import { auth } from "$lib/auth.svelte";
import { GROUP_ID, RELAY_URL, JOINCODE_REQUIRED } from "$lib/config"; import { GROUP_ID, MODE, RELAY_URL, JOINCODE_REQUIRED } from "$lib/config";
let joined = $state(false); // Membership is per-group: full mode lets a user belong to some rooms but not
// others, so we track joined group ids rather than a single boolean.
let joinedGroups = new Set<string>();
let modalOpen = $state(false); let modalOpen = $state(false);
let modalError = $state<string | null>(null); let modalError = $state<string | null>(null);
let busy = $state(false); let busy = $state(false);
let pendingAction: (() => Promise<void>) | null = null; let pendingAction: (() => Promise<void>) | null = null;
let pendingGroup: string | null = null;
export const joinState = { export const joinState = {
get joined() {
return joined;
},
get modalOpen() { get modalOpen() {
return modalOpen; return modalOpen;
}, },
@ -27,11 +27,12 @@ export const joinState = {
}; };
export function resetJoinState() { export function resetJoinState() {
joined = false; joinedGroups = new Set();
modalOpen = false; modalOpen = false;
modalError = null; modalError = null;
busy = false; busy = false;
pendingAction = null; pendingAction = null;
pendingGroup = null;
} }
// Checks group membership: kind:9000 (per-user put-user event, lightweight) // Checks group membership: kind:9000 (per-user put-user event, lightweight)
@ -67,31 +68,32 @@ function queryHasMatch(
}); });
} }
export async function initJoinForUser(pubkey: string) { // Membership signal for one group: kind:9000 (per-user put-user event,
// lightweight) first, falling back to kind:39002 (full members list, heavier).
async function checkMembership(
pubkey: string,
groupId: string,
): Promise<boolean> {
let relay: Relay; let relay: Relay;
try { try {
relay = await Relay.connect(RELAY_URL); relay = await Relay.connect(RELAY_URL);
} catch { } catch {
return; return false;
} }
try { try {
const has9000 = await queryHasMatch(relay, { const has9000 = await queryHasMatch(relay, {
kinds: [9000], kinds: [9000],
"#h": [GROUP_ID], "#h": [groupId],
"#p": [pubkey], "#p": [pubkey],
limit: 1, limit: 1,
}); });
if (has9000) { if (has9000) return true;
joined = true; return await queryHasMatch(relay, {
return;
}
const has39002 = await queryHasMatch(relay, {
kinds: [39002], kinds: [39002],
"#d": [GROUP_ID], "#d": [groupId],
"#p": [pubkey], "#p": [pubkey],
limit: 1, limit: 1,
}); });
if (has39002) joined = true;
} finally { } finally {
try { try {
relay.close(); relay.close();
@ -99,6 +101,13 @@ export async function initJoinForUser(pubkey: string) {
} }
} }
// Simple mode pre-checks the single configured group at login so the first post
// skips the 9021. Full mode checks lazily per room when the user first acts.
export async function initJoinForUser(pubkey: string) {
if (MODE !== "simple") return;
if (await checkMembership(pubkey, GROUP_ID)) joinedGroups.add(GROUP_ID);
}
export function closeJoinModal() { export function closeJoinModal() {
if (busy) return; if (busy) return;
modalOpen = false; modalOpen = false;
@ -106,9 +115,9 @@ export function closeJoinModal() {
pendingAction = null; pendingAction = null;
} }
async function publishJoinRequest(code?: string) { async function publishJoinRequest(groupId: string, code?: string) {
if (!auth.signer) throw new Error("Not logged in"); if (!auth.signer) throw new Error("Not logged in");
const tags: string[][] = [["h", GROUP_ID]]; const tags: string[][] = [["h", groupId]];
if (code) tags.push(["code", code]); if (code) tags.push(["code", code]);
const event = await auth.signer.signEvent({ const event = await auth.signer.signEvent({
kind: 9021, kind: 9021,
@ -130,22 +139,33 @@ async function publishJoinRequest(code?: string) {
} }
} }
// Wraps a group action. If not yet joined, sends kind:9021 first, then the // Wraps an action that posts to `groupId`. If the user isn't known to be a
// action. On failure, opens the join modal so the user can retry (with code if // member, checks membership first, then sends kind:9021 before the action. On
// configured). Returns true on success, false if the modal was opened. // failure, opens the join modal so the user can retry (with code if configured).
export async function withJoin(action: () => Promise<void>): Promise<boolean> { // Returns true on success, false if the modal was opened.
if (joined) { export async function withJoin(
groupId: string,
action: () => Promise<void>,
): Promise<boolean> {
if (joinedGroups.has(groupId)) {
await action(); await action();
return true; return true;
} }
busy = true; busy = true;
try { try {
await publishJoinRequest(); const pubkey = auth.user?.pubkey;
if (pubkey && (await checkMembership(pubkey, groupId))) {
joinedGroups.add(groupId);
await action(); await action();
joined = true; return true;
}
await publishJoinRequest(groupId);
await action();
joinedGroups.add(groupId);
return true; return true;
} catch (e) { } catch (e) {
pendingAction = action; pendingAction = action;
pendingGroup = groupId;
modalError = e instanceof Error ? e.message : "Could not join the group"; modalError = e instanceof Error ? e.message : "Could not join the group";
modalOpen = true; modalOpen = true;
return false; return false;
@ -155,15 +175,16 @@ export async function withJoin(action: () => Promise<void>): Promise<boolean> {
} }
export async function retryJoin(code?: string) { export async function retryJoin(code?: string) {
if (!pendingAction || busy) return; if (!pendingAction || !pendingGroup || busy) return;
busy = true; busy = true;
modalError = null; modalError = null;
try { try {
await publishJoinRequest(code); await publishJoinRequest(pendingGroup, code);
await pendingAction(); await pendingAction();
joined = true; joinedGroups.add(pendingGroup);
modalOpen = false; modalOpen = false;
pendingAction = null; pendingAction = null;
pendingGroup = null;
} catch (e) { } catch (e) {
modalError = e instanceof Error ? e.message : "Could not join the group"; modalError = e instanceof Error ? e.message : "Could not join the group";
} finally { } finally {

View file

@ -1,9 +1,3 @@
export type Room = {
slug: string;
name: string;
group: string;
};
export type Author = { export type Author = {
pubkey: string; pubkey: string;
name: string; name: string;
@ -42,16 +36,6 @@ export const community = {
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
}; };
export const rooms: Room[] = [
{ slug: "off-topics-cafe", name: "Off topics cafe", group: "Groups" },
{ slug: "proposals", name: "Proposals", group: "Groups" },
{ slug: "development", name: "Development", group: "Groups" },
{ slug: "announcements", name: "Announcements", group: "Groups" },
{ slug: "off-topics", name: "Off topics", group: "Groups" },
{ slug: "about", name: "About", group: "Resources" },
{ slug: "help-center", name: "Help center", group: "Resources" },
];
const alice: Author = { const alice: Author = {
pubkey: "npub1alice", pubkey: "npub1alice",
name: "Alice", name: "Alice",

128
src/lib/overview.svelte.ts Normal file
View file

@ -0,0 +1,128 @@
import { Relay } from "@nostr/tools";
import type { Event } from "@nostr/tools/core";
import type { Filter } from "@nostr/tools/filter";
import { loadNostrUser, type NostrUser } from "@nostr/gadgets/metadata";
import { RELAY_URL } from "$lib/config";
import { ingestNostrUser } from "$lib/profiles.svelte";
export type RoomActivity = {
latestAt: number;
latestPubkey: string;
};
export type RecentThread = {
id: string;
title: string;
groupId: string;
authorPubkey: string;
createdAt: number;
};
let activity = $state<Record<string, RoomActivity>>({});
let admins = $state<Record<string, string>>({}); // room id -> first admin pubkey
let recent = $state<RecentThread[]>([]);
let profiles = $state<Record<string, NostrUser>>({});
let loading = $state(false);
let loadedKey = ""; // room-id set last loaded for, to avoid redundant refetches
export const overviewStore = {
get activity() {
return activity;
},
get admins() {
return admins;
},
get recent() {
return recent;
},
get profiles() {
return profiles;
},
get loading() {
return loading;
},
};
function querySync(relay: Relay, filter: Filter): Promise<Event[]> {
return new Promise((resolve) => {
const events: Event[] = [];
const sub = relay.subscribe([filter], {
onevent(e) {
events.push(e);
},
oneose() {
sub.close();
resolve(events);
},
onclose() {
resolve(events);
},
});
});
}
async function loadProfile(pubkey: string) {
if (profiles[pubkey]) return;
const user = await loadNostrUser(pubkey);
profiles[pubkey] = user;
ingestNostrUser(user);
}
// Builds the full-mode landing data: each room's last activity (for the room
// cards) and the most recent threads across every room (for the side panel).
export async function loadOverview(roomIds: string[]) {
if (roomIds.length === 0) return;
const key = [...roomIds].sort().join(",");
if (key === loadedKey) return;
loadedKey = key;
loading = true;
const relay = await Relay.connect(RELAY_URL);
try {
// One tiny query per room for its newest event (thread or reply).
const latest = await Promise.all(
roomIds.map((id) =>
querySync(relay, { kinds: [11, 1111], "#h": [id], limit: 1 }),
),
);
const act: Record<string, RoomActivity> = {};
roomIds.forEach((id, i) => {
const e = latest[i][0];
if (e) act[id] = { latestAt: e.created_at, latestPubkey: e.pubkey };
});
activity = act;
// Each room's admin (NIP-29 kind 39001, first `p` tag) for the card byline.
const adminEvents = await Promise.all(
roomIds.map((id) => querySync(relay, { kinds: [39001], "#d": [id] })),
);
const adm: Record<string, string> = {};
roomIds.forEach((id, i) => {
const pk = adminEvents[i][0]?.tags.find((t) => t[0] === "p")?.[1];
if (pk) adm[id] = pk;
});
admins = adm;
// Most recent discussions (thread OPs) across all rooms combined.
const threads = await querySync(relay, {
kinds: [11],
"#h": roomIds,
limit: 20,
});
threads.sort((a, b) => b.created_at - a.created_at);
recent = threads.map((e) => ({
id: e.id,
title: e.tags.find((t) => t[0] === "title")?.[1] ?? "(untitled)",
groupId: e.tags.find((t) => t[0] === "h")?.[1] ?? "",
authorPubkey: e.pubkey,
createdAt: e.created_at,
}));
for (const a of Object.values(act)) loadProfile(a.latestPubkey);
for (const pk of Object.values(adm)) loadProfile(pk);
for (const t of recent) loadProfile(t.authorPubkey);
} finally {
relay.close();
loading = false;
}
}

View file

@ -23,6 +23,7 @@ type ThreadDetail = {
id: string; id: string;
title: string; title: string;
labels: string[]; labels: string[];
groupId: string; // the thread's NIP-29 group (its `h` tag)
op: PostData; op: PostData;
replies: PostData[]; replies: PostData[];
}; };
@ -50,6 +51,7 @@ function loadMockThread(id: string) {
id: t.id, id: t.id,
title: t.title, title: t.title,
labels: t.tags.map((tag) => tag.label), labels: t.tags.map((tag) => tag.label),
groupId: GROUP_ID,
op: { id: t.op.id, pubkey: t.op.author.pubkey, createdAt: toUnix(t.op.createdAt), content: t.op.content }, op: { id: t.op.id, pubkey: t.op.author.pubkey, createdAt: toUnix(t.op.createdAt), content: t.op.content },
replies: (t.op.replies ?? []).map((r) => ({ replies: (t.op.replies ?? []).map((r) => ({
id: r.id, pubkey: r.author.pubkey, createdAt: toUnix(r.createdAt), content: r.content, id: r.id, pubkey: r.author.pubkey, createdAt: toUnix(r.createdAt), content: r.content,
@ -87,6 +89,7 @@ export async function loadThread(id: string) {
id: event.id, id: event.id,
title: event.tags.find((t) => t[0] === "title")?.[1] ?? "(untitled)", title: event.tags.find((t) => t[0] === "title")?.[1] ?? "(untitled)",
labels: event.tags.filter((t) => t[0] === "t" && t[1]).map((t) => t[1]), labels: event.tags.filter((t) => t[0] === "t" && t[1]).map((t) => t[1]),
groupId: event.tags.find((t) => t[0] === "h")?.[1] ?? GROUP_ID,
op: { op: {
id: event.id, id: event.id,
pubkey: event.pubkey, pubkey: event.pubkey,
@ -133,7 +136,7 @@ export async function sendReply(content: string, ownPubkey: string) {
const opHint = hints.get(detail.op.pubkey) ?? RELAY_URL; const opHint = hints.get(detail.op.pubkey) ?? RELAY_URL;
const tags: string[][] = [ const tags: string[][] = [
["h", GROUP_ID], ["h", detail.groupId],
["E", detail.id, opHint, detail.op.pubkey], ["E", detail.id, opHint, detail.op.pubkey],
["K", "11"], ["K", "11"],
["P", detail.op.pubkey, opHint], ["P", detail.op.pubkey, opHint],

View file

@ -5,6 +5,7 @@
import LeftSidebar from "$lib/components/LeftSidebar.svelte"; import LeftSidebar from "$lib/components/LeftSidebar.svelte";
import MobileMenu from "$lib/components/MobileMenu.svelte"; import MobileMenu from "$lib/components/MobileMenu.svelte";
import ChatSidebar from "$lib/components/ChatSidebar.svelte"; import ChatSidebar from "$lib/components/ChatSidebar.svelte";
import LatestDiscussions from "$lib/components/LatestDiscussions.svelte";
import LoginModal from "$lib/components/LoginModal.svelte"; import LoginModal from "$lib/components/LoginModal.svelte";
import JoinModal from "$lib/components/JoinModal.svelte"; import JoinModal from "$lib/components/JoinModal.svelte";
import NewDiscussionModal from "$lib/components/NewDiscussionModal.svelte"; import NewDiscussionModal from "$lib/components/NewDiscussionModal.svelte";
@ -12,8 +13,10 @@
import { onMount } from "svelte"; import { onMount } from "svelte";
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 { loadGroups } from "$lib/groups.svelte";
import { seedProfiles } from "$lib/profiles.svelte"; import { seedProfiles } from "$lib/profiles.svelte";
import { startChat } from "$lib/chat.svelte"; import { startChat } from "$lib/chat.svelte";
import { activeGroup, setActiveGroup } from "$lib/active.svelte";
import { MODE } from "$lib/config"; import { MODE } from "$lib/config";
let { children } = $props(); let { children } = $props();
@ -22,16 +25,38 @@
const chatEnabled = true; const chatEnabled = true;
onMount(async () => { onMount(async () => {
await Promise.all([restoreSession(), loadGroup()]); const tasks = [restoreSession(), loadGroup()];
if (mode === "full") tasks.push(loadGroups());
await Promise.all(tasks);
seedProfiles(auth.user?.pubkey ?? null); seedProfiles(auth.user?.pubkey ?? null);
if (chatEnabled) startChat(); });
// Full mode: the room route defines the active group. Thread pages set it
// themselves from the thread's own group, so only track the slug here.
$effect(() => {
if (mode === "full" && page.params.slug) setActiveGroup(page.params.slug);
});
// Chat follows the active group, re-subscribing whenever the room changes.
$effect(() => {
if (chatEnabled && activeGroup.id) startChat(activeGroup.id);
}); });
let chatExpanded = $state(false); let chatExpanded = $state(false);
let menuOpen = $state(false); let menuOpen = $state(false);
let mobileView = $state<"forum" | "chat">("forum"); let mobileView = $state<"forum" | "chat">("forum");
const activeRoom = $derived(page.params.slug ?? ""); // Room pages highlight via their slug; thread pages highlight the room the
// thread belongs to (tracked in activeGroup).
const activeRoom = $derived(
page.params.slug ??
(page.url.pathname.startsWith("/thread/") ? activeGroup.id : ""),
);
// The full-mode landing page renders its own right column (latest
// discussions) and has no single room to chat in, so suppress room chat there.
const isHomeFull = $derived(mode === "full" && page.url.pathname === "/");
const showChat = $derived(chatEnabled && !isHomeFull);
// On mobile a route change should always land on the forum pane, so opening // On mobile a route change should always land on the forum pane, so opening
// a thread or room from the menu never leaves the user stranded on chat. // a thread or room from the menu never leaves the user stranded on chat.
@ -60,21 +85,30 @@
> >
<LeftSidebar {mode} {activeRoom} /> <LeftSidebar {mode} {activeRoom} />
<main <main
class="min-h-[calc(100dvh_-_4rem)] bg-white px-6 pt-8 pb-20 shadow-lg md:min-h-0 md:flex-1 md:overflow-y-auto md:rounded-t-xl md:px-10 md:pt-6 class="min-h-[calc(100dvh_-_4rem)] bg-white px-6 pt-8 pb-20 shadow-lg md:min-h-0 md:overflow-y-auto md:rounded-t-xl md:px-10 md:pt-6 {isHomeFull
? 'md:flex-[3]'
: 'md:flex-1'}
{mobileView === 'chat' ? 'hidden md:block' : 'block'}" {mobileView === 'chat' ? 'hidden md:block' : 'block'}"
> >
{@render children()} {@render children()}
</main> </main>
{#if chatEnabled} {#if showChat}
<div class="hidden w-80 shrink-0 md:block" aria-hidden="true"></div> <div class="hidden w-80 shrink-0 md:block" aria-hidden="true"></div>
<ChatSidebar <ChatSidebar
expanded={chatExpanded} expanded={chatExpanded}
onToggle={() => (chatExpanded = !chatExpanded)} onToggle={() => (chatExpanded = !chatExpanded)}
mobileActive={mobileView === "chat"} mobileActive={mobileView === "chat"}
/> />
{:else if isHomeFull}
<!-- Own panel (40%) so the gray gutter matches the main↔chat gap. -->
<div
class="mt-2 min-w-0 bg-white px-6 pt-8 pb-20 shadow-lg md:mt-0 md:flex-[2] md:overflow-y-auto md:rounded-t-xl md:px-8 md:pt-6"
>
<LatestDiscussions />
</div>
{/if} {/if}
</div> </div>
{#if chatEnabled} {#if showChat}
<nav <nav
class="fixed inset-x-0 bottom-0 z-30 flex border-t border-neutral-200 bg-neutral-100 md:hidden" class="fixed inset-x-0 bottom-0 z-30 flex border-t border-neutral-200 bg-neutral-100 md:hidden"
aria-label="Switch view" aria-label="Switch view"

View file

@ -1,130 +1,96 @@
<script lang="ts"> <script lang="ts">
import { import DiscussionsFeed from "$lib/components/DiscussionsFeed.svelte";
threadStore, import { groupsStore } from "$lib/groups.svelte";
loadThreads, import { overviewStore, loadOverview } from "$lib/overview.svelte";
loadMore, import { MODE, GROUP_ID } from "$lib/config";
type ThreadData,
type SortMode,
} from "$lib/threads.svelte";
import ThreadItem, {
type ThreadRow,
type Author,
} from "$lib/components/ThreadItem.svelte";
import SortToggle from "$lib/components/SortToggle.svelte";
import type { NostrUser } from "@nostr/gadgets/metadata"; import type { NostrUser } from "@nostr/gadgets/metadata";
import { auth, openLogin } from "$lib/auth.svelte";
import { openDraft } from "$lib/draft.svelte";
import { GROUP_ID } from "$lib/config";
import { sortPref } from "$lib/sort.svelte";
import { page } from "$app/state";
function parseSort(v: string | null): SortMode | null { // Full mode: load per-room activity + recent threads once the rooms are known.
return v === "new" ? "new" : v === "active" ? "active" : null;
}
// URL param is the explicit override; otherwise fall back to the saved
// preference so the sort survives Home/room navigation.
const urlSort = $derived(parseSort(page.url.searchParams.get("sort")));
const sort = $derived<SortMode>(urlSort ?? sortPref.value);
// Remember any explicit choice that arrives via the URL.
$effect(() => { $effect(() => {
if (urlSort) sortPref.value = urlSort; if (MODE !== "full") return;
const ids = groupsStore.list.map((g) => g.id);
if (ids.length > 0) loadOverview(ids);
}); });
// Re-runs on mount and whenever the effective sort changes.
$effect(() => {
loadThreads(GROUP_ID, sort);
});
function onNewTopic() {
if (!auth.user) {
openLogin();
return;
}
openDraft();
}
function relativeTime(ts: number): string { function relativeTime(ts: number): string {
const diff = Math.floor(Date.now() / 1000) - ts; const diff = Math.floor(Date.now() / 1000) - ts;
if (diff < 60) return "now";
if (diff < 3600) return `${Math.floor(diff / 60)}m`; if (diff < 3600) return `${Math.floor(diff / 60)}m`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h`; if (diff < 86400) return `${Math.floor(diff / 3600)}h`;
return `${Math.floor(diff / 86400)}d`; return `${Math.floor(diff / 86400)}d`;
} }
function resolveAuthor( function authorOf(pubkey: string) {
pubkey: string, const u: NostrUser | undefined = overviewStore.profiles[pubkey];
profiles: Record<string, NostrUser>,
): Author {
const user = profiles[pubkey];
return { return {
pubkey, name: u?.shortName ?? pubkey.slice(0, 8),
name: user?.shortName ?? pubkey.slice(0, 8), picture: u?.metadata?.picture,
picture: user?.metadata.picture,
}; };
} }
function toRow(
t: ThreadData,
profiles: Record<string, NostrUser>,
): ThreadRow {
return {
id: t.id,
title: t.title,
labels: t.labels,
author: resolveAuthor(t.authorPubkey, profiles),
replyCount: t.replyCount,
repliers: t.replierPubkeys.map((pk) => resolveAuthor(pk, profiles)),
lastActiveAuthor: resolveAuthor(t.latestPubkey, profiles),
lastActivity: relativeTime(t.latestAt),
};
}
const rows = $derived(
threadStore.threads.map((t) => toRow(t, threadStore.profiles)),
);
</script> </script>
<svelte:head> <svelte:head>
<title>Discussions</title> <title>{MODE === "full" ? "Rooms" : "Discussions"}</title>
</svelte:head> </svelte:head>
<div class="mx-auto max-w-6xl"> {#snippet pic(a: { name: string; picture?: string })}
<div class="flex flex-wrap items-center justify-between gap-2 py-2"> {#if a.picture}
<h1 class="text-[1.65rem] text-brand">Discussions</h1> <img src={a.picture} alt="" class="h-5 w-5 rounded-full object-cover" />
<div class="flex items-center gap-2"> {:else}
<button <span
onclick={onNewTopic} class="flex h-5 w-5 items-center justify-center rounded-full bg-neutral-200 text-[10px] font-semibold text-neutral-500"
class="rounded bg-brand px-4 py-1.5 md:text-sm font-medium text-white hover:bg-brand-hover md:px-6" aria-hidden="true"
> >
New discussion {a.name[0].toUpperCase()}
</button> </span>
<SortToggle {sort} /> {/if}
</div> {/snippet}
</div>
<div> {#if MODE === "simple"}
{#each rows as thread} <DiscussionsFeed groupId={GROUP_ID} title="Discussions" />
<ThreadItem {thread} /> {:else}
{/each} <h1 class="py-2 text-[1.65rem] text-brand">Rooms</h1>
</div>
{#if threadStore.loading && rows.length === 0} {#if groupsStore.list.length === 0}
<p class="py-6 text-center text-sm text-neutral-400"> <p class="py-6 text-sm text-neutral-400">
Loading discussions… {groupsStore.loaded ? "No rooms available yet." : "Loading rooms…"}
</p> </p>
{:else if !threadStore.exhausted} {/if}
<div class="flex justify-center py-6">
<button <div class="divide-y divide-neutral-100">
onclick={() => loadMore(GROUP_ID)} {#each groupsStore.list as room}
disabled={threadStore.loadingMore} {@const act = overviewStore.activity[room.id]}
aria-busy={threadStore.loadingMore} {@const adminPk = overviewStore.admins[room.id]}
class="rounded border border-neutral-200 px-6 py-1.5 text-sm font-medium text-neutral-700 hover:bg-neutral-50 disabled:opacity-50" {@const admin = adminPk ? authorOf(adminPk) : null}
{@const last = act ? authorOf(act.latestPubkey) : null}
<a
href="/room/{room.id}"
class="group flex items-start justify-between gap-4 py-5"
> >
{threadStore.loadingMore ? "Loading…" : "Show more"} <div class="min-w-0">
</button> <h2 class="text-2xl text-neutral-800 group-hover:text-brand">
{room.name}
</h2>
{#if room.about}
<p class="mt-1 text-neutral-600 leading-5">{room.about}</p>
{/if}
{#if admin}
<div class="mt-2 flex items-center gap-2 text-sm text-neutral-500">
<span>Admin</span>
{@render pic(admin)}
</div> </div>
{:else if rows.length > 0}
<p class="py-6 text-center text-sm text-neutral-400">No more discussions</p>
{/if} {/if}
</div> </div>
{#if act && last}
<div class="flex shrink-0 flex-col items-center gap-0.5">
<div class="flex items-center gap-1">
{@render pic(last)}
<span class="text-neutral-800">{relativeTime(act.latestAt)}</span>
</div>
<span class="text-sm text-neutral-400">activity</span>
</div>
{/if}
</a>
{/each}
</div>
{/if}

View file

@ -1,19 +1,47 @@
<script lang="ts"> <script lang="ts">
import * as nip19 from "@nostr/tools/nip19"; import * as nip19 from "@nostr/tools/nip19";
import { groupStore } from "$lib/group.svelte"; import { groupStore } from "$lib/group.svelte";
import { groupsStore } from "$lib/groups.svelte";
import { roomAdminsStore, loadRoomAdmins } from "$lib/admins.svelte";
import { MODE } from "$lib/config";
import { import {
profileStore, profileStore,
ensureProfile, ensureProfile,
type ProfileEntry, type ProfileEntry,
} from "$lib/profiles.svelte"; } from "$lib/profiles.svelte";
const adminPubkeys = $derived(groupStore.data?.admins ?? []); // Full mode pulls admins from every room; simple mode uses the one group.
$effect(() => {
if (MODE !== "full") return;
const ids = groupsStore.list.map((g) => g.id);
if (ids.length > 0) loadRoomAdmins(ids);
});
const adminPubkeys = $derived<string[]>(
MODE === "full"
? [...new Set(Object.values(roomAdminsStore.byRoom).flat())]
: (groupStore.data?.admins ?? []),
);
// Group data is loaded by the layout; profiles stream in reactively. // Group data is loaded by the layout; profiles stream in reactively.
$effect(() => { $effect(() => {
for (const pk of adminPubkeys) ensureProfile(pk); for (const pk of adminPubkeys) ensureProfile(pk);
}); });
// Rooms a given admin manages (full mode only).
function roomsOf(pk: string) {
return groupsStore.list.filter((g) =>
roomAdminsStore.byRoom[g.id]?.includes(pk),
);
}
const loading = $derived(
MODE === "full"
? !groupsStore.loaded ||
(groupsStore.list.length > 0 && !roomAdminsStore.loaded)
: !groupStore.data,
);
type Contact = { pubkey: string; npub: string; entry?: ProfileEntry }; type Contact = { pubkey: string; npub: string; entry?: ProfileEntry };
const contacts = $derived<Contact[]>( const contacts = $derived<Contact[]>(
@ -44,10 +72,10 @@
<div class="mx-auto max-w-6xl"> <div class="mx-auto max-w-6xl">
<h1 class="py-2 text-[1.65rem] text-brand">Contacts</h1> <h1 class="py-2 text-[1.65rem] text-brand">Contacts</h1>
{#if !groupStore.data} {#if loading}
<p class="py-6 text-center text-sm text-neutral-400">Loading…</p> <p class="py-6 text-center text-neutral-400">Loading…</p>
{:else if contacts.length === 0} {:else if contacts.length === 0}
<p class="py-6 text-center text-sm text-neutral-400">No admins listed.</p> <p class="py-6 text-center text-neutral-400">No admins listed.</p>
{:else} {:else}
<ul class="mt-2 space-y-3"> <ul class="mt-2 space-y-3">
{#each contacts as c (c.pubkey)} {#each contacts as c (c.pubkey)}
@ -71,11 +99,11 @@
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="flex items-start justify-between gap-3"> <div class="flex items-start justify-between gap-3">
<div class="min-w-0"> <div class="min-w-0">
<p class="truncate font-medium text-neutral-900"> <p class="truncate text-2xl font-medium text-neutral-900">
{displayName(c)} {displayName(c)}
</p> </p>
{#if c.entry?.nip05} {#if c.entry?.nip05}
<p class="truncate text-sm text-neutral-400"> <p class="truncate text-neutral-400">
{c.entry.nip05} {c.entry.nip05}
</p> </p>
{/if} {/if}
@ -91,15 +119,13 @@
</div> </div>
{#if c.entry?.about} {#if c.entry?.about}
<p class="mt-2 text-sm whitespace-pre-line text-neutral-600"> <p class="mt-2 whitespace-pre-line text-neutral-600">
{c.entry.about} {c.entry.about}
</p> </p>
{/if} {/if}
{#if c.entry?.website || c.entry?.lud16} {#if c.entry?.website || c.entry?.lud16}
<div <div class="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1">
class="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-sm"
>
{#if c.entry?.website} {#if c.entry?.website}
<a <a
href={websiteHref(c.entry.website)} href={websiteHref(c.entry.website)}
@ -117,6 +143,19 @@
{/if} {/if}
</div> </div>
{/if} {/if}
{#if MODE === "full"}
{@const rooms = roomsOf(c.pubkey)}
{#if rooms.length > 0}
<p class="mt-2 text-neutral-500">
<span class="text-neutral-400">Manages:</span>
{#each rooms as r, i}<a
href="/room/{r.id}"
class="text-brand hover:underline">{r.name}</a
>{i < rooms.length - 1 ? ", " : ""}{/each}
</p>
{/if}
{/if}
</div> </div>
</li> </li>
{/each} {/each}

View file

@ -1,59 +1,12 @@
<script lang="ts"> <script lang="ts">
import { threads, rooms, type Thread } from "$lib/mock"; import DiscussionsFeed from "$lib/components/DiscussionsFeed.svelte";
import ThreadItem, { import { groupsStore } from "$lib/groups.svelte";
type ThreadRow,
} from "$lib/components/ThreadItem.svelte";
import { page } from "$app/state"; import { page } from "$app/state";
import { auth, openLogin } from "$lib/auth.svelte";
import { openDraft } from "$lib/draft.svelte";
const slug = $derived(page.params.slug); // The slug is the NIP-29 group id; resolve its name from the loaded list.
const slug = $derived(page.params.slug ?? "");
function onNewTopic() { const room = $derived(groupsStore.list.find((g) => g.id === slug));
if (!auth.user) { const title = $derived(room?.name ?? slug);
openLogin();
return;
}
openDraft();
}
const room = $derived(rooms.find((r) => r.slug === slug));
function mockToRow(t: Thread): ThreadRow {
return {
id: t.id,
title: t.title,
labels: t.tags.map((tag) => tag.label),
author: t.op.author,
replyCount: t.replyCount,
repliers: t.participants
.filter((p) => p.pubkey !== t.op.author.pubkey)
.slice(0, 4),
lastActiveAuthor: t.participants[t.participants.length - 1],
lastActivity: t.lastActivity,
};
}
const rows = $derived(threads.map(mockToRow));
</script> </script>
<svelte:head> <DiscussionsFeed groupId={slug} {title} />
<title>{room?.name ?? "Room"}</title>
</svelte:head>
<div class="mx-auto max-w-6xl">
<div class="flex items-center justify-between pb-2">
<h1 class="text-[1.65rem] text-brand leading-7">{room?.name ?? slug}</h1>
<button
onclick={onNewTopic}
class="rounded bg-brand px-6 py-1.5 md:text-sm font-medium text-white hover:bg-brand-hover"
>
New discussion
</button>
</div>
<div>
{#each rows as thread}
<ThreadItem {thread} />
{/each}
</div>
</div>

View file

@ -10,7 +10,8 @@
} from "$lib/thread.svelte"; } from "$lib/thread.svelte";
import { auth, openLogin } from "$lib/auth.svelte"; import { auth, openLogin } from "$lib/auth.svelte";
import { withJoin } from "$lib/join.svelte"; import { withJoin } from "$lib/join.svelte";
import { RELAY_URL } from "$lib/config"; import { setActiveGroup } from "$lib/active.svelte";
import { RELAY_URL, MODE } from "$lib/config";
import ThreadScrubber from "$lib/components/ThreadScrubber.svelte"; import ThreadScrubber from "$lib/components/ThreadScrubber.svelte";
import MessageEditor from "$lib/components/MessageEditor.svelte"; import MessageEditor from "$lib/components/MessageEditor.svelte";
import PostContent from "$lib/components/PostContent.svelte"; import PostContent from "$lib/components/PostContent.svelte";
@ -85,10 +86,11 @@
if (!auth.user || !replyContent.trim()) return; if (!auth.user || !replyContent.trim()) return;
const content = replyContent.trim(); const content = replyContent.trim();
const pubkey = auth.user.pubkey; const pubkey = auth.user.pubkey;
if (!detail) return;
replying = true; replying = true;
replyError = null; replyError = null;
try { try {
await withJoin(async () => { await withJoin(detail.groupId, async () => {
await sendReply(content, pubkey); await sendReply(content, pubkey);
replyContent = ""; replyContent = "";
}); });
@ -115,6 +117,12 @@
if (page.params.id) loadThread(page.params.id); if (page.params.id) loadThread(page.params.id);
}); });
// A thread belongs to its own group; make that the active group so chat and
// replies target the right room.
$effect(() => {
if (detail?.groupId) setActiveGroup(detail.groupId);
});
onMount(() => { onMount(() => {
const main = document.querySelector("main"); const main = document.querySelector("main");
if (!main) return; if (!main) return;
@ -284,7 +292,7 @@
class="relative z-10 bg-white pb-1 md:sticky md:-top-6 md:-mx-10 md:px-10 md:pt-6 md:-mt-6" class="relative z-10 bg-white pb-1 md:sticky md:-top-6 md:-mx-10 md:px-10 md:pt-6 md:-mt-6"
> >
<a <a
href="/" href={MODE === "full" ? `/room/${detail.groupId}` : "/"}
class="mb-1 inline-flex items-center gap-1 text-sm text-neutral-400 hover:text-brand" class="mb-1 inline-flex items-center gap-1 text-sm text-neutral-400 hover:text-brand"
> >
<svg <svg