Enable full mode with multiple rooms
This commit is contained in:
parent
a8b7eaf20e
commit
f6bea700f0
24 changed files with 767 additions and 286 deletions
|
|
@ -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
17
src/lib/active.svelte.ts
Normal 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
41
src/lib/admins.svelte.ts
Normal 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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
136
src/lib/components/DiscussionsFeed.svelte
Normal file
136
src/lib/components/DiscussionsFeed.svelte
Normal 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>
|
||||||
62
src/lib/components/LatestDiscussions.svelte
Normal file
62
src/lib/components/LatestDiscussions.svelte
Normal 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>
|
||||||
|
|
@ -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}
|
<p
|
||||||
<div class="mb-8">
|
class="pb-1 text-xs font-semibold uppercase tracking-wider text-neutral-400"
|
||||||
<p
|
>
|
||||||
class="pb-1 text-xs font-semibold uppercase tracking-wider text-neutral-400"
|
Rooms
|
||||||
>
|
</p>
|
||||||
{group}
|
{#if groupsStore.list.length === 0 && !groupsStore.loaded}
|
||||||
</p>
|
<p class="py-1 text-sm text-neutral-400">Loading rooms…</p>
|
||||||
{#each rooms.filter((r) => r.group === group) as room}
|
{/if}
|
||||||
<a
|
{#each groupsStore.list as room}
|
||||||
href="/room/{room.slug}"
|
<a
|
||||||
class="flex items-center gap-2 py-1 hover:bg-neutral-100
|
href="/room/{room.id}"
|
||||||
{activeRoom === room.slug ? ' text-brand' : 'text-neutral-700'}"
|
class="flex items-center gap-2 py-1 hover:bg-neutral-100
|
||||||
>
|
{activeRoom === room.id ? ' text-brand' : 'text-neutral-700'}"
|
||||||
{room.name}
|
>
|
||||||
</a>
|
{room.name}
|
||||||
{/each}
|
</a>
|
||||||
</div>
|
|
||||||
{/each}
|
{/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">
|
||||||
|
|
|
||||||
|
|
@ -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,25 +113,24 @@
|
||||||
>
|
>
|
||||||
|
|
||||||
{#if mode === "full"}
|
{#if mode === "full"}
|
||||||
<nav class="mt-4">
|
<nav class="mt-4" aria-label="Rooms">
|
||||||
{#each groups as group}
|
<p
|
||||||
<div class="mb-5">
|
class="pb-1 text-xs font-semibold uppercase tracking-wider text-neutral-400"
|
||||||
<p
|
>
|
||||||
class="pb-1 text-xs font-semibold uppercase tracking-wider text-neutral-400"
|
Rooms
|
||||||
>
|
</p>
|
||||||
{group}
|
{#if groupsStore.list.length === 0 && !groupsStore.loaded}
|
||||||
</p>
|
<p class="py-1.5 text-base text-neutral-400">Loading rooms…</p>
|
||||||
{#each rooms.filter((r) => r.group === group) as room}
|
{/if}
|
||||||
<a
|
{#each groupsStore.list as room}
|
||||||
href="/room/{room.slug}"
|
<a
|
||||||
onclick={onClose}
|
href="/room/{room.id}"
|
||||||
class="block py-1.5 text-lg
|
onclick={onClose}
|
||||||
{activeRoom === room.slug ? 'text-brand' : 'text-neutral-700 hover:text-brand'}"
|
class="block py-1.5 text-lg
|
||||||
>
|
{activeRoom === room.id ? 'text-brand' : 'text-neutral-700 hover:text-brand'}"
|
||||||
{room.name}
|
>
|
||||||
</a>
|
{room.name}
|
||||||
{/each}
|
</a>
|
||||||
</div>
|
|
||||||
{/each}
|
{/each}
|
||||||
</nav>
|
</nav>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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")}
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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
48
src/lib/groups.svelte.ts
Normal 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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
await publishJoinRequest(groupId);
|
||||||
await action();
|
await action();
|
||||||
joined = true;
|
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 {
|
||||||
|
|
|
||||||
|
|
@ -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
128
src/lib/overview.svelte.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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],
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
<h1 class="py-2 text-[1.65rem] text-brand">Rooms</h1>
|
||||||
|
|
||||||
|
{#if groupsStore.list.length === 0}
|
||||||
|
<p class="py-6 text-sm text-neutral-400">
|
||||||
|
{groupsStore.loaded ? "No rooms available yet." : "Loading rooms…"}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="divide-y divide-neutral-100">
|
||||||
|
{#each groupsStore.list as room}
|
||||||
|
{@const act = overviewStore.activity[room.id]}
|
||||||
|
{@const adminPk = overviewStore.admins[room.id]}
|
||||||
|
{@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"
|
||||||
|
>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<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>
|
||||||
|
{/if}
|
||||||
|
</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}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
{#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(GROUP_ID)}
|
|
||||||
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>
|
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue