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

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

View file

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