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
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
>
|
>
|
||||||
|
{#if step === "relay-code"}
|
||||||
|
Invite code required
|
||||||
|
{:else if step === "pending"}
|
||||||
|
Request sent
|
||||||
|
{:else if step === "error"}
|
||||||
|
Could not join
|
||||||
|
{:else}
|
||||||
Join this group
|
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>
|
||||||
|
{#if showCode}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={onSubmit}
|
onclick={onSubmit}
|
||||||
disabled={joinState.busy}
|
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"
|
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
|
{joinState.busy ? "Joining…" : "Join"}
|
||||||
? "Joining…"
|
|
||||||
: joinState.needsCode
|
|
||||||
? "Request access"
|
|
||||||
: "Join"}
|
|
||||||
</button>
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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 ?? "")
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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, {
|
||||||
if (isAdmin) return true;
|
|
||||||
if (added !== null || removed !== null) {
|
|
||||||
return added !== null && added >= (removed ?? -Infinity);
|
|
||||||
}
|
|
||||||
return queryHasMatch(relay, {
|
|
||||||
kinds: [39002],
|
kinds: [39002],
|
||||||
"#d": [groupId],
|
"#d": [groupId],
|
||||||
"#p": [pubkey],
|
"#p": [pubkey],
|
||||||
limit: 1,
|
limit: 1,
|
||||||
});
|
}),
|
||||||
|
]);
|
||||||
|
if (isAdmin) return true;
|
||||||
|
return decideMembership(added, removed, listed);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue