Enable full mode with multiple rooms
This commit is contained in:
parent
a8b7eaf20e
commit
f6bea700f0
24 changed files with 767 additions and 286 deletions
|
|
@ -5,6 +5,7 @@
|
|||
import LeftSidebar from "$lib/components/LeftSidebar.svelte";
|
||||
import MobileMenu from "$lib/components/MobileMenu.svelte";
|
||||
import ChatSidebar from "$lib/components/ChatSidebar.svelte";
|
||||
import LatestDiscussions from "$lib/components/LatestDiscussions.svelte";
|
||||
import LoginModal from "$lib/components/LoginModal.svelte";
|
||||
import JoinModal from "$lib/components/JoinModal.svelte";
|
||||
import NewDiscussionModal from "$lib/components/NewDiscussionModal.svelte";
|
||||
|
|
@ -12,8 +13,10 @@
|
|||
import { onMount } from "svelte";
|
||||
import { auth, restoreSession } from "$lib/auth.svelte";
|
||||
import { loadGroup } from "$lib/group.svelte";
|
||||
import { loadGroups } from "$lib/groups.svelte";
|
||||
import { seedProfiles } from "$lib/profiles.svelte";
|
||||
import { startChat } from "$lib/chat.svelte";
|
||||
import { activeGroup, setActiveGroup } from "$lib/active.svelte";
|
||||
import { MODE } from "$lib/config";
|
||||
|
||||
let { children } = $props();
|
||||
|
|
@ -22,16 +25,38 @@
|
|||
const chatEnabled = true;
|
||||
|
||||
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);
|
||||
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 menuOpen = $state(false);
|
||||
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
|
||||
// a thread or room from the menu never leaves the user stranded on chat.
|
||||
|
|
@ -60,21 +85,30 @@
|
|||
>
|
||||
<LeftSidebar {mode} {activeRoom} />
|
||||
<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'}"
|
||||
>
|
||||
{@render children()}
|
||||
</main>
|
||||
{#if chatEnabled}
|
||||
{#if showChat}
|
||||
<div class="hidden w-80 shrink-0 md:block" aria-hidden="true"></div>
|
||||
<ChatSidebar
|
||||
expanded={chatExpanded}
|
||||
onToggle={() => (chatExpanded = !chatExpanded)}
|
||||
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}
|
||||
</div>
|
||||
{#if chatEnabled}
|
||||
{#if showChat}
|
||||
<nav
|
||||
class="fixed inset-x-0 bottom-0 z-30 flex border-t border-neutral-200 bg-neutral-100 md:hidden"
|
||||
aria-label="Switch view"
|
||||
|
|
|
|||
|
|
@ -1,130 +1,96 @@
|
|||
<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 DiscussionsFeed from "$lib/components/DiscussionsFeed.svelte";
|
||||
import { groupsStore } from "$lib/groups.svelte";
|
||||
import { overviewStore, loadOverview } from "$lib/overview.svelte";
|
||||
import { MODE, GROUP_ID } from "$lib/config";
|
||||
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 {
|
||||
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.
|
||||
// Full mode: load per-room activity + recent threads once the rooms are known.
|
||||
$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 {
|
||||
const diff = Math.floor(Date.now() / 1000) - ts;
|
||||
if (diff < 60) return "now";
|
||||
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];
|
||||
function authorOf(pubkey: string) {
|
||||
const u: NostrUser | undefined = overviewStore.profiles[pubkey];
|
||||
return {
|
||||
pubkey,
|
||||
name: user?.shortName ?? pubkey.slice(0, 8),
|
||||
picture: user?.metadata.picture,
|
||||
name: u?.shortName ?? pubkey.slice(0, 8),
|
||||
picture: u?.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>Discussions</title>
|
||||
<title>{MODE === "full" ? "Rooms" : "Discussions"}</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">Discussions</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>
|
||||
{#snippet pic(a: { name: string; picture?: string })}
|
||||
{#if a.picture}
|
||||
<img src={a.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"
|
||||
>
|
||||
{a.name[0].toUpperCase()}
|
||||
</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<div>
|
||||
{#each rows as thread}
|
||||
<ThreadItem {thread} />
|
||||
{#if MODE === "simple"}
|
||||
<DiscussionsFeed groupId={GROUP_ID} title="Discussions" />
|
||||
{: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}
|
||||
</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(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>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,47 @@
|
|||
<script lang="ts">
|
||||
import * as nip19 from "@nostr/tools/nip19";
|
||||
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 {
|
||||
profileStore,
|
||||
ensureProfile,
|
||||
type ProfileEntry,
|
||||
} 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.
|
||||
$effect(() => {
|
||||
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 };
|
||||
|
||||
const contacts = $derived<Contact[]>(
|
||||
|
|
@ -44,10 +72,10 @@
|
|||
<div class="mx-auto max-w-6xl">
|
||||
<h1 class="py-2 text-[1.65rem] text-brand">Contacts</h1>
|
||||
|
||||
{#if !groupStore.data}
|
||||
<p class="py-6 text-center text-sm text-neutral-400">Loading…</p>
|
||||
{#if loading}
|
||||
<p class="py-6 text-center text-neutral-400">Loading…</p>
|
||||
{: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}
|
||||
<ul class="mt-2 space-y-3">
|
||||
{#each contacts as c (c.pubkey)}
|
||||
|
|
@ -71,11 +99,11 @@
|
|||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<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)}
|
||||
</p>
|
||||
{#if c.entry?.nip05}
|
||||
<p class="truncate text-sm text-neutral-400">
|
||||
<p class="truncate text-neutral-400">
|
||||
{c.entry.nip05}
|
||||
</p>
|
||||
{/if}
|
||||
|
|
@ -91,15 +119,13 @@
|
|||
</div>
|
||||
|
||||
{#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}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if c.entry?.website || c.entry?.lud16}
|
||||
<div
|
||||
class="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-sm"
|
||||
>
|
||||
<div class="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1">
|
||||
{#if c.entry?.website}
|
||||
<a
|
||||
href={websiteHref(c.entry.website)}
|
||||
|
|
@ -117,6 +143,19 @@
|
|||
{/if}
|
||||
</div>
|
||||
{/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>
|
||||
</li>
|
||||
{/each}
|
||||
|
|
|
|||
|
|
@ -1,59 +1,12 @@
|
|||
<script lang="ts">
|
||||
import { threads, rooms, type Thread } from "$lib/mock";
|
||||
import ThreadItem, {
|
||||
type ThreadRow,
|
||||
} from "$lib/components/ThreadItem.svelte";
|
||||
import DiscussionsFeed from "$lib/components/DiscussionsFeed.svelte";
|
||||
import { groupsStore } from "$lib/groups.svelte";
|
||||
import { page } from "$app/state";
|
||||
import { auth, openLogin } from "$lib/auth.svelte";
|
||||
import { openDraft } from "$lib/draft.svelte";
|
||||
|
||||
const slug = $derived(page.params.slug);
|
||||
|
||||
function onNewTopic() {
|
||||
if (!auth.user) {
|
||||
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));
|
||||
// The slug is the NIP-29 group id; resolve its name from the loaded list.
|
||||
const slug = $derived(page.params.slug ?? "");
|
||||
const room = $derived(groupsStore.list.find((g) => g.id === slug));
|
||||
const title = $derived(room?.name ?? slug);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<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>
|
||||
<DiscussionsFeed groupId={slug} {title} />
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@
|
|||
} from "$lib/thread.svelte";
|
||||
import { auth, openLogin } from "$lib/auth.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 MessageEditor from "$lib/components/MessageEditor.svelte";
|
||||
import PostContent from "$lib/components/PostContent.svelte";
|
||||
|
|
@ -85,10 +86,11 @@
|
|||
if (!auth.user || !replyContent.trim()) return;
|
||||
const content = replyContent.trim();
|
||||
const pubkey = auth.user.pubkey;
|
||||
if (!detail) return;
|
||||
replying = true;
|
||||
replyError = null;
|
||||
try {
|
||||
await withJoin(async () => {
|
||||
await withJoin(detail.groupId, async () => {
|
||||
await sendReply(content, pubkey);
|
||||
replyContent = "";
|
||||
});
|
||||
|
|
@ -115,6 +117,12 @@
|
|||
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(() => {
|
||||
const main = document.querySelector("main");
|
||||
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"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<svg
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue