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 user = $state<NostrUser | null>(null);
let signer = $state<Signer | null>(null); let signer = $state<Signer | null>(null);
let loginModalOpen = $state(false); 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 // 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 // can re-fetch identity-scoped data — e.g. reload the room list once the relay
// will serve the user's private/hidden groups. // will serve the user's private/hidden groups.
@ -43,12 +46,23 @@ const PUBKEY_KEY = "nostr_pubkey";
const METHOD_KEY = "nostr_login_method"; const METHOD_KEY = "nostr_login_method";
const NSEC_KEY = "nostr_nsec"; const NSEC_KEY = "nostr_nsec";
export function openLogin() { export function openLogin(after?: () => void) {
afterLogin = after ?? null;
loginModalOpen = true; loginModalOpen = true;
} }
export function closeLogin() { export function closeLogin() {
loginModalOpen = false; 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 { function makeNsecSigner(secretKey: Uint8Array): Signer {
@ -97,6 +111,7 @@ export async function loginWithExtension() {
localStorage.removeItem(NSEC_KEY); localStorage.removeItem(NSEC_KEY);
await setUser(pubkey); await setUser(pubkey);
sessionEpoch++; sessionEpoch++;
runAfterLogin();
} }
function parseSecretKey(input: string): Uint8Array { function parseSecretKey(input: string): Uint8Array {
@ -128,6 +143,7 @@ export async function loginWithNsec(input: string) {
localStorage.setItem(NSEC_KEY, nsec); localStorage.setItem(NSEC_KEY, nsec);
await setUser(pubkey); await setUser(pubkey);
sessionEpoch++; sessionEpoch++;
runAfterLogin();
} }
export function logout() { export function logout() {

View file

@ -10,7 +10,12 @@
import { auth, openLogin } from "$lib/auth.svelte"; import { auth, openLogin } from "$lib/auth.svelte";
import { isGroupAdmin } from "$lib/admins.svelte"; import { isGroupAdmin } from "$lib/admins.svelte";
import { requestDelete } from "$lib/moderation.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 { 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";
@ -46,6 +51,24 @@
!!auth.user && isGroupAdmin(auth.user.pubkey, activeGroup.id), !!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) { function requestDeleteMessage(msg: ChatMessageData) {
openMenuId = null; openMenuId = null;
requestDelete( requestDelete(
@ -431,6 +454,14 @@
{sendError} {sendError}
</div> </div>
{/if} {/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 <MentionAutocomplete
bind:this={inputEl} bind:this={inputEl}
bind:value={inputValue} bind:value={inputValue}
@ -441,5 +472,6 @@
{contextPubkeys} {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" 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> </div>
</aside> </aside>

View file

@ -14,6 +14,12 @@
import type { NostrUser } from "@nostr/gadgets/metadata"; import type { NostrUser } from "@nostr/gadgets/metadata";
import { auth, openLogin } from "$lib/auth.svelte"; import { auth, openLogin } from "$lib/auth.svelte";
import { openDraft } from "$lib/draft.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 { sortPref } from "$lib/sort.svelte";
import { page } from "$app/state"; import { page } from "$app/state";
@ -38,17 +44,47 @@
if (urlSort) sortPref.value = urlSort; 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. // Re-runs on mount and whenever the group or effective sort changes.
$effect(() => { $effect(() => {
loadThreads(groupId, sort); 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) { if (!auth.user) {
openLogin(); openLogin();
return; return;
} }
openDraft(); openJoinModal(groupId, () => loadThreads(groupId, sort));
} }
function relativeTime(ts: number): string { function relativeTime(ts: number): string {
@ -98,17 +134,43 @@
<div class="mx-auto max-w-6xl"> <div class="mx-auto max-w-6xl">
<div class="flex flex-wrap items-center justify-between gap-2 py-2"> <div class="flex flex-wrap items-center justify-between gap-2 py-2">
<h1 class="text-accent text-[1.65rem] leading-7">{title}</h1> <h1 class="text-accent text-[1.65rem] leading-7">{title}</h1>
{#if !showPrivateGate && !checkingAccess}
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button <button
onclick={onNewTopic} 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" 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> </button>
<SortToggle {sort} /> <SortToggle {sort} />
</div> </div>
{/if}
</div> </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> <div>
{#each rows as thread} {#each rows as thread}
<ThreadItem {thread} /> <ThreadItem {thread} />
@ -116,7 +178,9 @@
</div> </div>
{#if threadStore.loading && rows.length === 0} {#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… Loading discussions…
</p> </p>
{:else if !threadStore.exhausted} {:else if !threadStore.exhausted}
@ -125,12 +189,17 @@
onclick={() => loadMore(groupId)} onclick={() => loadMore(groupId)}
disabled={threadStore.loadingMore} disabled={threadStore.loadingMore}
aria-busy={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"} {threadStore.loadingMore ? "Loading…" : "Show more"}
</button> </button>
</div> </div>
{:else if rows.length > 0} {: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} {/if}
</div> </div>

View file

@ -1,19 +1,19 @@
<script lang="ts"> <script lang="ts">
import { joinState, closeJoinModal, retryJoin } from "$lib/join.svelte"; import { joinState, closeJoinModal, submitJoin } from "$lib/join.svelte";
import { tick } from "svelte"; import { tick } from "svelte";
let code = $state(""); let code = $state("");
let codeInput = $state<HTMLInputElement | null>(null); let codeInput = $state<HTMLInputElement | null>(null);
$effect(() => { $effect(() => {
if (joinState.modalOpen && joinState.codeRequired) { if (joinState.modalOpen && joinState.needsCode) {
tick().then(() => codeInput?.focus()); tick().then(() => codeInput?.focus());
} }
if (!joinState.modalOpen) code = ""; if (!joinState.modalOpen) code = "";
}); });
async function onRetry() { async function onSubmit() {
await retryJoin(code.trim() || undefined); await submitJoin(code.trim() || undefined);
} }
function onClose() { function onClose() {
@ -37,7 +37,7 @@
onclick={onClose} onclick={onClose}
></button> ></button>
<div <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" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby="join-title" aria-labelledby="join-title"
@ -47,21 +47,24 @@
onclick={onClose} onclick={onClose}
aria-label="Close" aria-label="Close"
disabled={joinState.busy} 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> </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 Join this group
</h2> </h2>
<p class="mb-4 text-sm text-neutral-600 dark:text-neutral-400"> <p class="mb-4 text-sm text-neutral-600 dark:text-neutral-400">
{#if joinState.codeRequired} {#if joinState.needsCode}
This community requires an invite code. Enter your code below to Join this group to participate. If you have an invite code, enter it
request access. If you don't have one, contact the admin. below — otherwise just send the request.
{:else} {:else}
We could not add you to the group automatically. The relay may be Join this group to participate. Your request is sent to the relay and,
processing your request, or the admin needs to approve it manually. where needed, approved by an admin.
{/if} {/if}
</p> </p>
@ -74,20 +77,20 @@
</div> </div>
{/if} {/if}
{#if joinState.codeRequired} {#if joinState.needsCode}
<label for="join-code-input" class="sr-only">Invite code</label> <label for="join-code-input" class="sr-only">Invite code</label>
<input <input
id="join-code-input" id="join-code-input"
bind:this={codeInput} bind:this={codeInput}
type="text" type="text"
placeholder="Invite code" placeholder="Invite code (optional)"
bind:value={code} bind:value={code}
disabled={joinState.busy} disabled={joinState.busy}
autocomplete="off" autocomplete="off"
autocapitalize="off" autocapitalize="off"
spellcheck="false" spellcheck="false"
onkeydown={(e) => e.key === "Enter" && onRetry()} onkeydown={(e) => e.key === "Enter" && onSubmit()}
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" 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} {/if}
@ -96,17 +99,21 @@
type="button" type="button"
onclick={onClose} onclick={onClose}
disabled={joinState.busy} 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 Close
</button> </button>
<button <button
type="button" type="button"
onclick={onRetry} onclick={onSubmit}
disabled={joinState.busy || (joinState.codeRequired && !code.trim())} 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" 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> </button>
</div> </div>
</div> </div>

View file

@ -170,7 +170,7 @@
{:else} {:else}
<div class="mt-3 flex items-center gap-1"> <div class="mt-3 flex items-center gap-1">
<button <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" 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 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 { queryForum } from "$lib/relay";
import { groupsStore } from "$lib/groups.svelte";
export type GroupMetadata = { export type GroupMetadata = {
name: string; name: string;
@ -7,9 +8,20 @@ export type GroupMetadata = {
about?: string; about?: string;
isPrivate: boolean; isPrivate: boolean;
isClosed: boolean; isClosed: boolean;
isRestricted: boolean;
isHidden: boolean;
admins: string[]; 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 group = $state<GroupMetadata | null>(null);
let loaded = $state(false); let loaded = $state(false);
@ -40,9 +52,33 @@ export async function loadGroup() {
about: event.tags.find((t) => t[0] === "about")?.[1], about: event.tags.find((t) => t[0] === "about")?.[1],
isPrivate: event.tags.some((t) => t[0] === "private"), isPrivate: event.tags.some((t) => t[0] === "private"),
isClosed: event.tags.some((t) => t[0] === "closed"), 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, admins,
}; };
} finally { } finally {
loaded = true; 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 { auth } from "$lib/auth.svelte";
import { GROUP_ID, MODE, JOINCODE_REQUIRED } from "$lib/config"; import { GROUP_ID, MODE, JOINCODE_REQUIRED } from "$lib/config";
import { ensureForumRelay, publishForum } from "$lib/relay"; 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 modalOpen = $state(false);
let modalGroup = $state<string | null>(null);
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; // Continuation to run once the join succeeds (open the composer, focus the
let pendingGroup: string | null = null; // 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 = { export const joinState = {
get modalOpen() { get modalOpen() {
@ -22,23 +35,58 @@ export const joinState = {
get busy() { get busy() {
return busy; return busy;
}, },
get codeRequired() { // Closed groups (and a globally configured invite gate) can't be self-joined,
return JOINCODE_REQUIRED; // 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() { export function resetJoinState() {
joinedGroups = new Set(); membership = {};
modalOpen = false; modalOpen = false;
modalGroup = null;
modalError = null; modalError = null;
busy = false; busy = false;
pendingAction = null; onJoined = null;
pendingGroup = null;
} }
// Checks group membership: kind:9000 (per-user put-user event, lightweight) export function membershipOf(groupId: string): Membership | "unknown" {
// first, falling back to kind:39002 (full members list, heavier) only if 9000 return membership[memberKey(groupId)] ?? "unknown";
// did not match. Either signal marks the user as joined and skips the 9021. }
// 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( function queryHasMatch(
relay: AbstractRelay, relay: AbstractRelay,
filter: Parameters<AbstractRelay["subscribe"]>[0][number], filter: Parameters<AbstractRelay["subscribe"]>[0][number],
@ -69,8 +117,42 @@ function queryHasMatch(
}); });
} }
// Membership signal for one group: kind:9000 (per-user put-user event, // Newest `created_at` among events matching the filter, or null if none. We
// lightweight) first, falling back to kind:39002 (full members list, heavier). // 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( async function checkMembership(
pubkey: string, pubkey: string,
groupId: string, groupId: string,
@ -81,14 +163,21 @@ async function checkMembership(
} catch { } catch {
return false; return false;
} }
const has9000 = await queryHasMatch(relay, { const [isAdmin, added, removed] = await Promise.all([
kinds: [9000], queryHasMatch(relay, {
"#h": [groupId], kinds: [39001],
"#d": [groupId],
"#p": [pubkey], "#p": [pubkey],
limit: 1, limit: 1,
}); }),
if (has9000) return true; latestCreatedAt(relay, { kinds: [9000], "#h": [groupId], "#p": [pubkey] }),
return await queryHasMatch(relay, { 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], kinds: [39002],
"#d": [groupId], "#d": [groupId],
"#p": [pubkey], "#p": [pubkey],
@ -100,14 +189,29 @@ async function checkMembership(
// skips the 9021. Full mode checks lazily per room when the user first acts. // skips the 9021. Full mode checks lazily per room when the user first acts.
export async function initJoinForUser(pubkey: string) { export async function initJoinForUser(pubkey: string) {
if (MODE !== "simple") return; 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() { export function closeJoinModal() {
if (busy) return; if (busy) return;
modalOpen = false; modalOpen = false;
modalGroup = null;
modalError = 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) { 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]); await Promise.race([Promise.all(publishForum(event)), timeout]);
} }
// Wraps an action that posts to `groupId`. If the user isn't known to be a // Sends the kind:9021 for the modal's target group, marks the user joined, and
// member, checks membership first, then sends kind:9021 before the action. On // runs the pending continuation. Errors keep the modal open for a retry.
// failure, opens the join modal so the user can retry (with code if configured). export async function submitJoin(code?: string) {
// Returns true on success, false if the modal was opened. 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( export async function withJoin(
groupId: string, groupId: string,
action: () => Promise<void>, action: () => Promise<void>,
): Promise<boolean> { ): Promise<boolean> {
if (joinedGroups.has(groupId)) { if (membership[memberKey(groupId)] === "member") {
await action(); await action();
return true; return true;
} }
@ -142,17 +268,17 @@ export async function withJoin(
try { try {
const pubkey = auth.user?.pubkey; const pubkey = auth.user?.pubkey;
if (pubkey && (await checkMembership(pubkey, groupId))) { if (pubkey && (await checkMembership(pubkey, groupId))) {
joinedGroups.add(groupId); membership[memberKey(groupId)] = "member";
await action(); await action();
return true; return true;
} }
await publishJoinRequest(groupId); await publishJoinRequest(groupId);
await action(); await action();
joinedGroups.add(groupId); membership[memberKey(groupId)] = "member";
return true; return true;
} catch (e) { } catch (e) {
pendingAction = action; modalGroup = groupId;
pendingGroup = groupId; onJoined = action;
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;
@ -160,21 +286,3 @@ export async function withJoin(
busy = false; 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 detail = $state<ThreadDetail | null>(null);
let profiles = $state<Record<string, NostrUser>>({}); 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 = { export const threadDetailStore = {
get detail() { get detail() {
@ -39,6 +42,9 @@ export const threadDetailStore = {
get profiles() { get profiles() {
return profiles; return profiles;
}, },
get status() {
return status;
},
}; };
async function loadProfile(pubkey: string) { async function loadProfile(pubkey: string) {
@ -89,10 +95,12 @@ function loadMockThread(id: string) {
export async function loadThread(id: string) { export async function loadThread(id: string) {
detail = null; detail = null;
profiles = {}; profiles = {};
status = "loading";
if (!isNostrId(id)) { if (!isNostrId(id)) {
await Promise.resolve(); await Promise.resolve();
loadMockThread(id); loadMockThread(id);
status = detail ? "ready" : "notfound";
return; return;
} }
@ -102,7 +110,10 @@ export async function loadThread(id: string) {
]); ]);
const event = threadEvents[0]; const event = threadEvents[0];
if (!event) return; if (!event) {
status = "notfound";
return;
}
const replies = replyEvents.sort((a, b) => a.created_at - b.created_at); 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); [event.pubkey, ...replies.map((r) => r.pubkey)].forEach(loadProfile);
status = "ready";
} }
export function removeReply(id: string) { export function removeReply(id: string) {

View file

@ -16,6 +16,7 @@
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 { ensureMembershipChecked } from "$lib/join.svelte";
import { loadGroups, groupsStore } from "$lib/groups.svelte"; import { loadGroups, groupsStore } from "$lib/groups.svelte";
import { loadResources } from "$lib/resources.svelte"; import { loadResources } from "$lib/resources.svelte";
import { loadPartials } from "$lib/partials.svelte"; import { loadPartials } from "$lib/partials.svelte";
@ -108,6 +109,14 @@
if (chatEnabled && activeGroup.id) startChat(activeGroup.id); 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 chatExpanded = $state(false);
let menuOpen = $state(false); let menuOpen = $state(false);
let mobileView = $state<"forum" | "chat">("forum"); let mobileView = $state<"forum" | "chat">("forum");
@ -177,7 +186,7 @@
it first and it reappears once the top is reached. Desktop only. --> 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="hidden md:block md:h-6" aria-hidden="true"></div>
<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()} {@render children()}
</div> </div>
@ -197,7 +206,7 @@
> >
<div class="hidden md:block md:h-6" aria-hidden="true"></div> <div class="hidden md:block md:h-6" aria-hidden="true"></div>
<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 /> <LatestDiscussions />
</div> </div>
@ -206,7 +215,7 @@
</div> </div>
{#if showChat} {#if showChat}
<nav <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" aria-label="Switch view"
> >
<button <button
@ -214,7 +223,9 @@
onclick={() => (mobileView = "forum")} onclick={() => (mobileView = "forum")}
aria-pressed={mobileView === "forum"} aria-pressed={mobileView === "forum"}
class="flex flex-1 flex-col items-center gap-0.5 py-2 text-xs font-medium 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 <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
@ -238,7 +249,9 @@
onclick={() => (mobileView = "chat")} onclick={() => (mobileView = "chat")}
aria-pressed={mobileView === "chat"} aria-pressed={mobileView === "chat"}
class="flex flex-1 flex-col items-center gap-0.5 py-2 text-xs font-medium 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 <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"

View file

@ -14,7 +14,12 @@
import { isGroupAdmin } from "$lib/admins.svelte"; import { isGroupAdmin } from "$lib/admins.svelte";
import { requestDelete } from "$lib/moderation.svelte"; import { requestDelete } from "$lib/moderation.svelte";
import { showToast } from "$lib/toast.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 { setActiveGroup } from "$lib/active.svelte";
import { RELAY_URL, MODE } from "$lib/config"; import { RELAY_URL, MODE } from "$lib/config";
import ThreadScrubber from "$lib/components/ThreadScrubber.svelte"; import ThreadScrubber from "$lib/components/ThreadScrubber.svelte";
@ -111,6 +116,28 @@
const detail = $derived(threadDetailStore.detail); const detail = $derived(threadDetailStore.detail);
const profiles = $derived(threadDetailStore.profiles); 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( const canModerate = $derived(
!!auth.user && !!detail && isGroupAdmin(auth.user.pubkey, detail.groupId), !!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" class="mt-8 border-t border-neutral-200 pt-6 md:pl-18 dark:border-neutral-700"
> >
{#if auth.user} {#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} {#if replyError}
<div <div
class="mb-4 rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700" 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"} {replying ? "Posting…" : "post reply"}
</button> </button>
</div> </div>
{/if}
{:else} {:else}
<p class=" text-center text-neutral-500 dark:text-neutral-400"> <p class=" text-center text-neutral-500 dark:text-neutral-400">
To participate and reply, please To participate and reply, please
<button onclick={openLogin} class="text-accent hover:underline" <button
>login now</button onclick={() => openLogin()}
class="text-accent hover:underline">login now</button
> >
</p> </p>
{/if} {/if}
@ -481,6 +523,25 @@
bind:visible={scrubberVisible} bind:visible={scrubberVisible}
/> />
</div> </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} {:else}
<div class="py-12 text-center text-neutral-400 dark:text-neutral-500"> <div class="py-12 text-center text-neutral-400 dark:text-neutral-500">
Loading… Loading…