Show an access gate when the relay refuses reads

This commit is contained in:
dtonon 2026-08-14 18:10:35 +01:00
parent 6ecd618bce
commit 9cc82dc1fe
5 changed files with 437 additions and 145 deletions

View file

@ -12,9 +12,10 @@
import NewDiscussionModal from "$lib/components/NewDiscussionModal.svelte";
import DeleteModal from "$lib/components/DeleteModal.svelte";
import Toast from "$lib/components/Toast.svelte";
import RelayGate from "$lib/components/RelayGate.svelte";
import { page } from "$app/state";
import { afterNavigate } from "$app/navigation";
import { onMount } from "svelte";
import { onMount, untrack } from "svelte";
import { auth, restoreSession } from "$lib/auth.svelte";
import { loadGroup } from "$lib/group.svelte";
import { ensureMembershipChecked } from "$lib/join.svelte";
@ -25,6 +26,7 @@
import { seedProfiles } from "$lib/profiles.svelte";
import { startChat } from "$lib/chat.svelte";
import { resetForumConnection } from "$lib/relay";
import { relayAccess, probeRelayAccess } from "$lib/access.svelte";
import { activeGroup, setActiveGroup } from "$lib/active.svelte";
import {
MODE,
@ -68,21 +70,36 @@
if (nav.type !== "popstate") mainEl?.scrollTo(0, 0);
});
onMount(async () => {
// Nothing is fetched until the relay confirms it will serve this visitor:
// the probe runs once the stored signer is in place (restoreSession sets it
// synchronously, before its profile fetch), and every probe that ends open
// (re)loads the forum below.
onMount(() => {
restoreSession();
probeRelayAccess();
});
async function loadForum() {
loadResources();
loadPartials();
const tasks = [restoreSession(), loadGroup()];
const tasks = [loadGroup()];
if (mode === "full") tasks.push(loadGroups());
await Promise.all(tasks);
// Resources are filtered by the admin set; in full mode that means every
// room's admins, needed on every page for the sidebar.
if (mode === "full") loadRoomAdmins(groupsStore.list.map((g) => g.id));
seedProfiles(auth.user?.pubkey ?? null);
}
// Each completed probe that finds the relay open reloads the room views: on
// first load, after a login (private/hidden rooms appear) or logout (they
// vanish), and after joining a members-only relay.
$effect(() => {
relayAccess.probes;
if (relayAccess.state === "open") untrack(loadForum);
});
// Reload the room views when the user logs in or out: a logged-in member sees
// their private/hidden rooms, so the listing must be re-fetched over the now
// (de)authenticated connection. sessionEpoch only bumps on explicit
// Re-probe when the user logs in or out. sessionEpoch only bumps on explicit
// login/logout, never on the silent restore that onMount already covers.
let lastEpoch = -1;
$effect(() => {
@ -91,17 +108,12 @@
const first = lastEpoch === -1;
const loggedOut = auth.user === null;
lastEpoch = epoch;
if (first) return; // initial mount: onMount already loaded everything
if (first) return;
// On logout, drop the authenticated connection so the relay stops serving
// the previous user's private rooms; login reuses the open connection,
// which ensureForumReady authenticates via its stored challenge.
if (loggedOut) resetForumConnection();
loadGroup();
if (mode === "full") {
loadGroups().then(() =>
loadRoomAdmins(groupsStore.list.map((g) => g.id)),
);
}
probeRelayAccess();
});
// Full mode: the room route defines the active group. Thread pages set it
@ -167,118 +179,122 @@
<link rel="icon" href={favicon} />
</svelte:head>
<div
class="mx-auto flex max-w-[1540px] flex-col md:h-screen md:overflow-hidden {mobileView ===
'chat'
? 'h-dvh overflow-hidden'
: ''}"
>
<Navbar onMenuToggle={() => (menuOpen = true)} />
{#if relayAccess.state !== "open"}
<RelayGate />
{:else}
<div
class="relative md:flex md:flex-1 md:gap-5 md:overflow-hidden {mobileView ===
class="mx-auto flex max-w-[1540px] flex-col md:h-screen md:overflow-hidden {mobileView ===
'chat'
? 'flex flex-1 overflow-hidden'
? 'h-dvh overflow-hidden'
: ''}"
>
<LeftSidebar {mode} {activeRoom} {activeResource} {contactsActive} />
<main
bind:this={mainEl}
class="no-scrollbar md:min-h-0 md:overflow-y-auto {showDiscussions
? 'md:flex-[3]'
: 'md:flex-1'}
{mobileView === 'chat' ? 'hidden md:block' : 'block'}"
<Navbar onMenuToggle={() => (menuOpen = true)} />
<div
class="relative md:flex md:flex-1 md:gap-5 md:overflow-hidden {mobileView ===
'chat'
? 'flex flex-1 overflow-hidden'
: ''}"
>
<!-- Gray top margin lives inside the scroll area, so scrolling collapses
<LeftSidebar {mode} {activeRoom} {activeResource} {contactsActive} />
<main
bind:this={mainEl}
class="no-scrollbar md:min-h-0 md:overflow-y-auto {showDiscussions
? 'md:flex-[3]'
: 'md:flex-1'}
{mobileView === 'chat' ? 'hidden md:block' : 'block'}"
>
<!-- Gray top margin lives inside the scroll area, so scrolling collapses
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 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>
</main>
{#if showChat}
<div class="hidden w-80 shrink-0 md:block" aria-hidden="true"></div>
<ChatSidebar
expanded={chatExpanded}
onToggle={() => (chatExpanded = !chatExpanded)}
mobileActive={mobileView === "chat"}
/>
{:else if showDiscussions}
<!-- Own panel (40%) so the gray gutter matches the main↔chat gap.
Hidden on mobile — it's supplementary to the main column. -->
<div
class="no-scrollbar hidden min-w-0 md:block md:min-h-0 md:flex-[2] md:overflow-y-auto"
>
<div class="hidden md:block md:h-6" aria-hidden="true"></div>
<div
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"
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"
>
<LatestDiscussions />
{@render children()}
</div>
</div>
</main>
{#if showChat}
<div class="hidden w-80 shrink-0 md:block" aria-hidden="true"></div>
<ChatSidebar
expanded={chatExpanded}
onToggle={() => (chatExpanded = !chatExpanded)}
mobileActive={mobileView === "chat"}
/>
{:else if showDiscussions}
<!-- Own panel (40%) so the gray gutter matches the main↔chat gap.
Hidden on mobile — it's supplementary to the main column. -->
<div
class="no-scrollbar hidden min-w-0 md:block md:min-h-0 md:flex-[2] md:overflow-y-auto"
>
<div class="hidden md:block md:h-6" aria-hidden="true"></div>
<div
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>
</div>
{/if}
</div>
{#if showChat}
<nav
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
type="button"
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'}"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.8"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
/>
</svg>
Forum
</button>
<button
type="button"
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'}"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.8"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M8.625 9.75a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H8.25m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H12m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 0 1-2.555-.337A5.972 5.972 0 0 1 5.41 20.97a5.969 5.969 0 0 1-.474-.065 4.48 4.48 0 0 0 .978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25Z"
/>
</svg>
Chat
</button>
</nav>
{/if}
</div>
{#if showChat}
<nav
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
type="button"
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'}"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.8"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
/>
</svg>
Forum
</button>
<button
type="button"
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'}"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.8"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M8.625 9.75a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H8.25m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H12m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 0 1-2.555-.337A5.972 5.972 0 0 1 5.41 20.97a5.969 5.969 0 0 1-.474-.065 4.48 4.48 0 0 0 .978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25Z"
/>
</svg>
Chat
</button>
</nav>
{/if}
</div>
{/if}
<MobileMenu
open={menuOpen}