Move join before composing, and cache membership state

This commit is contained in:
dtonon 2026-06-12 18:15:37 +01:00
parent fadac80033
commit 30f52af4b1
10 changed files with 503 additions and 149 deletions

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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