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 user = $state<NostrUser | null>(null);
|
||||||
let signer = $state<Signer | null>(null);
|
let signer = $state<Signer | null>(null);
|
||||||
let loginModalOpen = $state(false);
|
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
|
// 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
|
// can re-fetch identity-scoped data — e.g. reload the room list once the relay
|
||||||
// will serve the user's private/hidden groups.
|
// will serve the user's private/hidden groups.
|
||||||
|
|
@ -43,12 +46,23 @@ const PUBKEY_KEY = "nostr_pubkey";
|
||||||
const METHOD_KEY = "nostr_login_method";
|
const METHOD_KEY = "nostr_login_method";
|
||||||
const NSEC_KEY = "nostr_nsec";
|
const NSEC_KEY = "nostr_nsec";
|
||||||
|
|
||||||
export function openLogin() {
|
export function openLogin(after?: () => void) {
|
||||||
|
afterLogin = after ?? null;
|
||||||
loginModalOpen = true;
|
loginModalOpen = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function closeLogin() {
|
export function closeLogin() {
|
||||||
loginModalOpen = false;
|
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 {
|
function makeNsecSigner(secretKey: Uint8Array): Signer {
|
||||||
|
|
@ -97,6 +111,7 @@ export async function loginWithExtension() {
|
||||||
localStorage.removeItem(NSEC_KEY);
|
localStorage.removeItem(NSEC_KEY);
|
||||||
await setUser(pubkey);
|
await setUser(pubkey);
|
||||||
sessionEpoch++;
|
sessionEpoch++;
|
||||||
|
runAfterLogin();
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseSecretKey(input: string): Uint8Array {
|
function parseSecretKey(input: string): Uint8Array {
|
||||||
|
|
@ -128,6 +143,7 @@ export async function loginWithNsec(input: string) {
|
||||||
localStorage.setItem(NSEC_KEY, nsec);
|
localStorage.setItem(NSEC_KEY, nsec);
|
||||||
await setUser(pubkey);
|
await setUser(pubkey);
|
||||||
sessionEpoch++;
|
sessionEpoch++;
|
||||||
|
runAfterLogin();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function logout() {
|
export function logout() {
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,12 @@
|
||||||
import { auth, openLogin } from "$lib/auth.svelte";
|
import { auth, openLogin } from "$lib/auth.svelte";
|
||||||
import { isGroupAdmin } from "$lib/admins.svelte";
|
import { isGroupAdmin } from "$lib/admins.svelte";
|
||||||
import { requestDelete } from "$lib/moderation.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 { activeGroup } from "$lib/active.svelte";
|
||||||
import type { NostrUser } from "@nostr/gadgets/metadata";
|
import type { NostrUser } from "@nostr/gadgets/metadata";
|
||||||
import MentionAutocomplete from "$lib/components/MentionAutocomplete.svelte";
|
import MentionAutocomplete from "$lib/components/MentionAutocomplete.svelte";
|
||||||
|
|
@ -46,6 +51,24 @@
|
||||||
!!auth.user && isGroupAdmin(auth.user.pubkey, activeGroup.id),
|
!!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) {
|
function requestDeleteMessage(msg: ChatMessageData) {
|
||||||
openMenuId = null;
|
openMenuId = null;
|
||||||
requestDelete(
|
requestDelete(
|
||||||
|
|
@ -431,6 +454,14 @@
|
||||||
{sendError}
|
{sendError}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#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
|
<MentionAutocomplete
|
||||||
bind:this={inputEl}
|
bind:this={inputEl}
|
||||||
bind:value={inputValue}
|
bind:value={inputValue}
|
||||||
|
|
@ -441,5 +472,6 @@
|
||||||
{contextPubkeys}
|
{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"
|
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>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,12 @@
|
||||||
import type { NostrUser } from "@nostr/gadgets/metadata";
|
import type { NostrUser } from "@nostr/gadgets/metadata";
|
||||||
import { auth, openLogin } from "$lib/auth.svelte";
|
import { auth, openLogin } from "$lib/auth.svelte";
|
||||||
import { openDraft } from "$lib/draft.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 { sortPref } from "$lib/sort.svelte";
|
||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
|
|
||||||
|
|
@ -38,17 +44,47 @@
|
||||||
if (urlSort) sortPref.value = urlSort;
|
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.
|
// Re-runs on mount and whenever the group or effective sort changes.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
loadThreads(groupId, sort);
|
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) {
|
if (!auth.user) {
|
||||||
openLogin();
|
openLogin();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
openDraft();
|
openJoinModal(groupId, () => loadThreads(groupId, sort));
|
||||||
}
|
}
|
||||||
|
|
||||||
function relativeTime(ts: number): string {
|
function relativeTime(ts: number): string {
|
||||||
|
|
@ -98,17 +134,43 @@
|
||||||
<div class="mx-auto max-w-6xl">
|
<div class="mx-auto max-w-6xl">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2 py-2">
|
<div class="flex flex-wrap items-center justify-between gap-2 py-2">
|
||||||
<h1 class="text-accent text-[1.65rem] leading-7">{title}</h1>
|
<h1 class="text-accent text-[1.65rem] leading-7">{title}</h1>
|
||||||
|
{#if !showPrivateGate && !checkingAccess}
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onclick={onNewTopic}
|
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"
|
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
|
{joinToPost ? "Join to post" : "New discussion"}
|
||||||
</button>
|
</button>
|
||||||
<SortToggle {sort} />
|
<SortToggle {sort} />
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if checkingAccess}
|
||||||
|
<p class="py-12 text-center text-sm text-neutral-400 dark:text-neutral-500">
|
||||||
|
Checking access…
|
||||||
|
</p>
|
||||||
|
{: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"
|
||||||
|
>
|
||||||
|
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}
|
||||||
<div>
|
<div>
|
||||||
{#each rows as thread}
|
{#each rows as thread}
|
||||||
<ThreadItem {thread} />
|
<ThreadItem {thread} />
|
||||||
|
|
@ -116,7 +178,9 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if threadStore.loading && rows.length === 0}
|
{#if threadStore.loading && rows.length === 0}
|
||||||
<p class="py-6 text-center text-sm text-neutral-400 dark:text-neutral-500">
|
<p
|
||||||
|
class="py-6 text-center text-sm text-neutral-400 dark:text-neutral-500"
|
||||||
|
>
|
||||||
Loading discussions…
|
Loading discussions…
|
||||||
</p>
|
</p>
|
||||||
{:else if !threadStore.exhausted}
|
{:else if !threadStore.exhausted}
|
||||||
|
|
@ -125,12 +189,17 @@
|
||||||
onclick={() => loadMore(groupId)}
|
onclick={() => loadMore(groupId)}
|
||||||
disabled={threadStore.loadingMore}
|
disabled={threadStore.loadingMore}
|
||||||
aria-busy={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"
|
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"}
|
{threadStore.loadingMore ? "Loading…" : "Show more"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{:else if rows.length > 0}
|
{:else if rows.length > 0}
|
||||||
<p class="py-6 text-center text-sm text-neutral-400 dark:text-neutral-500">No more discussions</p>
|
<p
|
||||||
|
class="py-6 text-center text-sm text-neutral-400 dark:text-neutral-500"
|
||||||
|
>
|
||||||
|
No more discussions
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,19 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { joinState, closeJoinModal, retryJoin } from "$lib/join.svelte";
|
import { joinState, closeJoinModal, submitJoin } from "$lib/join.svelte";
|
||||||
import { tick } from "svelte";
|
import { tick } from "svelte";
|
||||||
|
|
||||||
let code = $state("");
|
let code = $state("");
|
||||||
let codeInput = $state<HTMLInputElement | null>(null);
|
let codeInput = $state<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (joinState.modalOpen && joinState.codeRequired) {
|
if (joinState.modalOpen && joinState.needsCode) {
|
||||||
tick().then(() => codeInput?.focus());
|
tick().then(() => codeInput?.focus());
|
||||||
}
|
}
|
||||||
if (!joinState.modalOpen) code = "";
|
if (!joinState.modalOpen) code = "";
|
||||||
});
|
});
|
||||||
|
|
||||||
async function onRetry() {
|
async function onSubmit() {
|
||||||
await retryJoin(code.trim() || undefined);
|
await submitJoin(code.trim() || undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onClose() {
|
function onClose() {
|
||||||
|
|
@ -37,7 +37,7 @@
|
||||||
onclick={onClose}
|
onclick={onClose}
|
||||||
></button>
|
></button>
|
||||||
<div
|
<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"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="join-title"
|
aria-labelledby="join-title"
|
||||||
|
|
@ -47,21 +47,24 @@
|
||||||
onclick={onClose}
|
onclick={onClose}
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
disabled={joinState.busy}
|
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>
|
</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
|
Join this group
|
||||||
</h2>
|
</h2>
|
||||||
<p class="mb-4 text-sm text-neutral-600 dark:text-neutral-400">
|
<p class="mb-4 text-sm text-neutral-600 dark:text-neutral-400">
|
||||||
{#if joinState.codeRequired}
|
{#if joinState.needsCode}
|
||||||
This community requires an invite code. Enter your code below to
|
Join this group to participate. If you have an invite code, enter it
|
||||||
request access. If you don't have one, contact the admin.
|
below — otherwise just send the request.
|
||||||
{:else}
|
{:else}
|
||||||
We could not add you to the group automatically. The relay may be
|
Join this group to participate. Your request is sent to the relay and,
|
||||||
processing your request, or the admin needs to approve it manually.
|
where needed, approved by an admin.
|
||||||
{/if}
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
@ -74,20 +77,20 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if joinState.codeRequired}
|
{#if joinState.needsCode}
|
||||||
<label for="join-code-input" class="sr-only">Invite code</label>
|
<label for="join-code-input" class="sr-only">Invite code</label>
|
||||||
<input
|
<input
|
||||||
id="join-code-input"
|
id="join-code-input"
|
||||||
bind:this={codeInput}
|
bind:this={codeInput}
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Invite code"
|
placeholder="Invite code (optional)"
|
||||||
bind:value={code}
|
bind:value={code}
|
||||||
disabled={joinState.busy}
|
disabled={joinState.busy}
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
autocapitalize="off"
|
autocapitalize="off"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
onkeydown={(e) => e.key === "Enter" && onRetry()}
|
onkeydown={(e) => e.key === "Enter" && onSubmit()}
|
||||||
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"
|
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}
|
{/if}
|
||||||
|
|
||||||
|
|
@ -96,17 +99,21 @@
|
||||||
type="button"
|
type="button"
|
||||||
onclick={onClose}
|
onclick={onClose}
|
||||||
disabled={joinState.busy}
|
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
|
Close
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={onRetry}
|
onclick={onSubmit}
|
||||||
disabled={joinState.busy || (joinState.codeRequired && !code.trim())}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -170,7 +170,7 @@
|
||||||
{:else}
|
{:else}
|
||||||
<div class="mt-3 flex items-center gap-1">
|
<div class="mt-3 flex items-center gap-1">
|
||||||
<button
|
<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"
|
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
|
Login
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { GROUP_ID } from "$lib/config";
|
import { GROUP_ID, MODE } from "$lib/config";
|
||||||
import { queryForum } from "$lib/relay";
|
import { queryForum } from "$lib/relay";
|
||||||
|
import { groupsStore } from "$lib/groups.svelte";
|
||||||
|
|
||||||
export type GroupMetadata = {
|
export type GroupMetadata = {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -7,9 +8,20 @@ export type GroupMetadata = {
|
||||||
about?: string;
|
about?: string;
|
||||||
isPrivate: boolean;
|
isPrivate: boolean;
|
||||||
isClosed: boolean;
|
isClosed: boolean;
|
||||||
|
isRestricted: boolean;
|
||||||
|
isHidden: boolean;
|
||||||
admins: string[];
|
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 group = $state<GroupMetadata | null>(null);
|
||||||
let loaded = $state(false);
|
let loaded = $state(false);
|
||||||
|
|
||||||
|
|
@ -40,9 +52,33 @@ export async function loadGroup() {
|
||||||
about: event.tags.find((t) => t[0] === "about")?.[1],
|
about: event.tags.find((t) => t[0] === "about")?.[1],
|
||||||
isPrivate: event.tags.some((t) => t[0] === "private"),
|
isPrivate: event.tags.some((t) => t[0] === "private"),
|
||||||
isClosed: event.tags.some((t) => t[0] === "closed"),
|
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,
|
admins,
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
loaded = true;
|
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 { auth } from "$lib/auth.svelte";
|
||||||
import { GROUP_ID, MODE, JOINCODE_REQUIRED } from "$lib/config";
|
import { GROUP_ID, MODE, JOINCODE_REQUIRED } from "$lib/config";
|
||||||
import { ensureForumRelay, publishForum } from "$lib/relay";
|
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 modalOpen = $state(false);
|
||||||
|
let modalGroup = $state<string | null>(null);
|
||||||
let modalError = $state<string | null>(null);
|
let modalError = $state<string | null>(null);
|
||||||
let busy = $state(false);
|
let busy = $state(false);
|
||||||
let pendingAction: (() => Promise<void>) | null = null;
|
// Continuation to run once the join succeeds (open the composer, focus the
|
||||||
let pendingGroup: string | null = null;
|
// 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 = {
|
export const joinState = {
|
||||||
get modalOpen() {
|
get modalOpen() {
|
||||||
|
|
@ -22,23 +35,58 @@ export const joinState = {
|
||||||
get busy() {
|
get busy() {
|
||||||
return busy;
|
return busy;
|
||||||
},
|
},
|
||||||
get codeRequired() {
|
// Closed groups (and a globally configured invite gate) can't be self-joined,
|
||||||
return JOINCODE_REQUIRED;
|
// 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() {
|
export function resetJoinState() {
|
||||||
joinedGroups = new Set();
|
membership = {};
|
||||||
modalOpen = false;
|
modalOpen = false;
|
||||||
|
modalGroup = null;
|
||||||
modalError = null;
|
modalError = null;
|
||||||
busy = false;
|
busy = false;
|
||||||
pendingAction = null;
|
onJoined = null;
|
||||||
pendingGroup = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Checks group membership: kind:9000 (per-user put-user event, lightweight)
|
export function membershipOf(groupId: string): Membership | "unknown" {
|
||||||
// first, falling back to kind:39002 (full members list, heavier) only if 9000
|
return membership[memberKey(groupId)] ?? "unknown";
|
||||||
// did not match. Either signal marks the user as joined and skips the 9021.
|
}
|
||||||
|
|
||||||
|
// 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(
|
function queryHasMatch(
|
||||||
relay: AbstractRelay,
|
relay: AbstractRelay,
|
||||||
filter: Parameters<AbstractRelay["subscribe"]>[0][number],
|
filter: Parameters<AbstractRelay["subscribe"]>[0][number],
|
||||||
|
|
@ -69,8 +117,42 @@ function queryHasMatch(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Membership signal for one group: kind:9000 (per-user put-user event,
|
// Newest `created_at` among events matching the filter, or null if none. We
|
||||||
// lightweight) first, falling back to kind:39002 (full members list, heavier).
|
// 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(
|
async function checkMembership(
|
||||||
pubkey: string,
|
pubkey: string,
|
||||||
groupId: string,
|
groupId: string,
|
||||||
|
|
@ -81,14 +163,21 @@ async function checkMembership(
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const has9000 = await queryHasMatch(relay, {
|
const [isAdmin, added, removed] = await Promise.all([
|
||||||
kinds: [9000],
|
queryHasMatch(relay, {
|
||||||
"#h": [groupId],
|
kinds: [39001],
|
||||||
|
"#d": [groupId],
|
||||||
"#p": [pubkey],
|
"#p": [pubkey],
|
||||||
limit: 1,
|
limit: 1,
|
||||||
});
|
}),
|
||||||
if (has9000) return true;
|
latestCreatedAt(relay, { kinds: [9000], "#h": [groupId], "#p": [pubkey] }),
|
||||||
return await queryHasMatch(relay, {
|
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],
|
kinds: [39002],
|
||||||
"#d": [groupId],
|
"#d": [groupId],
|
||||||
"#p": [pubkey],
|
"#p": [pubkey],
|
||||||
|
|
@ -100,14 +189,29 @@ async function checkMembership(
|
||||||
// skips the 9021. Full mode checks lazily per room when the user first acts.
|
// skips the 9021. Full mode checks lazily per room when the user first acts.
|
||||||
export async function initJoinForUser(pubkey: string) {
|
export async function initJoinForUser(pubkey: string) {
|
||||||
if (MODE !== "simple") return;
|
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() {
|
export function closeJoinModal() {
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
modalOpen = false;
|
modalOpen = false;
|
||||||
|
modalGroup = null;
|
||||||
modalError = 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) {
|
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]);
|
await Promise.race([Promise.all(publishForum(event)), timeout]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wraps an action that posts to `groupId`. If the user isn't known to be a
|
// Sends the kind:9021 for the modal's target group, marks the user joined, and
|
||||||
// member, checks membership first, then sends kind:9021 before the action. On
|
// runs the pending continuation. Errors keep the modal open for a retry.
|
||||||
// failure, opens the join modal so the user can retry (with code if configured).
|
export async function submitJoin(code?: string) {
|
||||||
// Returns true on success, false if the modal was opened.
|
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(
|
export async function withJoin(
|
||||||
groupId: string,
|
groupId: string,
|
||||||
action: () => Promise<void>,
|
action: () => Promise<void>,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
if (joinedGroups.has(groupId)) {
|
if (membership[memberKey(groupId)] === "member") {
|
||||||
await action();
|
await action();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -142,17 +268,17 @@ export async function withJoin(
|
||||||
try {
|
try {
|
||||||
const pubkey = auth.user?.pubkey;
|
const pubkey = auth.user?.pubkey;
|
||||||
if (pubkey && (await checkMembership(pubkey, groupId))) {
|
if (pubkey && (await checkMembership(pubkey, groupId))) {
|
||||||
joinedGroups.add(groupId);
|
membership[memberKey(groupId)] = "member";
|
||||||
await action();
|
await action();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
await publishJoinRequest(groupId);
|
await publishJoinRequest(groupId);
|
||||||
await action();
|
await action();
|
||||||
joinedGroups.add(groupId);
|
membership[memberKey(groupId)] = "member";
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
pendingAction = action;
|
modalGroup = groupId;
|
||||||
pendingGroup = groupId;
|
onJoined = action;
|
||||||
modalError = e instanceof Error ? e.message : "Could not join the group";
|
modalError = e instanceof Error ? e.message : "Could not join the group";
|
||||||
modalOpen = true;
|
modalOpen = true;
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -160,21 +286,3 @@ export async function withJoin(
|
||||||
busy = false;
|
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 detail = $state<ThreadDetail | null>(null);
|
||||||
let profiles = $state<Record<string, NostrUser>>({});
|
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 = {
|
export const threadDetailStore = {
|
||||||
get detail() {
|
get detail() {
|
||||||
|
|
@ -39,6 +42,9 @@ export const threadDetailStore = {
|
||||||
get profiles() {
|
get profiles() {
|
||||||
return profiles;
|
return profiles;
|
||||||
},
|
},
|
||||||
|
get status() {
|
||||||
|
return status;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
async function loadProfile(pubkey: string) {
|
async function loadProfile(pubkey: string) {
|
||||||
|
|
@ -89,10 +95,12 @@ function loadMockThread(id: string) {
|
||||||
export async function loadThread(id: string) {
|
export async function loadThread(id: string) {
|
||||||
detail = null;
|
detail = null;
|
||||||
profiles = {};
|
profiles = {};
|
||||||
|
status = "loading";
|
||||||
|
|
||||||
if (!isNostrId(id)) {
|
if (!isNostrId(id)) {
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
loadMockThread(id);
|
loadMockThread(id);
|
||||||
|
status = detail ? "ready" : "notfound";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -102,7 +110,10 @@ export async function loadThread(id: string) {
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const event = threadEvents[0];
|
const event = threadEvents[0];
|
||||||
if (!event) return;
|
if (!event) {
|
||||||
|
status = "notfound";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const replies = replyEvents.sort((a, b) => a.created_at - b.created_at);
|
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);
|
[event.pubkey, ...replies.map((r) => r.pubkey)].forEach(loadProfile);
|
||||||
|
status = "ready";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function removeReply(id: string) {
|
export function removeReply(id: string) {
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { auth, restoreSession } from "$lib/auth.svelte";
|
import { auth, restoreSession } from "$lib/auth.svelte";
|
||||||
import { loadGroup } from "$lib/group.svelte";
|
import { loadGroup } from "$lib/group.svelte";
|
||||||
|
import { ensureMembershipChecked } from "$lib/join.svelte";
|
||||||
import { loadGroups, groupsStore } from "$lib/groups.svelte";
|
import { loadGroups, groupsStore } from "$lib/groups.svelte";
|
||||||
import { loadResources } from "$lib/resources.svelte";
|
import { loadResources } from "$lib/resources.svelte";
|
||||||
import { loadPartials } from "$lib/partials.svelte";
|
import { loadPartials } from "$lib/partials.svelte";
|
||||||
|
|
@ -108,6 +109,14 @@
|
||||||
if (chatEnabled && activeGroup.id) startChat(activeGroup.id);
|
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 chatExpanded = $state(false);
|
||||||
let menuOpen = $state(false);
|
let menuOpen = $state(false);
|
||||||
let mobileView = $state<"forum" | "chat">("forum");
|
let mobileView = $state<"forum" | "chat">("forum");
|
||||||
|
|
@ -177,7 +186,7 @@
|
||||||
it first and it reappears once the top is reached. Desktop only. -->
|
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="hidden md:block md:h-6" aria-hidden="true"></div>
|
||||||
<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()}
|
{@render children()}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -197,7 +206,7 @@
|
||||||
>
|
>
|
||||||
<div class="hidden md:block md:h-6" aria-hidden="true"></div>
|
<div class="hidden md:block md:h-6" aria-hidden="true"></div>
|
||||||
<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 />
|
<LatestDiscussions />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -206,7 +215,7 @@
|
||||||
</div>
|
</div>
|
||||||
{#if showChat}
|
{#if showChat}
|
||||||
<nav
|
<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"
|
aria-label="Switch view"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
|
|
@ -214,7 +223,9 @@
|
||||||
onclick={() => (mobileView = "forum")}
|
onclick={() => (mobileView = "forum")}
|
||||||
aria-pressed={mobileView === "forum"}
|
aria-pressed={mobileView === "forum"}
|
||||||
class="flex flex-1 flex-col items-center gap-0.5 py-2 text-xs font-medium
|
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
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
|
@ -238,7 +249,9 @@
|
||||||
onclick={() => (mobileView = "chat")}
|
onclick={() => (mobileView = "chat")}
|
||||||
aria-pressed={mobileView === "chat"}
|
aria-pressed={mobileView === "chat"}
|
||||||
class="flex flex-1 flex-col items-center gap-0.5 py-2 text-xs font-medium
|
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
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,12 @@
|
||||||
import { isGroupAdmin } from "$lib/admins.svelte";
|
import { isGroupAdmin } from "$lib/admins.svelte";
|
||||||
import { requestDelete } from "$lib/moderation.svelte";
|
import { requestDelete } from "$lib/moderation.svelte";
|
||||||
import { showToast } from "$lib/toast.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 { setActiveGroup } from "$lib/active.svelte";
|
||||||
import { RELAY_URL, MODE } from "$lib/config";
|
import { RELAY_URL, MODE } from "$lib/config";
|
||||||
import ThreadScrubber from "$lib/components/ThreadScrubber.svelte";
|
import ThreadScrubber from "$lib/components/ThreadScrubber.svelte";
|
||||||
|
|
@ -111,6 +116,28 @@
|
||||||
const detail = $derived(threadDetailStore.detail);
|
const detail = $derived(threadDetailStore.detail);
|
||||||
const profiles = $derived(threadDetailStore.profiles);
|
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(
|
const canModerate = $derived(
|
||||||
!!auth.user && !!detail && isGroupAdmin(auth.user.pubkey, detail.groupId),
|
!!auth.user && !!detail && isGroupAdmin(auth.user.pubkey, detail.groupId),
|
||||||
);
|
);
|
||||||
|
|
@ -437,6 +464,19 @@
|
||||||
class="mt-8 border-t border-neutral-200 pt-6 md:pl-18 dark:border-neutral-700"
|
class="mt-8 border-t border-neutral-200 pt-6 md:pl-18 dark:border-neutral-700"
|
||||||
>
|
>
|
||||||
{#if auth.user}
|
{#if auth.user}
|
||||||
|
{#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}
|
{#if replyError}
|
||||||
<div
|
<div
|
||||||
class="mb-4 rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700"
|
class="mb-4 rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700"
|
||||||
|
|
@ -463,11 +503,13 @@
|
||||||
{replying ? "Posting…" : "post reply"}
|
{replying ? "Posting…" : "post reply"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<p class=" text-center text-neutral-500 dark:text-neutral-400">
|
<p class=" text-center text-neutral-500 dark:text-neutral-400">
|
||||||
To participate and reply, please
|
To participate and reply, please
|
||||||
<button onclick={openLogin} class="text-accent hover:underline"
|
<button
|
||||||
>login now</button
|
onclick={() => openLogin()}
|
||||||
|
class="text-accent hover:underline">login now</button
|
||||||
>
|
>
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -481,6 +523,25 @@
|
||||||
bind:visible={scrubberVisible}
|
bind:visible={scrubberVisible}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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}
|
{:else}
|
||||||
<div class="py-12 text-center text-neutral-400 dark:text-neutral-500">
|
<div class="py-12 text-center text-neutral-400 dark:text-neutral-500">
|
||||||
Loading…
|
Loading…
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue