Join relay and group dynamically, dropping PUBLIC_JOINCODE
This commit is contained in:
parent
aaa9a0c1e5
commit
6ecd618bce
9 changed files with 453 additions and 178 deletions
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 ?? "")
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue