Show an access gate when the relay refuses reads
This commit is contained in:
parent
6ecd618bce
commit
9cc82dc1fe
5 changed files with 437 additions and 145 deletions
109
src/lib/access.svelte.ts
Normal file
109
src/lib/access.svelte.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import type { AbstractRelay } from "@nostr/tools/abstract-relay";
|
||||
import { auth } from "$lib/auth.svelte";
|
||||
import { ensureForumRelay, forumRelayInfo } from "$lib/relay";
|
||||
import type { ForumRelayInfo } from "$lib/relay";
|
||||
|
||||
// Whether the forum relay will serve this visitor at all. A relay can refuse
|
||||
// every read (auth-required for anonymous visitors, restricted for logged-in
|
||||
// non-members, blocked for bans), in which case the app would otherwise just
|
||||
// look empty; the layout shows a gate page instead until a probe comes back
|
||||
// open.
|
||||
export type RelayAccess =
|
||||
| "checking"
|
||||
| "open"
|
||||
| "auth-required"
|
||||
| "restricted"
|
||||
| "blocked";
|
||||
|
||||
let state = $state<RelayAccess>("checking");
|
||||
let message = $state<string | null>(null);
|
||||
let info = $state<ForumRelayInfo | null>(null);
|
||||
// Bumped when a probe completes, so listeners can react to every result even
|
||||
// when the state itself didn't change (open → open after a login).
|
||||
let probes = $state(0);
|
||||
|
||||
export const relayAccess = {
|
||||
get state() {
|
||||
return state;
|
||||
},
|
||||
get message() {
|
||||
return message;
|
||||
},
|
||||
get info() {
|
||||
return info;
|
||||
},
|
||||
get probes() {
|
||||
return probes;
|
||||
},
|
||||
};
|
||||
|
||||
// Sends a throwaway REQ and reads the CLOSED reason: nostr-tools' query helpers
|
||||
// discard it, and it is the only cross-relay signal for a read gate. No `#h`
|
||||
// in the filter, so a private group's per-group auth-required (handled by the
|
||||
// room itself) can't masquerade as a relay-wide one.
|
||||
function probeRead(relay: AbstractRelay): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const finish = (reason: string | null) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
try {
|
||||
sub.close();
|
||||
} catch {}
|
||||
resolve(reason);
|
||||
};
|
||||
const sub = relay.subscribe([{ kinds: [39000], limit: 1 }], {
|
||||
onevent() {},
|
||||
oneose() {
|
||||
finish(null);
|
||||
},
|
||||
onclose(reason) {
|
||||
finish(reason);
|
||||
},
|
||||
});
|
||||
setTimeout(() => finish(null), 4000);
|
||||
});
|
||||
}
|
||||
|
||||
function classify(reason: string | null): RelayAccess {
|
||||
const r = (reason ?? "").toLowerCase();
|
||||
if (r.startsWith("auth-required:")) return "auth-required";
|
||||
if (r.startsWith("restricted:")) return "restricted";
|
||||
if (r.startsWith("blocked:")) return "blocked";
|
||||
return "open";
|
||||
}
|
||||
|
||||
let inFlight: Promise<RelayAccess> | null = null;
|
||||
|
||||
// Re-evaluates access for the current signer. Concurrent calls share one probe.
|
||||
// An anonymous visitor on a relay whose NIP-11 declares auth_required is gated
|
||||
// without waiting for the relay to refuse a request.
|
||||
export function probeRelayAccess(): Promise<RelayAccess> {
|
||||
if (inFlight) return inFlight;
|
||||
inFlight = (async () => {
|
||||
try {
|
||||
const relayInfo = await forumRelayInfo();
|
||||
info = relayInfo;
|
||||
let next: RelayAccess;
|
||||
let reason: string | null = null;
|
||||
if (!auth.signer && relayInfo.authRequired) {
|
||||
next = "auth-required";
|
||||
} else {
|
||||
try {
|
||||
reason = await probeRead(await ensureForumRelay());
|
||||
} catch {
|
||||
reason = null; // connection failure: the pages surface their own errors
|
||||
}
|
||||
next = classify(reason);
|
||||
}
|
||||
state = next;
|
||||
message =
|
||||
next === "open" ? null : (reason?.replace(/^[a-z-]+:\s*/i, "") ?? null);
|
||||
probes++;
|
||||
return next;
|
||||
} finally {
|
||||
inFlight = null;
|
||||
}
|
||||
})();
|
||||
return inFlight;
|
||||
}
|
||||
106
src/lib/components/RelayGate.svelte
Normal file
106
src/lib/components/RelayGate.svelte
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
<script lang="ts">
|
||||
import ThemeToggle from "$lib/components/ThemeToggle.svelte";
|
||||
import { relayAccess, probeRelayAccess } from "$lib/access.svelte";
|
||||
import { auth, openLogin } from "$lib/auth.svelte";
|
||||
import { joinRelay, joinState } from "$lib/join.svelte";
|
||||
import { TITLE } from "$lib/config";
|
||||
|
||||
// The relay's NIP-11 name is readable even when nothing else is, so the
|
||||
// gate can still say which forum this is.
|
||||
const title = $derived(TITLE || relayAccess.info?.name || "Forum");
|
||||
const description = $derived(relayAccess.info?.description ?? "");
|
||||
const state = $derived(relayAccess.state);
|
||||
// A logged-in user still refused with auth-required means the handshake
|
||||
// failed rather than that they must log in; offer a retry instead.
|
||||
const authFailed = $derived(state === "auth-required" && !!auth.signer);
|
||||
|
||||
function onLogin() {
|
||||
openLogin(() => {
|
||||
probeRelayAccess();
|
||||
});
|
||||
}
|
||||
|
||||
function onJoin() {
|
||||
joinRelay(() => {
|
||||
probeRelayAccess();
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{title}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex min-h-dvh flex-col">
|
||||
<div class="flex justify-end p-4">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<main
|
||||
class="flex flex-1 flex-col items-center justify-center px-6 pb-24 text-center"
|
||||
>
|
||||
{#if relayAccess.info?.icon}
|
||||
<img
|
||||
src={relayAccess.info.icon}
|
||||
alt=""
|
||||
class="mb-6 h-20 w-20 rounded-full object-cover"
|
||||
/>
|
||||
{/if}
|
||||
<h1 class="text-accent text-3xl font-semibold">{title}</h1>
|
||||
{#if description}
|
||||
<p class="mt-2 max-w-md text-neutral-600 dark:text-neutral-400">
|
||||
{description}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="mt-8 max-w-md" role="status" aria-live="polite">
|
||||
{#if state === "checking"}
|
||||
<p class="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
Checking access…
|
||||
</p>
|
||||
{:else if authFailed}
|
||||
<p class="text-neutral-700 dark:text-neutral-300">
|
||||
The relay did not accept your login.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => probeRelayAccess()}
|
||||
class="bg-accent hover:bg-accent-hover mt-5 rounded px-6 py-2 font-medium text-white"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
{:else if state === "auth-required"}
|
||||
<p class="text-neutral-700 dark:text-neutral-300">
|
||||
You need to log in to access this forum.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onLogin}
|
||||
class="bg-accent hover:bg-accent-hover mt-5 rounded px-6 py-2 font-medium text-white"
|
||||
>
|
||||
Log in
|
||||
</button>
|
||||
{:else if state === "restricted"}
|
||||
<p class="text-neutral-700 dark:text-neutral-300">
|
||||
This forum is for members only.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onJoin}
|
||||
disabled={joinState.busy}
|
||||
class="bg-accent hover:bg-accent-hover mt-5 rounded px-6 py-2 font-medium text-white disabled:opacity-50"
|
||||
>
|
||||
{joinState.busy ? "Joining…" : "Join this forum"}
|
||||
</button>
|
||||
{:else if state === "blocked"}
|
||||
<p class="text-neutral-700 dark:text-neutral-300">
|
||||
You can't access this forum.
|
||||
</p>
|
||||
{#if relayAccess.message}
|
||||
<p class="mt-2 text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{relayAccess.message}
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
|
@ -319,34 +319,52 @@ const fail = (
|
|||
// 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.
|
||||
// Relay-level join (NIP-43). `refused` is set when the relay declined for an
|
||||
// unclear reason: a group refusal right after is then more likely the relay
|
||||
// gate than the group's.
|
||||
async function ensureRelayAccess(
|
||||
pubkey: string,
|
||||
codes: Codes,
|
||||
): Promise<{ outcome: Outcome; refused: boolean }> {
|
||||
await ensureRelayMembershipChecked(pubkey);
|
||||
if (relayMembership[pubkey] !== "guest") {
|
||||
return { outcome: { ok: true }, refused: false };
|
||||
}
|
||||
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 {
|
||||
outcome: fail(
|
||||
"relay-code",
|
||||
codes.relay ? stripPrefix(msg) : null,
|
||||
true,
|
||||
),
|
||||
refused: true,
|
||||
};
|
||||
} else if (prefixed(msg, "blocked:")) {
|
||||
return { outcome: fail("error", stripPrefix(msg)), refused: true };
|
||||
} else {
|
||||
return { outcome: { ok: true }, refused: true };
|
||||
}
|
||||
}
|
||||
return { outcome: { ok: true }, refused: false };
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
const relay = await ensureRelayAccess(pubkey, codes);
|
||||
if (!relay.outcome.ok) return relay.outcome;
|
||||
const relayRefused = relay.refused;
|
||||
|
||||
await ensureMembershipChecked(groupId);
|
||||
const k = memberKey(groupId);
|
||||
|
|
@ -383,7 +401,7 @@ async function ensureAccess(groupId: string, codes: Codes): Promise<Outcome> {
|
|||
}
|
||||
|
||||
function openModal(
|
||||
groupId: string,
|
||||
groupId: string | null,
|
||||
refusal: Refusal,
|
||||
cb: (() => void | Promise<void>) | null,
|
||||
) {
|
||||
|
|
@ -449,7 +467,7 @@ export function closeJoinModal() {
|
|||
// Re-runs the join with the code entered for the current step. Errors keep the
|
||||
// modal open for a retry; a further gate switches the modal to that step.
|
||||
export async function submitJoinCode(code?: string) {
|
||||
if (!modalGroup || busy) return;
|
||||
if (!modalOpen || busy) return;
|
||||
busy = true;
|
||||
modalError = null;
|
||||
const groupId = modalGroup;
|
||||
|
|
@ -457,7 +475,12 @@ export async function submitJoinCode(code?: string) {
|
|||
const codes: Codes =
|
||||
modalStep === "relay-code" ? { relay: code } : { group: code };
|
||||
try {
|
||||
const outcome = await ensureAccess(groupId, codes);
|
||||
const pubkey = auth.user?.pubkey;
|
||||
const outcome = groupId
|
||||
? await ensureAccess(groupId, codes)
|
||||
: pubkey
|
||||
? (await ensureRelayAccess(pubkey, codes)).outcome
|
||||
: fail("error", "Not logged in");
|
||||
if (!outcome.ok) {
|
||||
openModal(groupId, outcome, cb);
|
||||
return;
|
||||
|
|
@ -472,3 +495,21 @@ export async function submitJoinCode(code?: string) {
|
|||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Relay-only join, for a relay that refuses reads from non-members: no group
|
||||
// is involved, so the modal (if any) targets the relay alone.
|
||||
export async function joinRelay(cb: () => void | Promise<void>): Promise<void> {
|
||||
const pubkey = auth.user?.pubkey;
|
||||
if (!pubkey || busy) return;
|
||||
busy = true;
|
||||
try {
|
||||
const { outcome } = await ensureRelayAccess(pubkey, {});
|
||||
if (!outcome.ok) {
|
||||
openModal(null, outcome, cb);
|
||||
return;
|
||||
}
|
||||
await cb();
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -108,11 +108,20 @@ export function resetForumConnection(): void {
|
|||
}
|
||||
|
||||
// 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;
|
||||
// discovery (clients must only send relay join requests to relays advertising
|
||||
// it; `self` signs the membership events we read back) and for the access gate,
|
||||
// which can show the relay's name before anything else is readable.
|
||||
export type ForumRelayInfo = {
|
||||
self: string | null;
|
||||
nips: string[];
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
authRequired: boolean;
|
||||
};
|
||||
let relayInfo: Promise<ForumRelayInfo> | null = null;
|
||||
|
||||
export function forumRelayInfo() {
|
||||
export function forumRelayInfo(): Promise<ForumRelayInfo> {
|
||||
if (!relayInfo) {
|
||||
relayInfo = import("@nostr/tools/nip11")
|
||||
.then(({ fetchRelayInformation }) => fetchRelayInformation(RELAY_URL))
|
||||
|
|
@ -123,9 +132,20 @@ export function forumRelayInfo() {
|
|||
self: typeof self === "string" ? self : null,
|
||||
// Relays mix numbers and strings in this list
|
||||
nips: (info.supported_nips ?? []).map(String),
|
||||
name: info.name ?? "",
|
||||
description: info.description ?? "",
|
||||
icon: info.icon ?? "",
|
||||
authRequired: info.limitation?.auth_required === true,
|
||||
};
|
||||
})
|
||||
.catch(() => ({ self: null, nips: [] }));
|
||||
.catch(() => ({
|
||||
self: null,
|
||||
nips: [],
|
||||
name: "",
|
||||
description: "",
|
||||
icon: "",
|
||||
authRequired: false,
|
||||
}));
|
||||
}
|
||||
return relayInfo;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue