Join relay and group dynamically, dropping PUBLIC_JOINCODE

This commit is contained in:
dtonon 2026-08-13 22:54:26 +01:00
parent aaa9a0c1e5
commit 6ecd618bce
9 changed files with 453 additions and 178 deletions

View file

@ -2,7 +2,6 @@ PUBLIC_RELAY_URL=ws://localhost:3334 # required — NIP-29 relay hosting the g
PUBLIC_MODE=simple # simple | full (single forum vs. many rooms) PUBLIC_MODE=simple # simple | full (single forum vs. many rooms)
PUBLIC_GROUP_ID=mygrouprandomid # required in simple mode; the single forum's group id (ignored in full mode) PUBLIC_GROUP_ID=mygrouprandomid # required in simple mode; the single forum's group id (ignored in full mode)
PUBLIC_TITLE= # top-bar title; empty falls back to the group name PUBLIC_TITLE= # top-bar title; empty falls back to the group name
PUBLIC_JOINCODE=no # yes | no — show an invite-code field when a join is rejected
PUBLIC_SEARCH=no # yes | no — enable the search box (requires a relay with NIP-50 support) PUBLIC_SEARCH=no # yes | no — enable the search box (requires a relay with NIP-50 support)
PUBLIC_LABELS= # comma-separated discussion labels (e.g., bug,feature,question) PUBLIC_LABELS= # comma-separated discussion labels (e.g., bug,feature,question)
PUBLIC_BLOSSOM_URL= # Blossom server URL for uploads (e.g., https://blossom.primal.net) PUBLIC_BLOSSOM_URL= # Blossom server URL for uploads (e.g., https://blossom.primal.net)

View file

@ -15,9 +15,9 @@
} from "$lib/moderation.svelte"; } from "$lib/moderation.svelte";
import { import {
withJoin, withJoin,
joinState,
membershipOf, membershipOf,
ensureMembershipChecked, ensureMembershipChecked,
openJoinModal,
} from "$lib/join.svelte"; } 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";
@ -76,7 +76,7 @@
}); });
function onJoinToChat() { function onJoinToChat() {
openJoinModal(activeGroup.id, () => tick().then(() => inputEl?.focus())); withJoin(activeGroup.id, () => tick().then(() => inputEl?.focus()));
} }
function requestDeleteMessage(msg: ChatMessageData) { function requestDeleteMessage(msg: ChatMessageData) {
@ -486,9 +486,10 @@
{:else if joinToChat} {:else if joinToChat}
<button <button
onclick={onJoinToChat} onclick={onJoinToChat}
class="bg-accent hover:bg-accent-hover w-full rounded px-3 py-2 text-sm font-medium text-white" disabled={joinState.busy}
class="bg-accent hover:bg-accent-hover w-full rounded px-3 py-2 text-sm font-medium text-white disabled:opacity-50"
> >
Join to chat {joinState.busy ? "Joining…" : "Join to chat"}
</button> </button>
{:else} {:else}
<div class="relative"> <div class="relative">

View file

@ -18,7 +18,8 @@
import { import {
membershipOf, membershipOf,
ensureMembershipChecked, ensureMembershipChecked,
openJoinModal, withJoin,
joinState,
} from "$lib/join.svelte"; } 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";
@ -71,12 +72,7 @@
openLogin(onNewTopic); openLogin(onNewTopic);
return; return;
} }
await ensureMembershipChecked(groupId); await withJoin(groupId, openDraft);
if (membershipOf(groupId) !== "member") {
openJoinModal(groupId, openDraft);
return;
}
openDraft();
} }
function onPrivateJoin() { function onPrivateJoin() {
@ -84,7 +80,7 @@
openLogin(); openLogin();
return; return;
} }
openJoinModal(groupId, () => loadThreads(groupId, sort)); withJoin(groupId, () => loadThreads(groupId, sort));
} }
function relativeTime(ts: number): string { function relativeTime(ts: number): string {
@ -138,9 +134,14 @@
<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" disabled={joinState.busy}
class="bg-accent hover:bg-accent-hover rounded px-4 py-1.5 font-medium text-white disabled:opacity-50 md:px-6 md:text-sm"
> >
{joinToPost ? "Join to post" : "New discussion"} {joinState.busy
? "Joining…"
: joinToPost
? "Join to post"
: "New discussion"}
</button> </button>
<SortToggle {sort} /> <SortToggle {sort} />
</div> </div>
@ -165,9 +166,14 @@
</p> </p>
<button <button
onclick={onPrivateJoin} onclick={onPrivateJoin}
class="bg-accent hover:bg-accent-hover mt-5 rounded px-6 py-1.5 font-medium text-white" disabled={joinState.busy}
class="bg-accent hover:bg-accent-hover mt-5 rounded px-6 py-1.5 font-medium text-white disabled:opacity-50"
> >
{auth.user ? "Request to join" : "Log in to join"} {joinState.busy
? "Joining…"
: auth.user
? "Request to join"
: "Log in to join"}
</button> </button>
</div> </div>
{:else} {:else}

View file

@ -1,19 +1,38 @@
<script lang="ts"> <script lang="ts">
import { joinState, closeJoinModal, submitJoin } from "$lib/join.svelte"; import { joinState, closeJoinModal, submitJoinCode } 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);
// Pending approval hides the code field behind a discreet link, so a group
// that doesn't hand out codes doesn't send people hunting for one.
let codeRevealed = $state(false);
const step = $derived(joinState.modalStep);
const showCode = $derived(
step === "relay-code" ||
(step === "group-code" && joinState.modalCodeHinted) ||
codeRevealed,
);
const canOfferCode = $derived(
step === "pending" || (step === "group-code" && !joinState.modalCodeHinted),
);
$effect(() => { $effect(() => {
if (joinState.modalOpen && joinState.needsCode) { if (!joinState.modalOpen) {
code = "";
codeRevealed = false;
}
});
$effect(() => {
if (joinState.modalOpen && showCode) {
tick().then(() => codeInput?.focus()); tick().then(() => codeInput?.focus());
} }
if (!joinState.modalOpen) code = "";
}); });
async function onSubmit() { async function onSubmit() {
await submitJoin(code.trim() || undefined); await submitJoinCode(code.trim() || undefined);
} }
function onClose() { function onClose() {
@ -56,15 +75,28 @@
id="join-title" id="join-title"
class="mb-2 text-lg font-semibold text-neutral-900 dark:text-neutral-100" class="mb-2 text-lg font-semibold text-neutral-900 dark:text-neutral-100"
> >
Join this group {#if step === "relay-code"}
Invite code required
{:else if step === "pending"}
Request sent
{:else if step === "error"}
Could not join
{:else}
Join this group
{/if}
</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.needsCode} {#if step === "relay-code"}
Join this group to participate. If you have an invite code, enter it This relay only accepts members. Enter an invite code to join.
below — otherwise just send the request. {:else if step === "pending"}
Your request to join has been sent. An admin needs to approve it
before you can participate.
{:else if step === "error"}
The relay refused the request.
{:else if joinState.modalCodeHinted}
This group requires an invite code to join.
{:else} {:else}
Join this group to participate. Your request is sent to the relay and, The group refused the join request.
where needed, approved by an admin.
{/if} {/if}
</p> </p>
@ -77,13 +109,17 @@
</div> </div>
{/if} {/if}
{#if joinState.needsCode} {#if showCode}
<label for="join-code-input" class="sr-only">Invite code</label> <label for="join-code-input" class="sr-only">
{step === "relay-code" ? "Relay invite code" : "Group 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 (optional)" placeholder={step === "relay-code"
? "Relay invite code"
: "Group invite code"}
bind:value={code} bind:value={code}
disabled={joinState.busy} disabled={joinState.busy}
autocomplete="off" autocomplete="off"
@ -92,6 +128,14 @@
onkeydown={(e) => e.key === "Enter" && onSubmit()} 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" 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"
/> />
{:else if canOfferCode}
<button
type="button"
onclick={() => (codeRevealed = true)}
class="mb-3 text-sm text-neutral-500 underline hover:text-neutral-800 dark:text-neutral-400 dark:hover:text-neutral-200"
>
Have an invite code?
</button>
{/if} {/if}
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
@ -103,18 +147,16 @@
> >
Close Close
</button> </button>
<button {#if showCode}
type="button" <button
onclick={onSubmit} type="button"
disabled={joinState.busy} onclick={onSubmit}
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" disabled={joinState.busy || !code.trim()}
> 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 >
? "Joining…" {joinState.busy ? "Joining…" : "Join"}
: joinState.needsCode </button>
? "Request access" {/if}
: "Join"}
</button>
</div> </div>
</div> </div>
</div> </div>

View file

@ -18,7 +18,6 @@ export const GROUP_ID = env.PUBLIC_GROUP_ID ?? "";
if (MODE === "simple" && !GROUP_ID) { if (MODE === "simple" && !GROUP_ID) {
throw new Error("PUBLIC_GROUP_ID is required in simple mode"); throw new Error("PUBLIC_GROUP_ID is required in simple mode");
} }
export const JOINCODE_REQUIRED = env.PUBLIC_JOINCODE === "yes";
// Requires a relay with NIP-50 support. // Requires a relay with NIP-50 support.
export const SEARCH_ENABLED = env.PUBLIC_SEARCH === "yes"; export const SEARCH_ENABLED = env.PUBLIC_SEARCH === "yes";
export const LABELS = (env.PUBLIC_LABELS ?? "") export const LABELS = (env.PUBLIC_LABELS ?? "")

View file

@ -82,3 +82,12 @@ export function getGroupFlags(groupId: string): GroupFlags | null {
isHidden: group.isHidden, isHidden: group.isHidden,
}; };
} }
// Display name for a group from whichever store holds it, falling back to the
// id when metadata hasn't loaded.
export function getGroupName(groupId: string): string {
if (MODE === "full") {
return groupsStore.list.find((x) => x.id === groupId)?.name ?? groupId;
}
return group?.name ?? groupId;
}

View file

@ -1,24 +1,42 @@
import type { AbstractRelay } from "@nostr/tools/abstract-relay"; import type { AbstractRelay } from "@nostr/tools/abstract-relay";
import type { Event } from "@nostr/tools/core";
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 } from "$lib/config";
import { ensureForumRelay, publishForum } from "$lib/relay"; import { ensureForumRelay, publishForum, nip43Self } from "$lib/relay";
import { getGroupFlags } from "$lib/group.svelte"; import { getGroupName } from "$lib/group.svelte";
import { showToast } from "$lib/toast.svelte";
type Membership = "member" | "guest"; type Membership = "member" | "guest";
// "none": the relay doesn't support NIP-43, so no relay-level join exists.
type RelayMembership = Membership | "none";
// Per-group membership, resolved lazily and cached reactively so compose // Writing to a group can be gated at two levels: the relay itself (NIP-43) and
// actions can be gated before the user writes anything. Keyed by pubkey so a // the group (NIP-29). Neither gate is advertised up front, so membership is
// probed by reading the relay's membership events and, failing that, by trying
// to join and reacting to the rejection. Both caches are keyed by pubkey so a
// silent session restore (anon → pubkey) re-evaluates instead of reusing the // silent session restore (anon → pubkey) re-evaluates instead of reusing the
// "guest" cached before auth.user was populated; the read stays a pure getter // "guest" cached before auth.user was populated.
// (no state writes inside a derivation).
let membership = $state<Record<string, Membership>>({}); let membership = $state<Record<string, Membership>>({});
let relayMembership = $state<Record<string, RelayMembership>>({});
const checking = new Map<string, Promise<void>>(); const checking = new Map<string, Promise<void>>();
// The modal only appears when a relay refused a step and needs user input.
// - relay-code: the relay requires an invite code (NIP-43 claim)
// - group-code: the group refused the join; a code may be needed
// - pending: the group accepted the request but membership isn't granted yet
// (admin approval); an invite code, if any, can still be tried
// - error: a terminal refusal (e.g. banned); nothing the user can enter helps
export type JoinStep = "relay-code" | "group-code" | "pending" | "error";
let modalOpen = $state(false); let modalOpen = $state(false);
let modalStep = $state<JoinStep>("group-code");
let modalGroup = $state<string | null>(null); let modalGroup = $state<string | null>(null);
let modalError = $state<string | null>(null); let modalError = $state<string | null>(null);
// A rejection message that mentions a code makes the code field the primary
// affordance rather than a discreet fallback.
let modalCodeHinted = $state(false);
let busy = $state(false); let busy = $state(false);
// Continuation to run once the join succeeds (open the composer, focus the // Continuation to run once access is granted (open the composer, focus the
// reply box, reload a now-visible feed, or retry the original post). // reply box, reload a now-visible feed, or retry the original post).
let onJoined: (() => void | Promise<void>) | null = null; let onJoined: (() => void | Promise<void>) | null = null;
@ -29,26 +47,27 @@ export const joinState = {
get modalOpen() { get modalOpen() {
return modalOpen; return modalOpen;
}, },
get modalStep() {
return modalStep;
},
get modalError() { get modalError() {
return modalError; return modalError;
}, },
get modalCodeHinted() {
return modalCodeHinted;
},
get busy() { get busy() {
return busy; return busy;
}, },
// 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() { export function resetJoinState() {
membership = {}; membership = {};
relayMembership = {};
modalOpen = false; modalOpen = false;
modalGroup = null; modalGroup = null;
modalError = null; modalError = null;
modalCodeHinted = false;
busy = false; busy = false;
onJoined = null; onJoined = null;
} }
@ -85,11 +104,41 @@ export function ensureMembershipChecked(groupId: string): Promise<void> {
return p; return p;
} }
type SubFilter = Parameters<AbstractRelay["subscribe"]>[0][number];
// Collects every event matching the filter until EOSE/close/timeout.
function queryEvents(
relay: AbstractRelay,
filter: SubFilter,
timeoutMs = 3000,
): Promise<Event[]> {
return new Promise((resolve) => {
let settled = false;
const events: Event[] = [];
const finish = () => {
if (settled) return;
settled = true;
try {
sub.close();
} catch {}
resolve(events);
};
const sub = relay.subscribe([filter], {
onevent(e) {
events.push(e);
},
oneose: finish,
onclose: finish,
});
setTimeout(finish, timeoutMs);
});
}
// Existence check for a single matching event (resolves true on first event, // 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. // false on EOSE/close/timeout).
function queryHasMatch( function queryHasMatch(
relay: AbstractRelay, relay: AbstractRelay,
filter: Parameters<AbstractRelay["subscribe"]>[0][number], filter: SubFilter,
timeoutMs = 3000, timeoutMs = 3000,
): Promise<boolean> { ): Promise<boolean> {
return new Promise((resolve) => { return new Promise((resolve) => {
@ -120,39 +169,34 @@ function queryHasMatch(
// Newest `created_at` among events matching the filter, or null if none. We // 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 // 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). // returned events (the #p filter keeps the set tiny).
function latestCreatedAt( async function latestCreatedAt(
relay: AbstractRelay, relay: AbstractRelay,
filter: Parameters<AbstractRelay["subscribe"]>[0][number], filter: SubFilter,
timeoutMs = 3000,
): Promise<number | null> { ): Promise<number | null> {
return new Promise((resolve) => { const events = await queryEvents(relay, filter);
let settled = false; return events.reduce<number | null>(
let latest: number | null = null; (max, e) => (max === null || e.created_at > max ? e.created_at : max),
const finish = () => { null,
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". // Add/remove events are the freshest signal: the newest of the two decides
// Admins (kind:39001) can post but need not appear in the members list, so they // (a later removal unjoins). When the relay keeps neither, the list decides.
// count as members. Otherwise NIP-29 records membership as moderation events: function decideMembership(
// kind:9000 adds a user, kind:9001 removes one, so the newest of the two for added: number | null,
// this user decides current membership (a later 9001 unjoins them). When the removed: number | null,
// relay keeps neither, fall back to the kind:39002 members list. listed: boolean,
): boolean {
if (added !== null || removed !== null) {
return added !== null && added >= (removed ?? -Infinity);
}
return listed;
}
// Group membership — 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 kind:9000 (add) and
// kind:9001 (remove) moderation events, with the kind:39002 list as fallback.
async function checkMembership( async function checkMembership(
pubkey: string, pubkey: string,
groupId: string, groupId: string,
@ -163,7 +207,7 @@ async function checkMembership(
} catch { } catch {
return false; return false;
} }
const [isAdmin, added, removed] = await Promise.all([ const [isAdmin, added, removed, listed] = await Promise.all([
queryHasMatch(relay, { queryHasMatch(relay, {
kinds: [39001], kinds: [39001],
"#d": [groupId], "#d": [groupId],
@ -172,25 +216,225 @@ async function checkMembership(
}), }),
latestCreatedAt(relay, { kinds: [9000], "#h": [groupId], "#p": [pubkey] }), latestCreatedAt(relay, { kinds: [9000], "#h": [groupId], "#p": [pubkey] }),
latestCreatedAt(relay, { kinds: [9001], "#h": [groupId], "#p": [pubkey] }), latestCreatedAt(relay, { kinds: [9001], "#h": [groupId], "#p": [pubkey] }),
queryHasMatch(relay, {
kinds: [39002],
"#d": [groupId],
"#p": [pubkey],
limit: 1,
}),
]); ]);
if (isAdmin) return true; if (isAdmin) return true;
if (added !== null || removed !== null) { return decideMembership(added, removed, listed);
return added !== null && added >= (removed ?? -Infinity);
}
return queryHasMatch(relay, {
kinds: [39002],
"#d": [groupId],
"#p": [pubkey],
limit: 1,
});
} }
// Simple mode pre-checks the single configured group at login so the first post // Relay membership (NIP-43), mirroring the group check: kind:8000 (add) and
// skips the 9021. Full mode checks lazily per room when the user first acts. // kind:8001 (remove) signed by the relay, with the kind:13534 list as fallback.
// The `member` tag isn't filterable, so the list is fetched and scanned.
async function checkRelayMembership(
pubkey: string,
self: string,
): Promise<boolean> {
let relay: AbstractRelay;
try {
relay = await ensureForumRelay();
} catch {
return false;
}
const [added, removed, lists] = await Promise.all([
latestCreatedAt(relay, { kinds: [8000], authors: [self], "#p": [pubkey] }),
latestCreatedAt(relay, { kinds: [8001], authors: [self], "#p": [pubkey] }),
queryEvents(relay, { kinds: [13534], authors: [self], limit: 1 }),
]);
const listed = lists.some((e) =>
e.tags.some((t) => t[0] === "member" && t[1] === pubkey),
);
return decideMembership(added, removed, listed);
}
function ensureRelayMembershipChecked(pubkey: string): Promise<void> {
if (relayMembership[pubkey]) return Promise.resolve();
const k = `relay:${pubkey}`;
const existing = checking.get(k);
if (existing) return existing;
const p = (async () => {
try {
const self = await nip43Self();
if (!self) {
relayMembership[pubkey] = "none";
return;
}
const isMember = await checkRelayMembership(pubkey, self);
relayMembership[pubkey] = isMember ? "member" : "guest";
} finally {
checking.delete(k);
}
})();
checking.set(k, p);
return p;
}
// Probes both membership levels at login so a returning member's first post
// needs no join at all. Full mode checks groups lazily per room instead.
export async function initJoinForUser(pubkey: string) { export async function initJoinForUser(pubkey: string) {
if (MODE !== "simple") return; await Promise.all([
if (await checkMembership(pubkey, GROUP_ID)) { ensureRelayMembershipChecked(pubkey),
membership[memberKey(GROUP_ID)] = "member"; MODE === "simple" ? ensureMembershipChecked(GROUP_ID) : undefined,
]);
}
// Publishing rejections carry the relay's NIP-01 OK message; the prefix is the
// only cross-relay contract for what went wrong.
function reason(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}
const prefixed = (msg: string, prefix: string) =>
msg.toLowerCase().startsWith(prefix);
const stripPrefix = (msg: string) => msg.replace(/^[a-z-]+:\s*/i, "");
const mentionsCode = (msg: string) => /\b(code|invite|claim)\b/i.test(msg);
async function publishSigned(kind: number, tags: string[][]): Promise<void> {
if (!auth.signer) throw new Error("Not logged in");
const event = await auth.signer.signEvent({
kind,
created_at: Math.floor(Date.now() / 1000),
tags,
content: "",
});
const timeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("Relay did not respond in time")), 8000),
);
await Promise.race([Promise.all(publishForum(event)), timeout]);
}
type Codes = { relay?: string; group?: string };
type Refusal = { step: JoinStep; message: string | null; codeHinted: boolean };
type Outcome = { ok: true } | ({ ok: false } & Refusal);
const fail = (
step: JoinStep,
message: string | null,
codeHinted = false,
): Outcome => ({ ok: false, step, message, codeHinted });
// Runs the two-level join: relay first (a group join needs relay membership),
// then group. Everything free happens silently; the first refusal is reported
// as the step needing user input.
async function ensureAccess(groupId: string, codes: Codes): Promise<Outcome> {
const pubkey = auth.user?.pubkey;
if (!pubkey) return fail("error", "Not logged in");
await ensureRelayMembershipChecked(pubkey);
// Set when the relay refused the join for an unclear reason: a group
// refusal right after is then more likely the relay gate than the group's.
let relayRefused = false;
if (relayMembership[pubkey] === "guest") {
const tags: string[][] = [["-"]];
if (codes.relay) tags.push(["claim", codes.relay]);
try {
await publishSigned(28934, tags);
relayMembership[pubkey] = "member";
} catch (e) {
const msg = reason(e);
if (prefixed(msg, "duplicate:")) {
relayMembership[pubkey] = "member";
} else if (prefixed(msg, "restricted:")) {
// A bare request refused is the expected probe, not a user error
return fail("relay-code", codes.relay ? stripPrefix(msg) : null, true);
} else if (prefixed(msg, "blocked:")) {
return fail("error", stripPrefix(msg));
} else {
relayRefused = true;
}
}
}
await ensureMembershipChecked(groupId);
const k = memberKey(groupId);
if (membership[k] === "member") return { ok: true };
const tags: string[][] = [["h", groupId]];
if (codes.group) tags.push(["code", codes.group]);
try {
await publishSigned(9021, tags);
} catch (e) {
const msg = reason(e);
if (!prefixed(msg, "duplicate:")) {
if (prefixed(msg, "restricted:") && relayRefused) {
return fail("relay-code", stripPrefix(msg), true);
}
if (prefixed(msg, "restricted:")) {
const hinted = mentionsCode(msg);
return fail(
"group-code",
codes.group || !hinted ? stripPrefix(msg) : null,
hinted,
);
}
return fail("error", stripPrefix(msg));
}
}
// An accepted request doesn't imply membership: closed groups may hold it
// for admin approval, so re-read the relay's records before trusting it.
if (await checkMembership(pubkey, groupId)) {
membership[k] = "member";
showToast(`You joined ${getGroupName(groupId)}`);
return { ok: true };
}
return fail("pending", codes.group ? "The code was not accepted" : null);
}
function openModal(
groupId: string,
refusal: Refusal,
cb: (() => void | Promise<void>) | null,
) {
modalGroup = groupId;
onJoined = cb;
modalStep = refusal.step;
modalCodeHinted = refusal.codeHinted;
modalError = refusal.message;
modalOpen = true;
}
// Wraps an action that posts to `groupId`, joining relay and group first when
// needed. A write refused for membership reasons (e.g. a stale cache after the
// relay changed policy) invalidates both caches and retries once from the top.
// Returns false when the modal took over; the action then runs on its success.
export async function withJoin(
groupId: string,
action: () => void | Promise<void>,
): Promise<boolean> {
if (busy) return false;
busy = true;
try {
return await runWithAccess(groupId, action, {}, true);
} finally {
busy = false;
}
}
async function runWithAccess(
groupId: string,
action: () => void | Promise<void>,
codes: Codes,
retry: boolean,
): Promise<boolean> {
const outcome = await ensureAccess(groupId, codes);
if (!outcome.ok) {
openModal(groupId, outcome, action);
return false;
}
try {
await action();
return true;
} catch (e) {
const pubkey = auth.user?.pubkey;
if (retry && pubkey && prefixed(reason(e), "restricted:")) {
delete membership[memberKey(groupId)];
if (relayMembership[pubkey] === "member")
relayMembership[pubkey] = "guest";
return runWithAccess(groupId, action, codes, false);
}
throw e;
} }
} }
@ -202,86 +446,28 @@ export function closeJoinModal() {
onJoined = null; onJoined = null;
} }
// Opens the join modal for a group, with an optional continuation to run after // Re-runs the join with the code entered for the current step. Errors keep the
// a successful join (open the composer, focus the reply box, reload the feed). // modal open for a retry; a further gate switches the modal to that step.
export function openJoinModal( export async function submitJoinCode(code?: string) {
groupId: string,
cb?: () => void | Promise<void>,
) {
modalGroup = groupId;
onJoined = cb ?? null;
modalError = null;
modalOpen = true;
}
async function publishJoinRequest(groupId: string, code?: string) {
if (!auth.signer) throw new Error("Not logged in");
const tags: string[][] = [["h", groupId]];
if (code) tags.push(["code", code]);
const event = await auth.signer.signEvent({
kind: 9021,
created_at: Math.floor(Date.now() / 1000),
tags,
content: "",
});
const timeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("Relay did not respond in time")), 8000),
);
await Promise.race([Promise.all(publishForum(event)), timeout]);
}
// 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; if (!modalGroup || busy) return;
busy = true; busy = true;
modalError = null; modalError = null;
const groupId = modalGroup; const groupId = modalGroup;
const cb = onJoined; const cb = onJoined;
const codes: Codes =
modalStep === "relay-code" ? { relay: code } : { group: code };
try { try {
await publishJoinRequest(groupId, code); const outcome = await ensureAccess(groupId, codes);
membership[memberKey(groupId)] = "member"; if (!outcome.ok) {
openModal(groupId, outcome, cb);
return;
}
modalOpen = false; modalOpen = false;
modalGroup = null; modalGroup = null;
onJoined = null; onJoined = null;
if (cb) await cb(); if (cb) await cb();
} catch (e) { } catch (e) {
modalError = e instanceof Error ? e.message : "Could not join the group"; modalError = reason(e);
} 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 (membership[memberKey(groupId)] === "member") {
await action();
return true;
}
busy = true;
try {
const pubkey = auth.user?.pubkey;
if (pubkey && (await checkMembership(pubkey, groupId))) {
membership[memberKey(groupId)] = "member";
await action();
return true;
}
await publishJoinRequest(groupId);
await action();
membership[memberKey(groupId)] = "member";
return true;
} catch (e) {
modalGroup = groupId;
onJoined = action;
modalError = e instanceof Error ? e.message : "Could not join the group";
modalOpen = true;
return false;
} finally { } finally {
busy = false; busy = false;
} }

View file

@ -26,7 +26,9 @@ export const forumPool = pool;
function authParam() { function authParam() {
const signer = auth.signer; const signer = auth.signer;
return signer ? { onauth: (evt: EventTemplate) => signer.signEvent(evt) } : {}; return signer
? { onauth: (evt: EventTemplate) => signer.signEvent(evt) }
: {};
} }
// Resolves once the forum connection is open and, when logged in, NIP-42 // Resolves once the forum connection is open and, when logged in, NIP-42
@ -104,3 +106,33 @@ export async function ensureForumRelay(): Promise<AbstractRelay> {
export function resetForumConnection(): void { export function resetForumConnection(): void {
pool.close([RELAY_URL]); pool.close([RELAY_URL]);
} }
// NIP-11 document of the forum relay, fetched once per session. Used for NIP-43
// discovery: clients must only send relay join requests to relays advertising
// it, and the `self` pubkey signs the membership events we read back.
let relayInfo: Promise<{ self: string | null; nips: string[] }> | null = null;
export function forumRelayInfo() {
if (!relayInfo) {
relayInfo = import("@nostr/tools/nip11")
.then(({ fetchRelayInformation }) => fetchRelayInformation(RELAY_URL))
.then((info) => {
// `self` is a NIP-43 addition the library type doesn't know yet
const self = (info as { self?: unknown }).self;
return {
self: typeof self === "string" ? self : null,
// Relays mix numbers and strings in this list
nips: (info.supported_nips ?? []).map(String),
};
})
.catch(() => ({ self: null, nips: [] }));
}
return relayInfo;
}
// The relay pubkey when the forum relay supports NIP-43 relay membership,
// otherwise null (no relay-level join is needed or allowed).
export async function nip43Self(): Promise<string | null> {
const info = await forumRelayInfo();
return info.nips.includes("43") ? info.self : null;
}

View file

@ -19,9 +19,9 @@
import { showToast } from "$lib/toast.svelte"; import { showToast } from "$lib/toast.svelte";
import { import {
withJoin, withJoin,
joinState,
membershipOf, membershipOf,
ensureMembershipChecked, ensureMembershipChecked,
openJoinModal,
} from "$lib/join.svelte"; } from "$lib/join.svelte";
import { setActiveGroup } from "$lib/active.svelte"; import { setActiveGroup } from "$lib/active.svelte";
import { applyHighlights, clearHighlights } from "$lib/pageHighlight"; import { applyHighlights, clearHighlights } from "$lib/pageHighlight";
@ -136,7 +136,7 @@
function onJoinToReply() { function onJoinToReply() {
if (!detail) return; if (!detail) return;
openJoinModal(detail.groupId, async () => { withJoin(detail.groupId, async () => {
await tick(); await tick();
editorEl?.focus(); editorEl?.focus();
}); });
@ -514,9 +514,10 @@
</p> </p>
<button <button
onclick={onJoinToReply} onclick={onJoinToReply}
class="bg-accent hover:bg-accent-hover rounded px-6 py-1.5 font-medium text-white" disabled={joinState.busy}
class="bg-accent hover:bg-accent-hover rounded px-6 py-1.5 font-medium text-white disabled:opacity-50"
> >
Join to reply {joinState.busy ? "Joining…" : "Join to reply"}
</button> </button>
</div> </div>
{:else} {:else}