Enable full mode with multiple rooms
This commit is contained in:
parent
a8b7eaf20e
commit
f6bea700f0
24 changed files with 767 additions and 286 deletions
|
|
@ -8,6 +8,7 @@
|
|||
} from "$lib/chat.svelte";
|
||||
import { auth, openLogin } from "$lib/auth.svelte";
|
||||
import { withJoin } from "$lib/join.svelte";
|
||||
import { activeGroup } from "$lib/active.svelte";
|
||||
import type { NostrUser } from "@nostr/gadgets/metadata";
|
||||
import MentionAutocomplete from "$lib/components/MentionAutocomplete.svelte";
|
||||
import ChatContent from "$lib/components/ChatContent.svelte";
|
||||
|
|
@ -80,7 +81,7 @@
|
|||
? { id: replyTarget.id, pubkey: replyTarget.pubkey }
|
||||
: undefined;
|
||||
try {
|
||||
await withJoin(async () => {
|
||||
await withJoin(activeGroup.id, async () => {
|
||||
await sendChatMessage(content, reply);
|
||||
inputValue = "";
|
||||
replyTarget = null;
|
||||
|
|
|
|||
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">
|
||||
import { rooms } from "$lib/mock";
|
||||
import { auth, openLogin, logout } from "$lib/auth.svelte";
|
||||
import { groupStore } from "$lib/group.svelte";
|
||||
import { groupsStore } from "$lib/groups.svelte";
|
||||
import { draftState, resumeDraft } from "$lib/draft.svelte";
|
||||
|
||||
type Props = {
|
||||
|
|
@ -11,7 +11,10 @@
|
|||
|
||||
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>
|
||||
|
||||
<aside
|
||||
|
|
@ -20,7 +23,7 @@
|
|||
<div>
|
||||
<a
|
||||
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
|
||||
</a>
|
||||
|
|
@ -30,26 +33,30 @@
|
|||
<p class="text-sm text-neutral-500">{groupStore.data?.about ?? ""}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<nav class="flex-auto mt-6">
|
||||
{#each groups as group}
|
||||
<div class="mb-8">
|
||||
<p
|
||||
class="pb-1 text-xs font-semibold uppercase tracking-wider text-neutral-400"
|
||||
>
|
||||
{group}
|
||||
</p>
|
||||
{#each rooms.filter((r) => r.group === group) as room}
|
||||
<a
|
||||
href="/room/{room.slug}"
|
||||
class="flex items-center gap-2 py-1 hover:bg-neutral-100
|
||||
{activeRoom === room.slug ? ' text-brand' : 'text-neutral-700'}"
|
||||
>
|
||||
{room.name}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
<nav class="flex-auto mt-6" aria-label="Rooms">
|
||||
<p
|
||||
class="pb-1 text-xs font-semibold uppercase tracking-wider text-neutral-400"
|
||||
>
|
||||
Rooms
|
||||
</p>
|
||||
{#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
|
||||
href="/room/{room.id}"
|
||||
class="flex items-center gap-2 py-1 hover:bg-neutral-100
|
||||
{activeRoom === room.id ? ' text-brand' : 'text-neutral-700'}"
|
||||
>
|
||||
{room.name}
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
{#if activeAbout}
|
||||
<div class="mt-6">
|
||||
<p class="text-sm text-neutral-500">{activeAbout}</p>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<nav class="flex-auto mt-6">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script lang="ts">
|
||||
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 { draftState, resumeDraft } from "$lib/draft.svelte";
|
||||
|
||||
|
|
@ -13,8 +13,6 @@
|
|||
|
||||
let { open, onClose, mode, activeRoom }: Props = $props();
|
||||
|
||||
const groups = [...new Set(rooms.map((r) => r.group))];
|
||||
|
||||
function onLogin() {
|
||||
onClose();
|
||||
openLogin();
|
||||
|
|
@ -115,25 +113,24 @@
|
|||
>
|
||||
|
||||
{#if mode === "full"}
|
||||
<nav class="mt-4">
|
||||
{#each groups as group}
|
||||
<div class="mb-5">
|
||||
<p
|
||||
class="pb-1 text-xs font-semibold uppercase tracking-wider text-neutral-400"
|
||||
>
|
||||
{group}
|
||||
</p>
|
||||
{#each rooms.filter((r) => r.group === group) as room}
|
||||
<a
|
||||
href="/room/{room.slug}"
|
||||
onclick={onClose}
|
||||
class="block py-1.5 text-lg
|
||||
{activeRoom === room.slug ? 'text-brand' : 'text-neutral-700 hover:text-brand'}"
|
||||
>
|
||||
{room.name}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
<nav class="mt-4" aria-label="Rooms">
|
||||
<p
|
||||
class="pb-1 text-xs font-semibold uppercase tracking-wider text-neutral-400"
|
||||
>
|
||||
Rooms
|
||||
</p>
|
||||
{#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
|
||||
href="/room/{room.id}"
|
||||
onclick={onClose}
|
||||
class="block py-1.5 text-lg
|
||||
{activeRoom === room.id ? 'text-brand' : 'text-neutral-700 hover:text-brand'}"
|
||||
>
|
||||
{room.name}
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
<script lang="ts">
|
||||
import { groupStore } from "$lib/group.svelte";
|
||||
import { GROUP_ID } from "$lib/config";
|
||||
import { GROUP_ID, TITLE } from "$lib/config";
|
||||
|
||||
type Props = { onMenuToggle: () => void };
|
||||
let { onMenuToggle }: Props = $props();
|
||||
|
||||
const name = $derived(groupStore.data?.name ?? GROUP_ID);
|
||||
const name = $derived(TITLE || groupStore.data?.name || GROUP_ID);
|
||||
</script>
|
||||
|
||||
<header
|
||||
|
|
|
|||
|
|
@ -9,8 +9,10 @@
|
|||
removeLabel,
|
||||
publishDraft,
|
||||
} from "$lib/draft.svelte";
|
||||
import { LABELS } from "$lib/config";
|
||||
import { LABELS, MODE } from "$lib/config";
|
||||
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";
|
||||
|
||||
let labelInput = $state("");
|
||||
|
|
@ -18,6 +20,14 @@
|
|||
let suggestOpen = $state(false);
|
||||
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(
|
||||
LABELS.filter((l) => !draftState.labels.includes(l)),
|
||||
);
|
||||
|
|
@ -138,8 +148,8 @@
|
|||
<!-- Header -->
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
{#if groupStore.data?.name}
|
||||
<p class="text-sm text-neutral-700">{groupStore.data.name}</p>
|
||||
{#if targetName}
|
||||
<p class="text-sm text-neutral-700">{targetName}</p>
|
||||
{/if}
|
||||
<h2 id="newdisc-title" class="text-2xl text-brand">New discussion</h2>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@
|
|||
>
|
||||
<!-- Col 1: title + byline -->
|
||||
<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">
|
||||
<span>by</span>
|
||||
{@render avatar(thread.author, "h-5 w-5 rounded-full object-cover")}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue