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

@ -15,9 +15,9 @@
} from "$lib/moderation.svelte";
import {
withJoin,
joinState,
membershipOf,
ensureMembershipChecked,
openJoinModal,
} from "$lib/join.svelte";
import { activeGroup } from "$lib/active.svelte";
import type { NostrUser } from "@nostr/gadgets/metadata";
@ -76,7 +76,7 @@
});
function onJoinToChat() {
openJoinModal(activeGroup.id, () => tick().then(() => inputEl?.focus()));
withJoin(activeGroup.id, () => tick().then(() => inputEl?.focus()));
}
function requestDeleteMessage(msg: ChatMessageData) {
@ -486,9 +486,10 @@
{:else 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"
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>
{:else}
<div class="relative">

View file

@ -18,7 +18,8 @@
import {
membershipOf,
ensureMembershipChecked,
openJoinModal,
withJoin,
joinState,
} from "$lib/join.svelte";
import { sortPref } from "$lib/sort.svelte";
import { page } from "$app/state";
@ -71,12 +72,7 @@
openLogin(onNewTopic);
return;
}
await ensureMembershipChecked(groupId);
if (membershipOf(groupId) !== "member") {
openJoinModal(groupId, openDraft);
return;
}
openDraft();
await withJoin(groupId, openDraft);
}
function onPrivateJoin() {
@ -84,7 +80,7 @@
openLogin();
return;
}
openJoinModal(groupId, () => loadThreads(groupId, sort));
withJoin(groupId, () => loadThreads(groupId, sort));
}
function relativeTime(ts: number): string {
@ -138,9 +134,14 @@
<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"
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>
<SortToggle {sort} />
</div>
@ -165,9 +166,14 @@
</p>
<button
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>
</div>
{:else}

View file

@ -1,19 +1,38 @@
<script lang="ts">
import { joinState, closeJoinModal, submitJoin } from "$lib/join.svelte";
import { joinState, closeJoinModal, submitJoinCode } from "$lib/join.svelte";
import { tick } from "svelte";
let code = $state("");
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(() => {
if (joinState.modalOpen && joinState.needsCode) {
if (!joinState.modalOpen) {
code = "";
codeRevealed = false;
}
});
$effect(() => {
if (joinState.modalOpen && showCode) {
tick().then(() => codeInput?.focus());
}
if (!joinState.modalOpen) code = "";
});
async function onSubmit() {
await submitJoin(code.trim() || undefined);
await submitJoinCode(code.trim() || undefined);
}
function onClose() {
@ -56,15 +75,28 @@
id="join-title"
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>
<p class="mb-4 text-sm text-neutral-600 dark:text-neutral-400">
{#if joinState.needsCode}
Join this group to participate. If you have an invite code, enter it
below — otherwise just send the request.
{#if step === "relay-code"}
This relay only accepts members. Enter an invite code to join.
{: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}
Join this group to participate. Your request is sent to the relay and,
where needed, approved by an admin.
The group refused the join request.
{/if}
</p>
@ -77,13 +109,17 @@
</div>
{/if}
{#if joinState.needsCode}
<label for="join-code-input" class="sr-only">Invite code</label>
{#if showCode}
<label for="join-code-input" class="sr-only">
{step === "relay-code" ? "Relay invite code" : "Group invite code"}
</label>
<input
id="join-code-input"
bind:this={codeInput}
type="text"
placeholder="Invite code (optional)"
placeholder={step === "relay-code"
? "Relay invite code"
: "Group invite code"}
bind:value={code}
disabled={joinState.busy}
autocomplete="off"
@ -92,6 +128,14 @@
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"
/>
{: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}
<div class="flex justify-end gap-2">
@ -103,18 +147,16 @@
>
Close
</button>
<button
type="button"
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
? "Joining…"
: joinState.needsCode
? "Request access"
: "Join"}
</button>
{#if showCode}
<button
type="button"
onclick={onSubmit}
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…" : "Join"}
</button>
{/if}
</div>
</div>
</div>

View file

@ -18,7 +18,6 @@ export const GROUP_ID = env.PUBLIC_GROUP_ID ?? "";
if (MODE === "simple" && !GROUP_ID) {
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.
export const SEARCH_ENABLED = env.PUBLIC_SEARCH === "yes";
export const LABELS = (env.PUBLIC_LABELS ?? "")

View file

@ -82,3 +82,12 @@ export function getGroupFlags(groupId: string): GroupFlags | null {
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 { Event } from "@nostr/tools/core";
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";
import { GROUP_ID, MODE } from "$lib/config";
import { ensureForumRelay, publishForum, nip43Self } from "$lib/relay";
import { getGroupName } from "$lib/group.svelte";
import { showToast } from "$lib/toast.svelte";
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
// actions can be gated before the user writes anything. Keyed by pubkey so a
// Writing to a group can be gated at two levels: the relay itself (NIP-43) and
// 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
// "guest" cached before auth.user was populated; the read stays a pure getter
// (no state writes inside a derivation).
// "guest" cached before auth.user was populated.
let membership = $state<Record<string, Membership>>({});
let relayMembership = $state<Record<string, RelayMembership>>({});
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 modalStep = $state<JoinStep>("group-code");
let modalGroup = $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);
// 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).
let onJoined: (() => void | Promise<void>) | null = null;
@ -29,26 +47,27 @@ export const joinState = {
get modalOpen() {
return modalOpen;
},
get modalStep() {
return modalStep;
},
get modalError() {
return modalError;
},
get modalCodeHinted() {
return modalCodeHinted;
},
get 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() {
membership = {};
relayMembership = {};
modalOpen = false;
modalGroup = null;
modalError = null;
modalCodeHinted = false;
busy = false;
onJoined = null;
}
@ -85,11 +104,41 @@ export function ensureMembershipChecked(groupId: string): Promise<void> {
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,
// false on EOSE/close/timeout). Used for the kind:39002 members-list fallback.
// false on EOSE/close/timeout).
function queryHasMatch(
relay: AbstractRelay,
filter: Parameters<AbstractRelay["subscribe"]>[0][number],
filter: SubFilter,
timeoutMs = 3000,
): Promise<boolean> {
return new Promise((resolve) => {
@ -120,39 +169,34 @@ function queryHasMatch(
// 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(
async function latestCreatedAt(
relay: AbstractRelay,
filter: Parameters<AbstractRelay["subscribe"]>[0][number],
timeoutMs = 3000,
filter: SubFilter,
): 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);
});
const events = await queryEvents(relay, filter);
return events.reduce<number | null>(
(max, e) => (max === null || e.created_at > max ? e.created_at : max),
null,
);
}
// 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.
// Add/remove events are the freshest signal: the newest of the two decides
// (a later removal unjoins). When the relay keeps neither, the list decides.
function decideMembership(
added: number | null,
removed: number | null,
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(
pubkey: string,
groupId: string,
@ -163,7 +207,7 @@ async function checkMembership(
} catch {
return false;
}
const [isAdmin, added, removed] = await Promise.all([
const [isAdmin, added, removed, listed] = await Promise.all([
queryHasMatch(relay, {
kinds: [39001],
"#d": [groupId],
@ -172,25 +216,225 @@ async function checkMembership(
}),
latestCreatedAt(relay, { kinds: [9000], "#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 (added !== null || removed !== null) {
return added !== null && added >= (removed ?? -Infinity);
}
return queryHasMatch(relay, {
kinds: [39002],
"#d": [groupId],
"#p": [pubkey],
limit: 1,
});
return decideMembership(added, removed, listed);
}
// Simple mode pre-checks the single configured group at login so the first post
// skips the 9021. Full mode checks lazily per room when the user first acts.
// Relay membership (NIP-43), mirroring the group check: kind:8000 (add) and
// 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) {
if (MODE !== "simple") return;
if (await checkMembership(pubkey, GROUP_ID)) {
membership[memberKey(GROUP_ID)] = "member";
await Promise.all([
ensureRelayMembershipChecked(pubkey),
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;
}
// 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) {
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) {
// Re-runs the join with the code entered for the current step. Errors keep the
// modal open for a retry; a further gate switches the modal to that step.
export async function submitJoinCode(code?: string) {
if (!modalGroup || busy) return;
busy = true;
modalError = null;
const groupId = modalGroup;
const cb = onJoined;
const codes: Codes =
modalStep === "relay-code" ? { relay: code } : { group: code };
try {
await publishJoinRequest(groupId, code);
membership[memberKey(groupId)] = "member";
const outcome = await ensureAccess(groupId, codes);
if (!outcome.ok) {
openModal(groupId, outcome, cb);
return;
}
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 (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;
modalError = reason(e);
} finally {
busy = false;
}

View file

@ -26,7 +26,9 @@ export const forumPool = pool;
function authParam() {
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
@ -104,3 +106,33 @@ export async function ensureForumRelay(): Promise<AbstractRelay> {
export function resetForumConnection(): void {
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 {
withJoin,
joinState,
membershipOf,
ensureMembershipChecked,
openJoinModal,
} from "$lib/join.svelte";
import { setActiveGroup } from "$lib/active.svelte";
import { applyHighlights, clearHighlights } from "$lib/pageHighlight";
@ -136,7 +136,7 @@
function onJoinToReply() {
if (!detail) return;
openJoinModal(detail.groupId, async () => {
withJoin(detail.groupId, async () => {
await tick();
editorEl?.focus();
});
@ -514,9 +514,10 @@
</p>
<button
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>
</div>
{:else}