Move join before composing, and cache membership state
This commit is contained in:
parent
fadac80033
commit
30f52af4b1
10 changed files with 503 additions and 149 deletions
|
|
@ -19,6 +19,9 @@ export type Signer = {
|
|||
let user = $state<NostrUser | null>(null);
|
||||
let signer = $state<Signer | null>(null);
|
||||
let loginModalOpen = $state(false);
|
||||
// Optional action to run once login succeeds, so an intent like "post" started
|
||||
// while logged out resumes (login => join => composer) instead of being dropped.
|
||||
let afterLogin: (() => void) | null = null;
|
||||
// Bumped on explicit login/logout (not on silent session restore) so the app
|
||||
// can re-fetch identity-scoped data — e.g. reload the room list once the relay
|
||||
// will serve the user's private/hidden groups.
|
||||
|
|
@ -43,12 +46,23 @@ const PUBKEY_KEY = "nostr_pubkey";
|
|||
const METHOD_KEY = "nostr_login_method";
|
||||
const NSEC_KEY = "nostr_nsec";
|
||||
|
||||
export function openLogin() {
|
||||
export function openLogin(after?: () => void) {
|
||||
afterLogin = after ?? null;
|
||||
loginModalOpen = true;
|
||||
}
|
||||
|
||||
export function closeLogin() {
|
||||
loginModalOpen = false;
|
||||
afterLogin = null;
|
||||
}
|
||||
|
||||
// Closes the modal and runs the pending intent (if any). Called from the login
|
||||
// flows so the continuation fires with the modal already gone (no stacking).
|
||||
function runAfterLogin() {
|
||||
const cb = afterLogin;
|
||||
afterLogin = null;
|
||||
loginModalOpen = false;
|
||||
cb?.();
|
||||
}
|
||||
|
||||
function makeNsecSigner(secretKey: Uint8Array): Signer {
|
||||
|
|
@ -97,6 +111,7 @@ export async function loginWithExtension() {
|
|||
localStorage.removeItem(NSEC_KEY);
|
||||
await setUser(pubkey);
|
||||
sessionEpoch++;
|
||||
runAfterLogin();
|
||||
}
|
||||
|
||||
function parseSecretKey(input: string): Uint8Array {
|
||||
|
|
@ -128,6 +143,7 @@ export async function loginWithNsec(input: string) {
|
|||
localStorage.setItem(NSEC_KEY, nsec);
|
||||
await setUser(pubkey);
|
||||
sessionEpoch++;
|
||||
runAfterLogin();
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,12 @@
|
|||
import { auth, openLogin } from "$lib/auth.svelte";
|
||||
import { isGroupAdmin } from "$lib/admins.svelte";
|
||||
import { requestDelete } from "$lib/moderation.svelte";
|
||||
import { withJoin } from "$lib/join.svelte";
|
||||
import {
|
||||
withJoin,
|
||||
membershipOf,
|
||||
ensureMembershipChecked,
|
||||
openJoinModal,
|
||||
} from "$lib/join.svelte";
|
||||
import { activeGroup } from "$lib/active.svelte";
|
||||
import type { NostrUser } from "@nostr/gadgets/metadata";
|
||||
import MentionAutocomplete from "$lib/components/MentionAutocomplete.svelte";
|
||||
|
|
@ -46,6 +51,24 @@
|
|||
!!auth.user && isGroupAdmin(auth.user.pubkey, activeGroup.id),
|
||||
);
|
||||
|
||||
// Sending needs membership regardless of flags. Only gate a confirmed guest;
|
||||
// while membership resolves the input stays (withJoin nets a stray send), so a
|
||||
// member never flashes "Join to chat".
|
||||
const joinToChat = $derived(
|
||||
!!auth.user && membershipOf(activeGroup.id) === "guest",
|
||||
);
|
||||
|
||||
// Resolve membership on entry. Depends on auth.user so a silent session
|
||||
// restore re-runs the check.
|
||||
$effect(() => {
|
||||
auth.user;
|
||||
if (activeGroup.id) ensureMembershipChecked(activeGroup.id);
|
||||
});
|
||||
|
||||
function onJoinToChat() {
|
||||
openJoinModal(activeGroup.id, () => tick().then(() => inputEl?.focus()));
|
||||
}
|
||||
|
||||
function requestDeleteMessage(msg: ChatMessageData) {
|
||||
openMenuId = null;
|
||||
requestDelete(
|
||||
|
|
@ -431,15 +454,24 @@
|
|||
{sendError}
|
||||
</div>
|
||||
{/if}
|
||||
<MentionAutocomplete
|
||||
bind:this={inputEl}
|
||||
bind:value={inputValue}
|
||||
onkeydown={onKeydown}
|
||||
rows={1}
|
||||
disabled={sending}
|
||||
placeholder={auth.user ? "Message..." : "Login to send messages"}
|
||||
{contextPubkeys}
|
||||
textareaClass="w-full resize-none rounded border border-neutral-200 dark:border-neutral-700 px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-accent disabled:opacity-50"
|
||||
/>
|
||||
{#if joinToChat}
|
||||
<button
|
||||
onclick={onJoinToChat}
|
||||
class="bg-accent hover:bg-accent-hover w-full rounded px-3 py-2 text-sm font-medium text-white"
|
||||
>
|
||||
Join to chat
|
||||
</button>
|
||||
{:else}
|
||||
<MentionAutocomplete
|
||||
bind:this={inputEl}
|
||||
bind:value={inputValue}
|
||||
onkeydown={onKeydown}
|
||||
rows={1}
|
||||
disabled={sending}
|
||||
placeholder={auth.user ? "Message..." : "Login to send messages"}
|
||||
{contextPubkeys}
|
||||
textareaClass="w-full resize-none rounded border border-neutral-200 dark:border-neutral-700 px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-accent disabled:opacity-50"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
|
|
|
|||
|
|
@ -14,6 +14,12 @@
|
|||
import type { NostrUser } from "@nostr/gadgets/metadata";
|
||||
import { auth, openLogin } from "$lib/auth.svelte";
|
||||
import { openDraft } from "$lib/draft.svelte";
|
||||
import { getGroupFlags } from "$lib/group.svelte";
|
||||
import {
|
||||
membershipOf,
|
||||
ensureMembershipChecked,
|
||||
openJoinModal,
|
||||
} from "$lib/join.svelte";
|
||||
import { sortPref } from "$lib/sort.svelte";
|
||||
import { page } from "$app/state";
|
||||
|
||||
|
|
@ -38,17 +44,47 @@
|
|||
if (urlSort) sortPref.value = urlSort;
|
||||
});
|
||||
|
||||
const flags = $derived(getGroupFlags(groupId));
|
||||
const member = $derived(membershipOf(groupId));
|
||||
|
||||
// The relay requires membership to post to any group, so resolve it on entry
|
||||
// for every room. Depends on auth.user so a silent session restore re-runs it.
|
||||
$effect(() => {
|
||||
auth.user;
|
||||
ensureMembershipChecked(groupId);
|
||||
});
|
||||
|
||||
const showPrivateGate = $derived(!!flags?.isPrivate && member === "guest");
|
||||
const checkingAccess = $derived(!!flags?.isPrivate && member === "unknown");
|
||||
// Posting needs membership regardless of flags. Only gate a confirmed guest;
|
||||
// while membership is still resolving the click awaits the check, so a member
|
||||
// never flashes "Join to post".
|
||||
const joinToPost = $derived(!!auth.user && member === "guest");
|
||||
|
||||
// Re-runs on mount and whenever the group or effective sort changes.
|
||||
$effect(() => {
|
||||
loadThreads(groupId, sort);
|
||||
});
|
||||
|
||||
function onNewTopic() {
|
||||
async function onNewTopic() {
|
||||
if (!auth.user) {
|
||||
openLogin(onNewTopic);
|
||||
return;
|
||||
}
|
||||
await ensureMembershipChecked(groupId);
|
||||
if (membershipOf(groupId) !== "member") {
|
||||
openJoinModal(groupId, openDraft);
|
||||
return;
|
||||
}
|
||||
openDraft();
|
||||
}
|
||||
|
||||
function onPrivateJoin() {
|
||||
if (!auth.user) {
|
||||
openLogin();
|
||||
return;
|
||||
}
|
||||
openDraft();
|
||||
openJoinModal(groupId, () => loadThreads(groupId, sort));
|
||||
}
|
||||
|
||||
function relativeTime(ts: number): string {
|
||||
|
|
@ -98,39 +134,72 @@
|
|||
<div class="mx-auto max-w-6xl">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 py-2">
|
||||
<h1 class="text-accent text-[1.65rem] leading-7">{title}</h1>
|
||||
<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"
|
||||
>
|
||||
New discussion
|
||||
</button>
|
||||
<SortToggle {sort} />
|
||||
</div>
|
||||
{#if !showPrivateGate && !checkingAccess}
|
||||
<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"
|
||||
>
|
||||
{joinToPost ? "Join to post" : "New discussion"}
|
||||
</button>
|
||||
<SortToggle {sort} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{#each rows as thread}
|
||||
<ThreadItem {thread} />
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if threadStore.loading && rows.length === 0}
|
||||
<p class="py-6 text-center text-sm text-neutral-400 dark:text-neutral-500">
|
||||
Loading discussions…
|
||||
{#if checkingAccess}
|
||||
<p class="py-12 text-center text-sm text-neutral-400 dark:text-neutral-500">
|
||||
Checking access…
|
||||
</p>
|
||||
{:else if !threadStore.exhausted}
|
||||
<div class="flex justify-center py-6">
|
||||
<button
|
||||
onclick={() => loadMore(groupId)}
|
||||
disabled={threadStore.loadingMore}
|
||||
aria-busy={threadStore.loadingMore}
|
||||
class="rounded border border-neutral-200 dark:border-neutral-700 px-6 py-1.5 text-sm font-medium text-neutral-700 dark:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-800 disabled:opacity-50"
|
||||
{:else if showPrivateGate}
|
||||
<div
|
||||
class="mt-6 rounded-lg border border-neutral-200 px-6 py-10 text-center dark:border-neutral-700"
|
||||
>
|
||||
<h2 class="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
This room is private
|
||||
</h2>
|
||||
<p
|
||||
class="mx-auto mt-2 max-w-md text-sm text-neutral-600 dark:text-neutral-400"
|
||||
>
|
||||
{threadStore.loadingMore ? "Loading…" : "Show more"}
|
||||
Only members can read its discussions. Join to request access.
|
||||
</p>
|
||||
<button
|
||||
onclick={onPrivateJoin}
|
||||
class="bg-accent hover:bg-accent-hover mt-5 rounded px-6 py-1.5 font-medium text-white"
|
||||
>
|
||||
{auth.user ? "Request to join" : "Log in to join"}
|
||||
</button>
|
||||
</div>
|
||||
{:else if rows.length > 0}
|
||||
<p class="py-6 text-center text-sm text-neutral-400 dark:text-neutral-500">No more discussions</p>
|
||||
{:else}
|
||||
<div>
|
||||
{#each rows as thread}
|
||||
<ThreadItem {thread} />
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if threadStore.loading && rows.length === 0}
|
||||
<p
|
||||
class="py-6 text-center text-sm text-neutral-400 dark:text-neutral-500"
|
||||
>
|
||||
Loading discussions…
|
||||
</p>
|
||||
{:else if !threadStore.exhausted}
|
||||
<div class="flex justify-center py-6">
|
||||
<button
|
||||
onclick={() => loadMore(groupId)}
|
||||
disabled={threadStore.loadingMore}
|
||||
aria-busy={threadStore.loadingMore}
|
||||
class="rounded border border-neutral-200 px-6 py-1.5 text-sm font-medium text-neutral-700 hover:bg-neutral-50 disabled:opacity-50 dark:border-neutral-700 dark:text-neutral-300 dark:hover:bg-neutral-800"
|
||||
>
|
||||
{threadStore.loadingMore ? "Loading…" : "Show more"}
|
||||
</button>
|
||||
</div>
|
||||
{:else if rows.length > 0}
|
||||
<p
|
||||
class="py-6 text-center text-sm text-neutral-400 dark:text-neutral-500"
|
||||
>
|
||||
No more discussions
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,19 +1,19 @@
|
|||
<script lang="ts">
|
||||
import { joinState, closeJoinModal, retryJoin } from "$lib/join.svelte";
|
||||
import { joinState, closeJoinModal, submitJoin } from "$lib/join.svelte";
|
||||
import { tick } from "svelte";
|
||||
|
||||
let code = $state("");
|
||||
let codeInput = $state<HTMLInputElement | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (joinState.modalOpen && joinState.codeRequired) {
|
||||
if (joinState.modalOpen && joinState.needsCode) {
|
||||
tick().then(() => codeInput?.focus());
|
||||
}
|
||||
if (!joinState.modalOpen) code = "";
|
||||
});
|
||||
|
||||
async function onRetry() {
|
||||
await retryJoin(code.trim() || undefined);
|
||||
async function onSubmit() {
|
||||
await submitJoin(code.trim() || undefined);
|
||||
}
|
||||
|
||||
function onClose() {
|
||||
|
|
@ -37,7 +37,7 @@
|
|||
onclick={onClose}
|
||||
></button>
|
||||
<div
|
||||
class="relative w-full max-w-md rounded-lg bg-white dark:bg-neutral-900 p-6 shadow-xl"
|
||||
class="relative w-full max-w-md rounded-lg bg-white p-6 shadow-xl dark:bg-neutral-900"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="join-title"
|
||||
|
|
@ -47,21 +47,24 @@
|
|||
onclick={onClose}
|
||||
aria-label="Close"
|
||||
disabled={joinState.busy}
|
||||
class="absolute top-3 right-3 text-2xl leading-none text-neutral-400 dark:text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300 disabled:opacity-50"
|
||||
class="absolute top-3 right-3 text-2xl leading-none text-neutral-400 hover:text-neutral-700 disabled:opacity-50 dark:text-neutral-500 dark:hover:text-neutral-300"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
||||
<h2 id="join-title" class="mb-2 text-lg font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
<h2
|
||||
id="join-title"
|
||||
class="mb-2 text-lg font-semibold text-neutral-900 dark:text-neutral-100"
|
||||
>
|
||||
Join this group
|
||||
</h2>
|
||||
<p class="mb-4 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{#if joinState.codeRequired}
|
||||
This community requires an invite code. Enter your code below to
|
||||
request access. If you don't have one, contact the admin.
|
||||
{#if joinState.needsCode}
|
||||
Join this group to participate. If you have an invite code, enter it
|
||||
below — otherwise just send the request.
|
||||
{:else}
|
||||
We could not add you to the group automatically. The relay may be
|
||||
processing your request, or the admin needs to approve it manually.
|
||||
Join this group to participate. Your request is sent to the relay and,
|
||||
where needed, approved by an admin.
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
|
|
@ -74,20 +77,20 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
{#if joinState.codeRequired}
|
||||
{#if joinState.needsCode}
|
||||
<label for="join-code-input" class="sr-only">Invite code</label>
|
||||
<input
|
||||
id="join-code-input"
|
||||
bind:this={codeInput}
|
||||
type="text"
|
||||
placeholder="Invite code"
|
||||
placeholder="Invite code (optional)"
|
||||
bind:value={code}
|
||||
disabled={joinState.busy}
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
onkeydown={(e) => e.key === "Enter" && onRetry()}
|
||||
class="focus:ring-accent mb-3 w-full rounded border border-neutral-200 dark:border-neutral-700 px-3 py-2 text-sm focus:ring-1 focus:outline-none disabled:opacity-50"
|
||||
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"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
|
|
@ -96,17 +99,21 @@
|
|||
type="button"
|
||||
onclick={onClose}
|
||||
disabled={joinState.busy}
|
||||
class="rounded px-3 py-2 text-sm font-medium text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800 disabled:opacity-50"
|
||||
class="rounded px-3 py-2 text-sm font-medium text-neutral-600 hover:bg-neutral-100 disabled:opacity-50 dark:text-neutral-400 dark:hover:bg-neutral-800"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onRetry}
|
||||
disabled={joinState.busy || (joinState.codeRequired && !code.trim())}
|
||||
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 ? "Trying…" : "Retry"}
|
||||
{joinState.busy
|
||||
? "Joining…"
|
||||
: joinState.needsCode
|
||||
? "Request access"
|
||||
: "Join"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -170,7 +170,7 @@
|
|||
{:else}
|
||||
<div class="mt-3 flex items-center gap-1">
|
||||
<button
|
||||
onclick={openLogin}
|
||||
onclick={() => openLogin()}
|
||||
class="bg-accent hover:bg-accent-hover min-w-0 flex-1 rounded px-3 py-1.5 text-sm font-medium text-white"
|
||||
>
|
||||
Login
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { GROUP_ID } from "$lib/config";
|
||||
import { GROUP_ID, MODE } from "$lib/config";
|
||||
import { queryForum } from "$lib/relay";
|
||||
import { groupsStore } from "$lib/groups.svelte";
|
||||
|
||||
export type GroupMetadata = {
|
||||
name: string;
|
||||
|
|
@ -7,9 +8,20 @@ export type GroupMetadata = {
|
|||
about?: string;
|
||||
isPrivate: boolean;
|
||||
isClosed: boolean;
|
||||
isRestricted: boolean;
|
||||
isHidden: boolean;
|
||||
admins: string[];
|
||||
};
|
||||
|
||||
// NIP-29 access flags: private = members-only read, restricted = members-only
|
||||
// write, closed = join requests ignored, hidden = metadata hidden from non-members.
|
||||
export type GroupFlags = {
|
||||
isPrivate: boolean;
|
||||
isClosed: boolean;
|
||||
isRestricted: boolean;
|
||||
isHidden: boolean;
|
||||
};
|
||||
|
||||
let group = $state<GroupMetadata | null>(null);
|
||||
let loaded = $state(false);
|
||||
|
||||
|
|
@ -40,9 +52,33 @@ export async function loadGroup() {
|
|||
about: event.tags.find((t) => t[0] === "about")?.[1],
|
||||
isPrivate: event.tags.some((t) => t[0] === "private"),
|
||||
isClosed: event.tags.some((t) => t[0] === "closed"),
|
||||
isRestricted: event.tags.some((t) => t[0] === "restricted"),
|
||||
isHidden: event.tags.some((t) => t[0] === "hidden"),
|
||||
admins,
|
||||
};
|
||||
} finally {
|
||||
loaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
// A group's access flags from whichever store holds them: full mode keeps every
|
||||
// room in the groups list, simple mode has the single active group's metadata.
|
||||
export function getGroupFlags(groupId: string): GroupFlags | null {
|
||||
if (MODE === "full") {
|
||||
const g = groupsStore.list.find((x) => x.id === groupId);
|
||||
if (!g) return null;
|
||||
return {
|
||||
isPrivate: g.flags.includes("private"),
|
||||
isClosed: g.flags.includes("closed"),
|
||||
isRestricted: g.flags.includes("restricted"),
|
||||
isHidden: g.flags.includes("hidden"),
|
||||
};
|
||||
}
|
||||
if (!group) return null;
|
||||
return {
|
||||
isPrivate: group.isPrivate,
|
||||
isClosed: group.isClosed,
|
||||
isRestricted: group.isRestricted,
|
||||
isHidden: group.isHidden,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,15 +2,28 @@ import type { AbstractRelay } from "@nostr/tools/abstract-relay";
|
|||
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";
|
||||
|
||||
type Membership = "member" | "guest";
|
||||
|
||||
// Per-group membership, resolved lazily and cached reactively so compose
|
||||
// actions can be gated before the user writes anything. Keyed by pubkey so a
|
||||
// silent session restore (anon → pubkey) re-evaluates instead of reusing the
|
||||
// "guest" cached before auth.user was populated; the read stays a pure getter
|
||||
// (no state writes inside a derivation).
|
||||
let membership = $state<Record<string, Membership>>({});
|
||||
const checking = new Map<string, Promise<void>>();
|
||||
|
||||
// Membership is per-group: full mode lets a user belong to some rooms but not
|
||||
// others, so we track joined group ids rather than a single boolean.
|
||||
let joinedGroups = new Set<string>();
|
||||
let modalOpen = $state(false);
|
||||
let modalGroup = $state<string | null>(null);
|
||||
let modalError = $state<string | null>(null);
|
||||
let busy = $state(false);
|
||||
let pendingAction: (() => Promise<void>) | null = null;
|
||||
let pendingGroup: string | null = null;
|
||||
// Continuation to run once the join succeeds (open the composer, focus the
|
||||
// reply box, reload a now-visible feed, or retry the original post).
|
||||
let onJoined: (() => void | Promise<void>) | null = null;
|
||||
|
||||
const memberKey = (groupId: string) =>
|
||||
`${auth.user?.pubkey ?? "anon"}:${groupId}`;
|
||||
|
||||
export const joinState = {
|
||||
get modalOpen() {
|
||||
|
|
@ -22,23 +35,58 @@ export const joinState = {
|
|||
get busy() {
|
||||
return busy;
|
||||
},
|
||||
get codeRequired() {
|
||||
return JOINCODE_REQUIRED;
|
||||
// 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() {
|
||||
joinedGroups = new Set();
|
||||
membership = {};
|
||||
modalOpen = false;
|
||||
modalGroup = null;
|
||||
modalError = null;
|
||||
busy = false;
|
||||
pendingAction = null;
|
||||
pendingGroup = null;
|
||||
onJoined = null;
|
||||
}
|
||||
|
||||
// Checks group membership: kind:9000 (per-user put-user event, lightweight)
|
||||
// first, falling back to kind:39002 (full members list, heavier) only if 9000
|
||||
// did not match. Either signal marks the user as joined and skips the 9021.
|
||||
export function membershipOf(groupId: string): Membership | "unknown" {
|
||||
return membership[memberKey(groupId)] ?? "unknown";
|
||||
}
|
||||
|
||||
// Resolves membership for a group once and caches it. Logged-out users are
|
||||
// always guests; the relay query only runs when signed in. Returns the shared
|
||||
// in-flight promise when a check is already running, so an awaiting caller
|
||||
// (e.g. clicking "New discussion") gets the real result rather than racing past
|
||||
// an unresolved "unknown".
|
||||
export function ensureMembershipChecked(groupId: string): Promise<void> {
|
||||
if (!groupId) return Promise.resolve();
|
||||
const pubkey = auth.user?.pubkey;
|
||||
const k = memberKey(groupId);
|
||||
if (membership[k]) return Promise.resolve();
|
||||
if (!pubkey) {
|
||||
membership[k] = "guest";
|
||||
return Promise.resolve();
|
||||
}
|
||||
const existing = checking.get(k);
|
||||
if (existing) return existing;
|
||||
const p = (async () => {
|
||||
try {
|
||||
const isMember = await checkMembership(pubkey, groupId);
|
||||
membership[k] = isMember ? "member" : "guest";
|
||||
} finally {
|
||||
checking.delete(k);
|
||||
}
|
||||
})();
|
||||
checking.set(k, p);
|
||||
return p;
|
||||
}
|
||||
|
||||
// Existence check for a single matching event (resolves true on first event,
|
||||
// false on EOSE/close/timeout). Used for the kind:39002 members-list fallback.
|
||||
function queryHasMatch(
|
||||
relay: AbstractRelay,
|
||||
filter: Parameters<AbstractRelay["subscribe"]>[0][number],
|
||||
|
|
@ -69,8 +117,42 @@ function queryHasMatch(
|
|||
});
|
||||
}
|
||||
|
||||
// Membership signal for one group: kind:9000 (per-user put-user event,
|
||||
// lightweight) first, falling back to kind:39002 (full members list, heavier).
|
||||
// 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(
|
||||
relay: AbstractRelay,
|
||||
filter: Parameters<AbstractRelay["subscribe"]>[0][number],
|
||||
timeoutMs = 3000,
|
||||
): Promise<number | null> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
let latest: number | null = null;
|
||||
const finish = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
try {
|
||||
sub.close();
|
||||
} catch {}
|
||||
resolve(latest);
|
||||
};
|
||||
const sub = relay.subscribe([filter], {
|
||||
onevent(e) {
|
||||
if (latest === null || e.created_at > latest) latest = e.created_at;
|
||||
},
|
||||
oneose: finish,
|
||||
onclose: finish,
|
||||
});
|
||||
setTimeout(finish, timeoutMs);
|
||||
});
|
||||
}
|
||||
|
||||
// Membership signal for one group — i.e. "is this user allowed to write here".
|
||||
// Admins (kind:39001) can post but need not appear in the members list, so they
|
||||
// count as members. Otherwise NIP-29 records membership as moderation events:
|
||||
// kind:9000 adds a user, kind:9001 removes one, so the newest of the two for
|
||||
// this user decides current membership (a later 9001 unjoins them). When the
|
||||
// relay keeps neither, fall back to the kind:39002 members list.
|
||||
async function checkMembership(
|
||||
pubkey: string,
|
||||
groupId: string,
|
||||
|
|
@ -81,14 +163,21 @@ async function checkMembership(
|
|||
} catch {
|
||||
return false;
|
||||
}
|
||||
const has9000 = await queryHasMatch(relay, {
|
||||
kinds: [9000],
|
||||
"#h": [groupId],
|
||||
"#p": [pubkey],
|
||||
limit: 1,
|
||||
});
|
||||
if (has9000) return true;
|
||||
return await queryHasMatch(relay, {
|
||||
const [isAdmin, added, removed] = await Promise.all([
|
||||
queryHasMatch(relay, {
|
||||
kinds: [39001],
|
||||
"#d": [groupId],
|
||||
"#p": [pubkey],
|
||||
limit: 1,
|
||||
}),
|
||||
latestCreatedAt(relay, { kinds: [9000], "#h": [groupId], "#p": [pubkey] }),
|
||||
latestCreatedAt(relay, { kinds: [9001], "#h": [groupId], "#p": [pubkey] }),
|
||||
]);
|
||||
if (isAdmin) return true;
|
||||
if (added !== null || removed !== null) {
|
||||
return added !== null && added >= (removed ?? -Infinity);
|
||||
}
|
||||
return queryHasMatch(relay, {
|
||||
kinds: [39002],
|
||||
"#d": [groupId],
|
||||
"#p": [pubkey],
|
||||
|
|
@ -100,14 +189,29 @@ async function checkMembership(
|
|||
// skips the 9021. Full mode checks lazily per room when the user first acts.
|
||||
export async function initJoinForUser(pubkey: string) {
|
||||
if (MODE !== "simple") return;
|
||||
if (await checkMembership(pubkey, GROUP_ID)) joinedGroups.add(GROUP_ID);
|
||||
if (await checkMembership(pubkey, GROUP_ID)) {
|
||||
membership[memberKey(GROUP_ID)] = "member";
|
||||
}
|
||||
}
|
||||
|
||||
export function closeJoinModal() {
|
||||
if (busy) return;
|
||||
modalOpen = false;
|
||||
modalGroup = null;
|
||||
modalError = null;
|
||||
pendingAction = null;
|
||||
onJoined = null;
|
||||
}
|
||||
|
||||
// Opens the join modal for a group, with an optional continuation to run after
|
||||
// a successful join (open the composer, focus the reply box, reload the feed).
|
||||
export function openJoinModal(
|
||||
groupId: string,
|
||||
cb?: () => void | Promise<void>,
|
||||
) {
|
||||
modalGroup = groupId;
|
||||
onJoined = cb ?? null;
|
||||
modalError = null;
|
||||
modalOpen = true;
|
||||
}
|
||||
|
||||
async function publishJoinRequest(groupId: string, code?: string) {
|
||||
|
|
@ -126,15 +230,37 @@ async function publishJoinRequest(groupId: string, code?: string) {
|
|||
await Promise.race([Promise.all(publishForum(event)), timeout]);
|
||||
}
|
||||
|
||||
// Wraps an action that posts to `groupId`. If the user isn't known to be a
|
||||
// member, checks membership first, then sends kind:9021 before the action. On
|
||||
// failure, opens the join modal so the user can retry (with code if configured).
|
||||
// Returns true on success, false if the modal was opened.
|
||||
// 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;
|
||||
busy = true;
|
||||
modalError = null;
|
||||
const groupId = modalGroup;
|
||||
const cb = onJoined;
|
||||
try {
|
||||
await publishJoinRequest(groupId, code);
|
||||
membership[memberKey(groupId)] = "member";
|
||||
modalOpen = false;
|
||||
modalGroup = null;
|
||||
onJoined = null;
|
||||
if (cb) await cb();
|
||||
} catch (e) {
|
||||
modalError = e instanceof Error ? e.message : "Could not join the group";
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Wraps an action that posts to `groupId`. The relay requires membership to
|
||||
// write to any group, so a non-member is joined first. Compose entry points
|
||||
// gate membership up front, making this mostly a safety net: it joins
|
||||
// transparently if possible, else opens the modal to retry.
|
||||
export async function withJoin(
|
||||
groupId: string,
|
||||
action: () => Promise<void>,
|
||||
): Promise<boolean> {
|
||||
if (joinedGroups.has(groupId)) {
|
||||
if (membership[memberKey(groupId)] === "member") {
|
||||
await action();
|
||||
return true;
|
||||
}
|
||||
|
|
@ -142,17 +268,17 @@ export async function withJoin(
|
|||
try {
|
||||
const pubkey = auth.user?.pubkey;
|
||||
if (pubkey && (await checkMembership(pubkey, groupId))) {
|
||||
joinedGroups.add(groupId);
|
||||
membership[memberKey(groupId)] = "member";
|
||||
await action();
|
||||
return true;
|
||||
}
|
||||
await publishJoinRequest(groupId);
|
||||
await action();
|
||||
joinedGroups.add(groupId);
|
||||
membership[memberKey(groupId)] = "member";
|
||||
return true;
|
||||
} catch (e) {
|
||||
pendingAction = action;
|
||||
pendingGroup = groupId;
|
||||
modalGroup = groupId;
|
||||
onJoined = action;
|
||||
modalError = e instanceof Error ? e.message : "Could not join the group";
|
||||
modalOpen = true;
|
||||
return false;
|
||||
|
|
@ -160,21 +286,3 @@ export async function withJoin(
|
|||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function retryJoin(code?: string) {
|
||||
if (!pendingAction || !pendingGroup || busy) return;
|
||||
busy = true;
|
||||
modalError = null;
|
||||
try {
|
||||
await publishJoinRequest(pendingGroup, code);
|
||||
await pendingAction();
|
||||
joinedGroups.add(pendingGroup);
|
||||
modalOpen = false;
|
||||
pendingAction = null;
|
||||
pendingGroup = null;
|
||||
} catch (e) {
|
||||
modalError = e instanceof Error ? e.message : "Could not join the group";
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,9 @@ type ThreadDetail = {
|
|||
|
||||
let detail = $state<ThreadDetail | null>(null);
|
||||
let profiles = $state<Record<string, NostrUser>>({});
|
||||
// "notfound" means the relay returned nothing — either no such thread or it sits
|
||||
// in a private group the current (non-member) user can't read.
|
||||
let status = $state<"loading" | "ready" | "notfound">("loading");
|
||||
|
||||
export const threadDetailStore = {
|
||||
get detail() {
|
||||
|
|
@ -39,6 +42,9 @@ export const threadDetailStore = {
|
|||
get profiles() {
|
||||
return profiles;
|
||||
},
|
||||
get status() {
|
||||
return status;
|
||||
},
|
||||
};
|
||||
|
||||
async function loadProfile(pubkey: string) {
|
||||
|
|
@ -89,10 +95,12 @@ function loadMockThread(id: string) {
|
|||
export async function loadThread(id: string) {
|
||||
detail = null;
|
||||
profiles = {};
|
||||
status = "loading";
|
||||
|
||||
if (!isNostrId(id)) {
|
||||
await Promise.resolve();
|
||||
loadMockThread(id);
|
||||
status = detail ? "ready" : "notfound";
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -102,7 +110,10 @@ export async function loadThread(id: string) {
|
|||
]);
|
||||
|
||||
const event = threadEvents[0];
|
||||
if (!event) return;
|
||||
if (!event) {
|
||||
status = "notfound";
|
||||
return;
|
||||
}
|
||||
|
||||
const replies = replyEvents.sort((a, b) => a.created_at - b.created_at);
|
||||
|
||||
|
|
@ -126,6 +137,7 @@ export async function loadThread(id: string) {
|
|||
};
|
||||
|
||||
[event.pubkey, ...replies.map((r) => r.pubkey)].forEach(loadProfile);
|
||||
status = "ready";
|
||||
}
|
||||
|
||||
export function removeReply(id: string) {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
import { onMount } from "svelte";
|
||||
import { auth, restoreSession } from "$lib/auth.svelte";
|
||||
import { loadGroup } from "$lib/group.svelte";
|
||||
import { ensureMembershipChecked } from "$lib/join.svelte";
|
||||
import { loadGroups, groupsStore } from "$lib/groups.svelte";
|
||||
import { loadResources } from "$lib/resources.svelte";
|
||||
import { loadPartials } from "$lib/partials.svelte";
|
||||
|
|
@ -108,6 +109,14 @@
|
|||
if (chatEnabled && activeGroup.id) startChat(activeGroup.id);
|
||||
});
|
||||
|
||||
// Pre-warm membership for the active room so the join gates resolve before
|
||||
// the user reaches a composer (no flash, no wasted typing). Depends on
|
||||
// auth.user so a silent session restore re-runs the check.
|
||||
$effect(() => {
|
||||
auth.user;
|
||||
if (activeGroup.id) ensureMembershipChecked(activeGroup.id);
|
||||
});
|
||||
|
||||
let chatExpanded = $state(false);
|
||||
let menuOpen = $state(false);
|
||||
let mobileView = $state<"forum" | "chat">("forum");
|
||||
|
|
@ -177,7 +186,7 @@
|
|||
it first and it reappears once the top is reached. Desktop only. -->
|
||||
<div class="hidden md:block md:h-6" aria-hidden="true"></div>
|
||||
<div
|
||||
class="min-h-[calc(100dvh_-_4rem)] bg-white dark:bg-neutral-900 px-6 pt-4 pb-20 shadow-lg md:min-h-full md:rounded-t-xl md:px-10 md:pt-6 md:pt-8"
|
||||
class="min-h-[calc(100dvh_-_4rem)] bg-white px-6 pt-4 pb-20 shadow-lg md:min-h-full md:rounded-t-xl md:px-10 md:pt-6 md:pt-8 dark:bg-neutral-900"
|
||||
>
|
||||
{@render children()}
|
||||
</div>
|
||||
|
|
@ -197,7 +206,7 @@
|
|||
>
|
||||
<div class="hidden md:block md:h-6" aria-hidden="true"></div>
|
||||
<div
|
||||
class="bg-white dark:bg-neutral-900 px-6 pt-8 pb-20 shadow-lg md:min-h-full md:rounded-t-xl md:px-8 md:pt-6"
|
||||
class="bg-white px-6 pt-8 pb-20 shadow-lg md:min-h-full md:rounded-t-xl md:px-8 md:pt-6 dark:bg-neutral-900"
|
||||
>
|
||||
<LatestDiscussions />
|
||||
</div>
|
||||
|
|
@ -206,7 +215,7 @@
|
|||
</div>
|
||||
{#if showChat}
|
||||
<nav
|
||||
class="fixed inset-x-0 bottom-0 z-30 flex border-t border-neutral-200 dark:border-neutral-700 bg-neutral-100 dark:bg-neutral-800 md:hidden"
|
||||
class="fixed inset-x-0 bottom-0 z-30 flex border-t border-neutral-200 bg-neutral-100 md:hidden dark:border-neutral-700 dark:bg-neutral-800"
|
||||
aria-label="Switch view"
|
||||
>
|
||||
<button
|
||||
|
|
@ -214,7 +223,9 @@
|
|||
onclick={() => (mobileView = "forum")}
|
||||
aria-pressed={mobileView === "forum"}
|
||||
class="flex flex-1 flex-col items-center gap-0.5 py-2 text-xs font-medium
|
||||
{mobileView === 'forum' ? 'text-accent' : 'text-neutral-500 dark:text-neutral-400'}"
|
||||
{mobileView === 'forum'
|
||||
? 'text-accent'
|
||||
: 'text-neutral-500 dark:text-neutral-400'}"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
|
@ -238,7 +249,9 @@
|
|||
onclick={() => (mobileView = "chat")}
|
||||
aria-pressed={mobileView === "chat"}
|
||||
class="flex flex-1 flex-col items-center gap-0.5 py-2 text-xs font-medium
|
||||
{mobileView === 'chat' ? 'text-accent' : 'text-neutral-500 dark:text-neutral-400'}"
|
||||
{mobileView === 'chat'
|
||||
? 'text-accent'
|
||||
: 'text-neutral-500 dark:text-neutral-400'}"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
|
|
|||
|
|
@ -14,7 +14,12 @@
|
|||
import { isGroupAdmin } from "$lib/admins.svelte";
|
||||
import { requestDelete } from "$lib/moderation.svelte";
|
||||
import { showToast } from "$lib/toast.svelte";
|
||||
import { withJoin } from "$lib/join.svelte";
|
||||
import {
|
||||
withJoin,
|
||||
membershipOf,
|
||||
ensureMembershipChecked,
|
||||
openJoinModal,
|
||||
} from "$lib/join.svelte";
|
||||
import { setActiveGroup } from "$lib/active.svelte";
|
||||
import { RELAY_URL, MODE } from "$lib/config";
|
||||
import ThreadScrubber from "$lib/components/ThreadScrubber.svelte";
|
||||
|
|
@ -111,6 +116,28 @@
|
|||
const detail = $derived(threadDetailStore.detail);
|
||||
const profiles = $derived(threadDetailStore.profiles);
|
||||
|
||||
// Replying needs membership regardless of flags. Only gate a confirmed guest;
|
||||
// while membership resolves the editor stays and submit awaits the check, so a
|
||||
// member never flashes "Join to reply".
|
||||
const joinToReply = $derived(
|
||||
!!detail && !!auth.user && membershipOf(detail.groupId) === "guest",
|
||||
);
|
||||
|
||||
// The relay requires membership to reply, so resolve it up front. Depends on
|
||||
// auth.user so a silent session restore re-runs the check.
|
||||
$effect(() => {
|
||||
auth.user;
|
||||
if (detail?.groupId) ensureMembershipChecked(detail.groupId);
|
||||
});
|
||||
|
||||
function onJoinToReply() {
|
||||
if (!detail) return;
|
||||
openJoinModal(detail.groupId, async () => {
|
||||
await tick();
|
||||
editorEl?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
const canModerate = $derived(
|
||||
!!auth.user && !!detail && isGroupAdmin(auth.user.pubkey, detail.groupId),
|
||||
);
|
||||
|
|
@ -437,37 +464,52 @@
|
|||
class="mt-8 border-t border-neutral-200 pt-6 md:pl-18 dark:border-neutral-700"
|
||||
>
|
||||
{#if auth.user}
|
||||
{#if replyError}
|
||||
<div
|
||||
class="mb-4 rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700"
|
||||
>
|
||||
{replyError}
|
||||
{#if joinToReply}
|
||||
<div class="text-center">
|
||||
<p class="mb-3 text-neutral-600 dark:text-neutral-400">
|
||||
This room is restricted. Join to reply to this discussion.
|
||||
</p>
|
||||
<button
|
||||
onclick={onJoinToReply}
|
||||
class="bg-accent hover:bg-accent-hover rounded px-6 py-1.5 font-medium text-white"
|
||||
>
|
||||
Join to reply
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
{#if replyError}
|
||||
<div
|
||||
class="mb-4 rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700"
|
||||
>
|
||||
{replyError}
|
||||
</div>
|
||||
{/if}
|
||||
<MessageEditor
|
||||
bind:this={editorEl}
|
||||
bind:value={replyContent}
|
||||
disabled={replying}
|
||||
rows={8}
|
||||
minHeightClass="min-h-[12rem]"
|
||||
placeholder="Write a reply..."
|
||||
contextPubkeys={allPosts.map((p) => p.pubkey)}
|
||||
{threadEventAuthors}
|
||||
/>
|
||||
<div class="mt-4 flex justify-end">
|
||||
<button
|
||||
onclick={submitReply}
|
||||
disabled={replying || !replyContent.trim()}
|
||||
class="bg-accent hover:bg-accent-hover rounded px-6 py-1.5 font-medium text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{replying ? "Posting…" : "post reply"}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
<MessageEditor
|
||||
bind:this={editorEl}
|
||||
bind:value={replyContent}
|
||||
disabled={replying}
|
||||
rows={8}
|
||||
minHeightClass="min-h-[12rem]"
|
||||
placeholder="Write a reply..."
|
||||
contextPubkeys={allPosts.map((p) => p.pubkey)}
|
||||
{threadEventAuthors}
|
||||
/>
|
||||
<div class="mt-4 flex justify-end">
|
||||
<button
|
||||
onclick={submitReply}
|
||||
disabled={replying || !replyContent.trim()}
|
||||
class="bg-accent hover:bg-accent-hover rounded px-6 py-1.5 font-medium text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{replying ? "Posting…" : "post reply"}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<p class=" text-center text-neutral-500 dark:text-neutral-400">
|
||||
To participate and reply, please
|
||||
<button onclick={openLogin} class="text-accent hover:underline"
|
||||
>login now</button
|
||||
<button
|
||||
onclick={() => openLogin()}
|
||||
class="text-accent hover:underline">login now</button
|
||||
>
|
||||
</p>
|
||||
{/if}
|
||||
|
|
@ -481,6 +523,25 @@
|
|||
bind:visible={scrubberVisible}
|
||||
/>
|
||||
</div>
|
||||
{:else if threadDetailStore.status === "notfound"}
|
||||
<div class="mx-auto max-w-md py-16 text-center">
|
||||
<h1 class="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
This discussion is private or unavailable
|
||||
</h1>
|
||||
<p class="mt-2 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
It may belong to a private room. {auth.user
|
||||
? "You may need to be a member to read it."
|
||||
: "Log in as a member to read it."}
|
||||
</p>
|
||||
{#if !auth.user}
|
||||
<button
|
||||
onclick={() => openLogin()}
|
||||
class="bg-accent hover:bg-accent-hover mt-5 rounded px-6 py-1.5 font-medium text-white"
|
||||
>
|
||||
Log in
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="py-12 text-center text-neutral-400 dark:text-neutral-500">
|
||||
Loading…
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue