Move join before composing, and cache membership state

This commit is contained in:
dtonon 2026-06-12 18:15:37 +01:00
parent fadac80033
commit 30f52af4b1
10 changed files with 503 additions and 149 deletions

View file

@ -19,6 +19,9 @@ export type Signer = {
let user = $state<NostrUser | null>(null);
let signer = $state<Signer | null>(null);
let loginModalOpen = $state(false);
// Optional action to run once login succeeds, so an intent like "post" started
// while logged out resumes (login => join => composer) instead of being dropped.
let afterLogin: (() => void) | null = null;
// Bumped on explicit login/logout (not on silent session restore) so the app
// can re-fetch identity-scoped data — e.g. reload the room list once the relay
// will serve the user's private/hidden groups.
@ -43,12 +46,23 @@ const PUBKEY_KEY = "nostr_pubkey";
const METHOD_KEY = "nostr_login_method";
const NSEC_KEY = "nostr_nsec";
export function openLogin() {
export function openLogin(after?: () => void) {
afterLogin = after ?? null;
loginModalOpen = true;
}
export function closeLogin() {
loginModalOpen = false;
afterLogin = null;
}
// Closes the modal and runs the pending intent (if any). Called from the login
// flows so the continuation fires with the modal already gone (no stacking).
function runAfterLogin() {
const cb = afterLogin;
afterLogin = null;
loginModalOpen = false;
cb?.();
}
function makeNsecSigner(secretKey: Uint8Array): Signer {
@ -97,6 +111,7 @@ export async function loginWithExtension() {
localStorage.removeItem(NSEC_KEY);
await setUser(pubkey);
sessionEpoch++;
runAfterLogin();
}
function parseSecretKey(input: string): Uint8Array {
@ -128,6 +143,7 @@ export async function loginWithNsec(input: string) {
localStorage.setItem(NSEC_KEY, nsec);
await setUser(pubkey);
sessionEpoch++;
runAfterLogin();
}
export function logout() {

View file

@ -10,7 +10,12 @@
import { auth, openLogin } from "$lib/auth.svelte";
import { isGroupAdmin } from "$lib/admins.svelte";
import { requestDelete } from "$lib/moderation.svelte";
import { withJoin } from "$lib/join.svelte";
import {
withJoin,
membershipOf,
ensureMembershipChecked,
openJoinModal,
} from "$lib/join.svelte";
import { activeGroup } from "$lib/active.svelte";
import type { NostrUser } from "@nostr/gadgets/metadata";
import MentionAutocomplete from "$lib/components/MentionAutocomplete.svelte";
@ -46,6 +51,24 @@
!!auth.user && isGroupAdmin(auth.user.pubkey, activeGroup.id),
);
// Sending needs membership regardless of flags. Only gate a confirmed guest;
// while membership resolves the input stays (withJoin nets a stray send), so a
// member never flashes "Join to chat".
const joinToChat = $derived(
!!auth.user && membershipOf(activeGroup.id) === "guest",
);
// Resolve membership on entry. Depends on auth.user so a silent session
// restore re-runs the check.
$effect(() => {
auth.user;
if (activeGroup.id) ensureMembershipChecked(activeGroup.id);
});
function onJoinToChat() {
openJoinModal(activeGroup.id, () => tick().then(() => inputEl?.focus()));
}
function requestDeleteMessage(msg: ChatMessageData) {
openMenuId = null;
requestDelete(
@ -431,6 +454,14 @@
{sendError}
</div>
{/if}
{#if joinToChat}
<button
onclick={onJoinToChat}
class="bg-accent hover:bg-accent-hover w-full rounded px-3 py-2 text-sm font-medium text-white"
>
Join to chat
</button>
{:else}
<MentionAutocomplete
bind:this={inputEl}
bind:value={inputValue}
@ -441,5 +472,6 @@
{contextPubkeys}
textareaClass="w-full resize-none rounded border border-neutral-200 dark:border-neutral-700 px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-accent disabled:opacity-50"
/>
{/if}
</div>
</aside>

View file

@ -14,6 +14,12 @@
import type { NostrUser } from "@nostr/gadgets/metadata";
import { auth, openLogin } from "$lib/auth.svelte";
import { openDraft } from "$lib/draft.svelte";
import { getGroupFlags } from "$lib/group.svelte";
import {
membershipOf,
ensureMembershipChecked,
openJoinModal,
} from "$lib/join.svelte";
import { sortPref } from "$lib/sort.svelte";
import { page } from "$app/state";
@ -38,17 +44,47 @@
if (urlSort) sortPref.value = urlSort;
});
const flags = $derived(getGroupFlags(groupId));
const member = $derived(membershipOf(groupId));
// The relay requires membership to post to any group, so resolve it on entry
// for every room. Depends on auth.user so a silent session restore re-runs it.
$effect(() => {
auth.user;
ensureMembershipChecked(groupId);
});
const showPrivateGate = $derived(!!flags?.isPrivate && member === "guest");
const checkingAccess = $derived(!!flags?.isPrivate && member === "unknown");
// Posting needs membership regardless of flags. Only gate a confirmed guest;
// while membership is still resolving the click awaits the check, so a member
// never flashes "Join to post".
const joinToPost = $derived(!!auth.user && member === "guest");
// Re-runs on mount and whenever the group or effective sort changes.
$effect(() => {
loadThreads(groupId, sort);
});
function onNewTopic() {
async function onNewTopic() {
if (!auth.user) {
openLogin(onNewTopic);
return;
}
await ensureMembershipChecked(groupId);
if (membershipOf(groupId) !== "member") {
openJoinModal(groupId, openDraft);
return;
}
openDraft();
}
function onPrivateJoin() {
if (!auth.user) {
openLogin();
return;
}
openDraft();
openJoinModal(groupId, () => loadThreads(groupId, sort));
}
function relativeTime(ts: number): string {
@ -98,17 +134,43 @@
<div class="mx-auto max-w-6xl">
<div class="flex flex-wrap items-center justify-between gap-2 py-2">
<h1 class="text-accent text-[1.65rem] leading-7">{title}</h1>
{#if !showPrivateGate && !checkingAccess}
<div class="flex items-center gap-2">
<button
onclick={onNewTopic}
class="bg-accent hover:bg-accent-hover rounded px-4 py-1.5 font-medium text-white md:px-6 md:text-sm"
>
New discussion
{joinToPost ? "Join to post" : "New discussion"}
</button>
<SortToggle {sort} />
</div>
{/if}
</div>
{#if checkingAccess}
<p class="py-12 text-center text-sm text-neutral-400 dark:text-neutral-500">
Checking access…
</p>
{:else if showPrivateGate}
<div
class="mt-6 rounded-lg border border-neutral-200 px-6 py-10 text-center dark:border-neutral-700"
>
<h2 class="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
This room is private
</h2>
<p
class="mx-auto mt-2 max-w-md text-sm text-neutral-600 dark:text-neutral-400"
>
Only members can read its discussions. Join to request access.
</p>
<button
onclick={onPrivateJoin}
class="bg-accent hover:bg-accent-hover mt-5 rounded px-6 py-1.5 font-medium text-white"
>
{auth.user ? "Request to join" : "Log in to join"}
</button>
</div>
{:else}
<div>
{#each rows as thread}
<ThreadItem {thread} />
@ -116,7 +178,9 @@
</div>
{#if threadStore.loading && rows.length === 0}
<p class="py-6 text-center text-sm text-neutral-400 dark:text-neutral-500">
<p
class="py-6 text-center text-sm text-neutral-400 dark:text-neutral-500"
>
Loading discussions…
</p>
{:else if !threadStore.exhausted}
@ -125,12 +189,17 @@
onclick={() => loadMore(groupId)}
disabled={threadStore.loadingMore}
aria-busy={threadStore.loadingMore}
class="rounded border border-neutral-200 dark:border-neutral-700 px-6 py-1.5 text-sm font-medium text-neutral-700 dark:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-800 disabled:opacity-50"
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 dark:border-neutral-700 dark:text-neutral-300 dark:hover:bg-neutral-800"
>
{threadStore.loadingMore ? "Loading…" : "Show more"}
</button>
</div>
{:else if rows.length > 0}
<p class="py-6 text-center text-sm text-neutral-400 dark:text-neutral-500">No more discussions</p>
<p
class="py-6 text-center text-sm text-neutral-400 dark:text-neutral-500"
>
No more discussions
</p>
{/if}
{/if}
</div>

View file

@ -1,19 +1,19 @@
<script lang="ts">
import { joinState, closeJoinModal, retryJoin } from "$lib/join.svelte";
import { joinState, closeJoinModal, submitJoin } from "$lib/join.svelte";
import { tick } from "svelte";
let code = $state("");
let codeInput = $state<HTMLInputElement | null>(null);
$effect(() => {
if (joinState.modalOpen && joinState.codeRequired) {
if (joinState.modalOpen && joinState.needsCode) {
tick().then(() => codeInput?.focus());
}
if (!joinState.modalOpen) code = "";
});
async function onRetry() {
await retryJoin(code.trim() || undefined);
async function onSubmit() {
await submitJoin(code.trim() || undefined);
}
function onClose() {
@ -37,7 +37,7 @@
onclick={onClose}
></button>
<div
class="relative w-full max-w-md rounded-lg bg-white dark:bg-neutral-900 p-6 shadow-xl"
class="relative w-full max-w-md rounded-lg bg-white p-6 shadow-xl dark:bg-neutral-900"
role="dialog"
aria-modal="true"
aria-labelledby="join-title"
@ -47,21 +47,24 @@
onclick={onClose}
aria-label="Close"
disabled={joinState.busy}
class="absolute top-3 right-3 text-2xl leading-none text-neutral-400 dark:text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300 disabled:opacity-50"
class="absolute top-3 right-3 text-2xl leading-none text-neutral-400 hover:text-neutral-700 disabled:opacity-50 dark:text-neutral-500 dark:hover:text-neutral-300"
>
×
</button>
<h2 id="join-title" class="mb-2 text-lg font-semibold text-neutral-900 dark:text-neutral-100">
<h2
id="join-title"
class="mb-2 text-lg font-semibold text-neutral-900 dark:text-neutral-100"
>
Join this group
</h2>
<p class="mb-4 text-sm text-neutral-600 dark:text-neutral-400">
{#if joinState.codeRequired}
This community requires an invite code. Enter your code below to
request access. If you don't have one, contact the admin.
{#if joinState.needsCode}
Join this group to participate. If you have an invite code, enter it
below — otherwise just send the request.
{:else}
We could not add you to the group automatically. The relay may be
processing your request, or the admin needs to approve it manually.
Join this group to participate. Your request is sent to the relay and,
where needed, approved by an admin.
{/if}
</p>
@ -74,20 +77,20 @@
</div>
{/if}
{#if joinState.codeRequired}
{#if joinState.needsCode}
<label for="join-code-input" class="sr-only">Invite code</label>
<input
id="join-code-input"
bind:this={codeInput}
type="text"
placeholder="Invite code"
placeholder="Invite code (optional)"
bind:value={code}
disabled={joinState.busy}
autocomplete="off"
autocapitalize="off"
spellcheck="false"
onkeydown={(e) => e.key === "Enter" && onRetry()}
class="focus:ring-accent mb-3 w-full rounded border border-neutral-200 dark:border-neutral-700 px-3 py-2 text-sm focus:ring-1 focus:outline-none disabled:opacity-50"
onkeydown={(e) => e.key === "Enter" && onSubmit()}
class="focus:ring-accent mb-3 w-full rounded border border-neutral-200 px-3 py-2 text-sm focus:ring-1 focus:outline-none disabled:opacity-50 dark:border-neutral-700"
/>
{/if}
@ -96,17 +99,21 @@
type="button"
onclick={onClose}
disabled={joinState.busy}
class="rounded px-3 py-2 text-sm font-medium text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800 disabled:opacity-50"
class="rounded px-3 py-2 text-sm font-medium text-neutral-600 hover:bg-neutral-100 disabled:opacity-50 dark:text-neutral-400 dark:hover:bg-neutral-800"
>
Close
</button>
<button
type="button"
onclick={onRetry}
disabled={joinState.busy || (joinState.codeRequired && !code.trim())}
onclick={onSubmit}
disabled={joinState.busy}
class="bg-accent hover:bg-accent-hover rounded px-3 py-2 text-sm font-medium text-white disabled:cursor-not-allowed disabled:opacity-50"
>
{joinState.busy ? "Trying…" : "Retry"}
{joinState.busy
? "Joining…"
: joinState.needsCode
? "Request access"
: "Join"}
</button>
</div>
</div>

View file

@ -170,7 +170,7 @@
{:else}
<div class="mt-3 flex items-center gap-1">
<button
onclick={openLogin}
onclick={() => openLogin()}
class="bg-accent hover:bg-accent-hover min-w-0 flex-1 rounded px-3 py-1.5 text-sm font-medium text-white"
>
Login

View file

@ -1,5 +1,6 @@
import { GROUP_ID } from "$lib/config";
import { GROUP_ID, MODE } from "$lib/config";
import { queryForum } from "$lib/relay";
import { groupsStore } from "$lib/groups.svelte";
export type GroupMetadata = {
name: string;
@ -7,9 +8,20 @@ export type GroupMetadata = {
about?: string;
isPrivate: boolean;
isClosed: boolean;
isRestricted: boolean;
isHidden: boolean;
admins: string[];
};
// NIP-29 access flags: private = members-only read, restricted = members-only
// write, closed = join requests ignored, hidden = metadata hidden from non-members.
export type GroupFlags = {
isPrivate: boolean;
isClosed: boolean;
isRestricted: boolean;
isHidden: boolean;
};
let group = $state<GroupMetadata | null>(null);
let loaded = $state(false);
@ -40,9 +52,33 @@ export async function loadGroup() {
about: event.tags.find((t) => t[0] === "about")?.[1],
isPrivate: event.tags.some((t) => t[0] === "private"),
isClosed: event.tags.some((t) => t[0] === "closed"),
isRestricted: event.tags.some((t) => t[0] === "restricted"),
isHidden: event.tags.some((t) => t[0] === "hidden"),
admins,
};
} finally {
loaded = true;
}
}
// A group's access flags from whichever store holds them: full mode keeps every
// room in the groups list, simple mode has the single active group's metadata.
export function getGroupFlags(groupId: string): GroupFlags | null {
if (MODE === "full") {
const g = groupsStore.list.find((x) => x.id === groupId);
if (!g) return null;
return {
isPrivate: g.flags.includes("private"),
isClosed: g.flags.includes("closed"),
isRestricted: g.flags.includes("restricted"),
isHidden: g.flags.includes("hidden"),
};
}
if (!group) return null;
return {
isPrivate: group.isPrivate,
isClosed: group.isClosed,
isRestricted: group.isRestricted,
isHidden: group.isHidden,
};
}

View file

@ -2,15 +2,28 @@ import type { AbstractRelay } from "@nostr/tools/abstract-relay";
import { auth } from "$lib/auth.svelte";
import { GROUP_ID, MODE, JOINCODE_REQUIRED } from "$lib/config";
import { ensureForumRelay, publishForum } from "$lib/relay";
import { getGroupFlags } from "$lib/group.svelte";
type Membership = "member" | "guest";
// Per-group membership, resolved lazily and cached reactively so compose
// actions can be gated before the user writes anything. Keyed by pubkey so a
// silent session restore (anon → pubkey) re-evaluates instead of reusing the
// "guest" cached before auth.user was populated; the read stays a pure getter
// (no state writes inside a derivation).
let membership = $state<Record<string, Membership>>({});
const checking = new Map<string, Promise<void>>();
// 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 modalGroup = $state<string | null>(null);
let modalError = $state<string | null>(null);
let busy = $state(false);
let pendingAction: (() => Promise<void>) | null = null;
let pendingGroup: string | null = null;
// Continuation to run once the join succeeds (open the composer, focus the
// reply box, reload a now-visible feed, or retry the original post).
let onJoined: (() => void | Promise<void>) | null = null;
const memberKey = (groupId: string) =>
`${auth.user?.pubkey ?? "anon"}:${groupId}`;
export const joinState = {
get modalOpen() {
@ -22,23 +35,58 @@ export const joinState = {
get busy() {
return busy;
},
get codeRequired() {
return JOINCODE_REQUIRED;
// Closed groups (and a globally configured invite gate) can't be self-joined,
// so the modal collects an invite code; open groups just confirm.
get needsCode() {
if (JOINCODE_REQUIRED) return true;
const f = modalGroup ? getGroupFlags(modalGroup) : null;
return !!f?.isClosed;
},
};
export function resetJoinState() {
joinedGroups = new Set();
membership = {};
modalOpen = false;
modalGroup = null;
modalError = null;
busy = false;
pendingAction = null;
pendingGroup = null;
onJoined = null;
}
// Checks group membership: kind:9000 (per-user put-user event, lightweight)
// first, falling back to kind:39002 (full members list, heavier) only if 9000
// did not match. Either signal marks the user as joined and skips the 9021.
export function membershipOf(groupId: string): Membership | "unknown" {
return membership[memberKey(groupId)] ?? "unknown";
}
// Resolves membership for a group once and caches it. Logged-out users are
// always guests; the relay query only runs when signed in. Returns the shared
// in-flight promise when a check is already running, so an awaiting caller
// (e.g. clicking "New discussion") gets the real result rather than racing past
// an unresolved "unknown".
export function ensureMembershipChecked(groupId: string): Promise<void> {
if (!groupId) return Promise.resolve();
const pubkey = auth.user?.pubkey;
const k = memberKey(groupId);
if (membership[k]) return Promise.resolve();
if (!pubkey) {
membership[k] = "guest";
return Promise.resolve();
}
const existing = checking.get(k);
if (existing) return existing;
const p = (async () => {
try {
const isMember = await checkMembership(pubkey, groupId);
membership[k] = isMember ? "member" : "guest";
} finally {
checking.delete(k);
}
})();
checking.set(k, p);
return p;
}
// Existence check for a single matching event (resolves true on first event,
// false on EOSE/close/timeout). Used for the kind:39002 members-list fallback.
function queryHasMatch(
relay: AbstractRelay,
filter: Parameters<AbstractRelay["subscribe"]>[0][number],
@ -69,8 +117,42 @@ function queryHasMatch(
});
}
// Membership signal for one group: kind:9000 (per-user put-user event,
// lightweight) first, falling back to kind:39002 (full members list, heavier).
// Newest `created_at` among events matching the filter, or null if none. We
// don't trust the relay to honour limit ordering, so we keep the max across all
// returned events (the #p filter keeps the set tiny).
function latestCreatedAt(
relay: AbstractRelay,
filter: Parameters<AbstractRelay["subscribe"]>[0][number],
timeoutMs = 3000,
): Promise<number | null> {
return new Promise((resolve) => {
let settled = false;
let latest: number | null = null;
const finish = () => {
if (settled) return;
settled = true;
try {
sub.close();
} catch {}
resolve(latest);
};
const sub = relay.subscribe([filter], {
onevent(e) {
if (latest === null || e.created_at > latest) latest = e.created_at;
},
oneose: finish,
onclose: finish,
});
setTimeout(finish, timeoutMs);
});
}
// Membership signal for one group — i.e. "is this user allowed to write here".
// Admins (kind:39001) can post but need not appear in the members list, so they
// count as members. Otherwise NIP-29 records membership as moderation events:
// kind:9000 adds a user, kind:9001 removes one, so the newest of the two for
// this user decides current membership (a later 9001 unjoins them). When the
// relay keeps neither, fall back to the kind:39002 members list.
async function checkMembership(
pubkey: string,
groupId: string,
@ -81,14 +163,21 @@ async function checkMembership(
} catch {
return false;
}
const has9000 = await queryHasMatch(relay, {
kinds: [9000],
"#h": [groupId],
const [isAdmin, added, removed] = await Promise.all([
queryHasMatch(relay, {
kinds: [39001],
"#d": [groupId],
"#p": [pubkey],
limit: 1,
});
if (has9000) return true;
return await queryHasMatch(relay, {
}),
latestCreatedAt(relay, { kinds: [9000], "#h": [groupId], "#p": [pubkey] }),
latestCreatedAt(relay, { kinds: [9001], "#h": [groupId], "#p": [pubkey] }),
]);
if (isAdmin) return true;
if (added !== null || removed !== null) {
return added !== null && added >= (removed ?? -Infinity);
}
return queryHasMatch(relay, {
kinds: [39002],
"#d": [groupId],
"#p": [pubkey],
@ -100,14 +189,29 @@ async function checkMembership(
// 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);
if (await checkMembership(pubkey, GROUP_ID)) {
membership[memberKey(GROUP_ID)] = "member";
}
}
export function closeJoinModal() {
if (busy) return;
modalOpen = false;
modalGroup = null;
modalError = null;
pendingAction = null;
onJoined = null;
}
// Opens the join modal for a group, with an optional continuation to run after
// a successful join (open the composer, focus the reply box, reload the feed).
export function openJoinModal(
groupId: string,
cb?: () => void | Promise<void>,
) {
modalGroup = groupId;
onJoined = cb ?? null;
modalError = null;
modalOpen = true;
}
async function publishJoinRequest(groupId: string, code?: string) {
@ -126,15 +230,37 @@ async function publishJoinRequest(groupId: string, code?: string) {
await Promise.race([Promise.all(publishForum(event)), timeout]);
}
// Wraps an action that posts to `groupId`. If the user isn't known to be a
// member, checks membership first, then sends kind:9021 before the action. On
// failure, opens the join modal so the user can retry (with code if configured).
// Returns true on success, false if the modal was opened.
// Sends the kind:9021 for the modal's target group, marks the user joined, and
// runs the pending continuation. Errors keep the modal open for a retry.
export async function submitJoin(code?: string) {
if (!modalGroup || busy) return;
busy = true;
modalError = null;
const groupId = modalGroup;
const cb = onJoined;
try {
await publishJoinRequest(groupId, code);
membership[memberKey(groupId)] = "member";
modalOpen = false;
modalGroup = null;
onJoined = null;
if (cb) await cb();
} catch (e) {
modalError = e instanceof Error ? e.message : "Could not join the group";
} finally {
busy = false;
}
}
// Wraps an action that posts to `groupId`. The relay requires membership to
// write to any group, so a non-member is joined first. Compose entry points
// gate membership up front, making this mostly a safety net: it joins
// transparently if possible, else opens the modal to retry.
export async function withJoin(
groupId: string,
action: () => Promise<void>,
): Promise<boolean> {
if (joinedGroups.has(groupId)) {
if (membership[memberKey(groupId)] === "member") {
await action();
return true;
}
@ -142,17 +268,17 @@ export async function withJoin(
try {
const pubkey = auth.user?.pubkey;
if (pubkey && (await checkMembership(pubkey, groupId))) {
joinedGroups.add(groupId);
membership[memberKey(groupId)] = "member";
await action();
return true;
}
await publishJoinRequest(groupId);
await action();
joinedGroups.add(groupId);
membership[memberKey(groupId)] = "member";
return true;
} catch (e) {
pendingAction = action;
pendingGroup = groupId;
modalGroup = groupId;
onJoined = action;
modalError = e instanceof Error ? e.message : "Could not join the group";
modalOpen = true;
return false;
@ -160,21 +286,3 @@ export async function withJoin(
busy = false;
}
}
export async function retryJoin(code?: string) {
if (!pendingAction || !pendingGroup || busy) return;
busy = true;
modalError = null;
try {
await publishJoinRequest(pendingGroup, code);
await pendingAction();
joinedGroups.add(pendingGroup);
modalOpen = false;
pendingAction = null;
pendingGroup = null;
} catch (e) {
modalError = e instanceof Error ? e.message : "Could not join the group";
} finally {
busy = false;
}
}

View file

@ -31,6 +31,9 @@ type ThreadDetail = {
let detail = $state<ThreadDetail | null>(null);
let profiles = $state<Record<string, NostrUser>>({});
// "notfound" means the relay returned nothing — either no such thread or it sits
// in a private group the current (non-member) user can't read.
let status = $state<"loading" | "ready" | "notfound">("loading");
export const threadDetailStore = {
get detail() {
@ -39,6 +42,9 @@ export const threadDetailStore = {
get profiles() {
return profiles;
},
get status() {
return status;
},
};
async function loadProfile(pubkey: string) {
@ -89,10 +95,12 @@ function loadMockThread(id: string) {
export async function loadThread(id: string) {
detail = null;
profiles = {};
status = "loading";
if (!isNostrId(id)) {
await Promise.resolve();
loadMockThread(id);
status = detail ? "ready" : "notfound";
return;
}
@ -102,7 +110,10 @@ export async function loadThread(id: string) {
]);
const event = threadEvents[0];
if (!event) return;
if (!event) {
status = "notfound";
return;
}
const replies = replyEvents.sort((a, b) => a.created_at - b.created_at);
@ -126,6 +137,7 @@ export async function loadThread(id: string) {
};
[event.pubkey, ...replies.map((r) => r.pubkey)].forEach(loadProfile);
status = "ready";
}
export function removeReply(id: string) {

View file

@ -16,6 +16,7 @@
import { onMount } from "svelte";
import { auth, restoreSession } from "$lib/auth.svelte";
import { loadGroup } from "$lib/group.svelte";
import { ensureMembershipChecked } from "$lib/join.svelte";
import { loadGroups, groupsStore } from "$lib/groups.svelte";
import { loadResources } from "$lib/resources.svelte";
import { loadPartials } from "$lib/partials.svelte";
@ -108,6 +109,14 @@
if (chatEnabled && activeGroup.id) startChat(activeGroup.id);
});
// Pre-warm membership for the active room so the join gates resolve before
// the user reaches a composer (no flash, no wasted typing). Depends on
// auth.user so a silent session restore re-runs the check.
$effect(() => {
auth.user;
if (activeGroup.id) ensureMembershipChecked(activeGroup.id);
});
let chatExpanded = $state(false);
let menuOpen = $state(false);
let mobileView = $state<"forum" | "chat">("forum");
@ -177,7 +186,7 @@
it first and it reappears once the top is reached. Desktop only. -->
<div class="hidden md:block md:h-6" aria-hidden="true"></div>
<div
class="min-h-[calc(100dvh_-_4rem)] bg-white dark:bg-neutral-900 px-6 pt-4 pb-20 shadow-lg md:min-h-full md:rounded-t-xl md:px-10 md:pt-6 md:pt-8"
class="min-h-[calc(100dvh_-_4rem)] bg-white px-6 pt-4 pb-20 shadow-lg md:min-h-full md:rounded-t-xl md:px-10 md:pt-6 md:pt-8 dark:bg-neutral-900"
>
{@render children()}
</div>
@ -197,7 +206,7 @@
>
<div class="hidden md:block md:h-6" aria-hidden="true"></div>
<div
class="bg-white dark:bg-neutral-900 px-6 pt-8 pb-20 shadow-lg md:min-h-full md:rounded-t-xl md:px-8 md:pt-6"
class="bg-white px-6 pt-8 pb-20 shadow-lg md:min-h-full md:rounded-t-xl md:px-8 md:pt-6 dark:bg-neutral-900"
>
<LatestDiscussions />
</div>
@ -206,7 +215,7 @@
</div>
{#if showChat}
<nav
class="fixed inset-x-0 bottom-0 z-30 flex border-t border-neutral-200 dark:border-neutral-700 bg-neutral-100 dark:bg-neutral-800 md:hidden"
class="fixed inset-x-0 bottom-0 z-30 flex border-t border-neutral-200 bg-neutral-100 md:hidden dark:border-neutral-700 dark:bg-neutral-800"
aria-label="Switch view"
>
<button
@ -214,7 +223,9 @@
onclick={() => (mobileView = "forum")}
aria-pressed={mobileView === "forum"}
class="flex flex-1 flex-col items-center gap-0.5 py-2 text-xs font-medium
{mobileView === 'forum' ? 'text-accent' : 'text-neutral-500 dark:text-neutral-400'}"
{mobileView === 'forum'
? 'text-accent'
: 'text-neutral-500 dark:text-neutral-400'}"
>
<svg
xmlns="http://www.w3.org/2000/svg"
@ -238,7 +249,9 @@
onclick={() => (mobileView = "chat")}
aria-pressed={mobileView === "chat"}
class="flex flex-1 flex-col items-center gap-0.5 py-2 text-xs font-medium
{mobileView === 'chat' ? 'text-accent' : 'text-neutral-500 dark:text-neutral-400'}"
{mobileView === 'chat'
? 'text-accent'
: 'text-neutral-500 dark:text-neutral-400'}"
>
<svg
xmlns="http://www.w3.org/2000/svg"

View file

@ -14,7 +14,12 @@
import { isGroupAdmin } from "$lib/admins.svelte";
import { requestDelete } from "$lib/moderation.svelte";
import { showToast } from "$lib/toast.svelte";
import { withJoin } from "$lib/join.svelte";
import {
withJoin,
membershipOf,
ensureMembershipChecked,
openJoinModal,
} from "$lib/join.svelte";
import { setActiveGroup } from "$lib/active.svelte";
import { RELAY_URL, MODE } from "$lib/config";
import ThreadScrubber from "$lib/components/ThreadScrubber.svelte";
@ -111,6 +116,28 @@
const detail = $derived(threadDetailStore.detail);
const profiles = $derived(threadDetailStore.profiles);
// Replying needs membership regardless of flags. Only gate a confirmed guest;
// while membership resolves the editor stays and submit awaits the check, so a
// member never flashes "Join to reply".
const joinToReply = $derived(
!!detail && !!auth.user && membershipOf(detail.groupId) === "guest",
);
// The relay requires membership to reply, so resolve it up front. Depends on
// auth.user so a silent session restore re-runs the check.
$effect(() => {
auth.user;
if (detail?.groupId) ensureMembershipChecked(detail.groupId);
});
function onJoinToReply() {
if (!detail) return;
openJoinModal(detail.groupId, async () => {
await tick();
editorEl?.focus();
});
}
const canModerate = $derived(
!!auth.user && !!detail && isGroupAdmin(auth.user.pubkey, detail.groupId),
);
@ -437,6 +464,19 @@
class="mt-8 border-t border-neutral-200 pt-6 md:pl-18 dark:border-neutral-700"
>
{#if auth.user}
{#if joinToReply}
<div class="text-center">
<p class="mb-3 text-neutral-600 dark:text-neutral-400">
This room is restricted. Join to reply to this discussion.
</p>
<button
onclick={onJoinToReply}
class="bg-accent hover:bg-accent-hover rounded px-6 py-1.5 font-medium text-white"
>
Join to reply
</button>
</div>
{:else}
{#if replyError}
<div
class="mb-4 rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700"
@ -463,11 +503,13 @@
{replying ? "Posting…" : "post reply"}
</button>
</div>
{/if}
{:else}
<p class=" text-center text-neutral-500 dark:text-neutral-400">
To participate and reply, please
<button onclick={openLogin} class="text-accent hover:underline"
>login now</button
<button
onclick={() => openLogin()}
class="text-accent hover:underline">login now</button
>
</p>
{/if}
@ -481,6 +523,25 @@
bind:visible={scrubberVisible}
/>
</div>
{:else if threadDetailStore.status === "notfound"}
<div class="mx-auto max-w-md py-16 text-center">
<h1 class="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
This discussion is private or unavailable
</h1>
<p class="mt-2 text-sm text-neutral-600 dark:text-neutral-400">
It may belong to a private room. {auth.user
? "You may need to be a member to read it."
: "Log in as a member to read it."}
</p>
{#if !auth.user}
<button
onclick={() => openLogin()}
class="bg-accent hover:bg-accent-hover mt-5 rounded px-6 py-1.5 font-medium text-white"
>
Log in
</button>
{/if}
</div>
{:else}
<div class="py-12 text-center text-neutral-400 dark:text-neutral-500">
Loading…