Lazy join a group automatically before posting

This commit is contained in:
dtonon 2026-04-08 14:24:54 +01:00
parent fab4edd721
commit 77e2ebfbd6
7 changed files with 302 additions and 2 deletions

View file

@ -1,3 +1,4 @@
PUBLIC_RELAY_URL=ws://localhost:3334 PUBLIC_RELAY_URL=ws://localhost:3334
PUBLIC_GROUP_ID=mygrouprandomid PUBLIC_GROUP_ID=mygrouprandomid
PUBLIC_MODE=simple # simple | full PUBLIC_MODE=simple # simple | full
PUBLIC_JOINCODE=no # yes | no — show invite-code field on join failure

View file

@ -3,6 +3,7 @@ import type { WindowNostr } from "@nostr/tools/nip07";
import type { EventTemplate, VerifiedEvent } from "@nostr/tools/core"; import type { EventTemplate, VerifiedEvent } from "@nostr/tools/core";
import * as nip19 from "@nostr/tools/nip19"; import * as nip19 from "@nostr/tools/nip19";
import { finalizeEvent, getPublicKey } from "@nostr/tools/pure"; import { finalizeEvent, getPublicKey } from "@nostr/tools/pure";
import { resetJoinState, initJoinForUser } from "$lib/join.svelte";
declare global { declare global {
interface Window { interface Window {
@ -58,6 +59,7 @@ function makeNsecSigner(secretKey: Uint8Array): Signer {
async function setUser(pubkey: string) { async function setUser(pubkey: string) {
const { loadNostrUser } = await import("@nostr/gadgets/metadata"); const { loadNostrUser } = await import("@nostr/gadgets/metadata");
user = await loadNostrUser(pubkey); user = await loadNostrUser(pubkey);
initJoinForUser(pubkey);
} }
export async function loginWithExtension() { export async function loginWithExtension() {
@ -108,6 +110,7 @@ export function logout() {
localStorage.removeItem(PUBKEY_KEY); localStorage.removeItem(PUBKEY_KEY);
localStorage.removeItem(METHOD_KEY); localStorage.removeItem(METHOD_KEY);
localStorage.removeItem(NSEC_KEY); localStorage.removeItem(NSEC_KEY);
resetJoinState();
} }
export async function restoreSession() { export async function restoreSession() {

View file

@ -0,0 +1,115 @@
<script lang="ts">
import { joinState, closeJoinModal, retryJoin } from "$lib/join.svelte";
import { tick } from "svelte";
let code = $state("");
let codeInput = $state<HTMLInputElement | null>(null);
$effect(() => {
if (joinState.modalOpen && joinState.codeRequired) {
tick().then(() => codeInput?.focus());
}
if (!joinState.modalOpen) code = "";
});
async function onRetry() {
await retryJoin(code.trim() || undefined);
}
function onClose() {
closeJoinModal();
}
function onKeydown(e: KeyboardEvent) {
if (!joinState.modalOpen) return;
if (e.key === "Escape") onClose();
}
</script>
<svelte:window onkeydown={onKeydown} />
{#if joinState.modalOpen}
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<button
type="button"
aria-label="Close"
class="absolute inset-0 bg-black/40"
onclick={onClose}
></button>
<div
class="relative w-full max-w-md rounded-lg bg-white p-6 shadow-xl"
role="dialog"
aria-modal="true"
aria-labelledby="join-title"
>
<button
type="button"
onclick={onClose}
aria-label="Close"
disabled={joinState.busy}
class="absolute right-3 top-3 text-2xl leading-none text-gray-400 hover:text-gray-700 disabled:opacity-50"
>
×
</button>
<h2 id="join-title" class="mb-2 text-lg font-semibold text-gray-900">
Join this group
</h2>
<p class="mb-4 text-sm text-gray-600">
{#if joinState.codeRequired}
This community requires an invite code. Enter your code below to
request access. If you don't have one, contact the admin.
{:else}
We could not add you to the group automatically. The relay may be
processing your request, or the admin needs to approve it manually.
{/if}
</p>
{#if joinState.modalError}
<div
class="mb-4 rounded border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700"
role="alert"
>
{joinState.modalError}
</div>
{/if}
{#if joinState.codeRequired}
<label for="join-code-input" class="sr-only">Invite code</label>
<input
id="join-code-input"
bind:this={codeInput}
type="text"
placeholder="Invite code"
bind:value={code}
disabled={joinState.busy}
autocomplete="off"
autocapitalize="off"
spellcheck="false"
onkeydown={(e) => e.key === "Enter" && onRetry()}
class="mb-3 w-full rounded border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-brand disabled:opacity-50"
/>
{/if}
<div class="flex justify-end gap-2">
<button
type="button"
onclick={onClose}
disabled={joinState.busy}
class="rounded px-3 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100 disabled:opacity-50"
>
Close
</button>
<button
type="button"
onclick={onRetry}
disabled={joinState.busy ||
(joinState.codeRequired && !code.trim())}
class="rounded bg-brand px-3 py-2 text-sm font-medium text-white hover:bg-brand-hover disabled:cursor-not-allowed disabled:opacity-50"
>
{joinState.busy ? "Trying…" : "Retry"}
</button>
</div>
</div>
</div>
{/if}

View file

@ -2,9 +2,11 @@ import {
PUBLIC_RELAY_URL, PUBLIC_RELAY_URL,
PUBLIC_GROUP_ID, PUBLIC_GROUP_ID,
PUBLIC_MODE, PUBLIC_MODE,
PUBLIC_JOINCODE,
} from "$env/static/public"; } from "$env/static/public";
export const RELAY_URL = PUBLIC_RELAY_URL; export const RELAY_URL = PUBLIC_RELAY_URL;
export const GROUP_ID = PUBLIC_GROUP_ID; export const GROUP_ID = PUBLIC_GROUP_ID;
export const MODE: "simple" | "full" = export const MODE: "simple" | "full" =
PUBLIC_MODE === "full" ? "full" : "simple"; PUBLIC_MODE === "full" ? "full" : "simple";
export const JOINCODE_REQUIRED = PUBLIC_JOINCODE === "yes";

172
src/lib/join.svelte.ts Normal file
View file

@ -0,0 +1,172 @@
import { Relay, SimplePool } from "@nostr/tools";
import { auth } from "$lib/auth.svelte";
import { GROUP_ID, RELAY_URL, JOINCODE_REQUIRED } from "$lib/config";
let joined = $state(false);
let modalOpen = $state(false);
let modalError = $state<string | null>(null);
let busy = $state(false);
let pendingAction: (() => Promise<void>) | null = null;
export const joinState = {
get joined() {
return joined;
},
get modalOpen() {
return modalOpen;
},
get modalError() {
return modalError;
},
get busy() {
return busy;
},
get codeRequired() {
return JOINCODE_REQUIRED;
},
};
export function resetJoinState() {
joined = false;
modalOpen = false;
modalError = null;
busy = false;
pendingAction = null;
}
// Checks group membership: kind:9000 (per-user put-user event, lightweight)
// first, falling back to kind:39002 (full members list, heavier) only if 9000
// did not match. Either signal marks the user as joined and skips the 9021.
function queryHasMatch(
relay: Relay,
filter: Parameters<Relay["subscribe"]>[0][number],
timeoutMs = 3000,
): Promise<boolean> {
return new Promise((resolve) => {
let settled = false;
const finish = (val: boolean) => {
if (settled) return;
settled = true;
try {
sub.close();
} catch {}
resolve(val);
};
const sub = relay.subscribe([filter], {
onevent() {
finish(true);
},
oneose() {
finish(false);
},
onclose() {
finish(false);
},
});
setTimeout(() => finish(false), timeoutMs);
});
}
export async function initJoinForUser(pubkey: string) {
let relay: Relay;
try {
relay = await Relay.connect(RELAY_URL);
} catch {
return;
}
try {
const has9000 = await queryHasMatch(relay, {
kinds: [9000],
"#h": [GROUP_ID],
"#p": [pubkey],
limit: 1,
});
if (has9000) {
joined = true;
return;
}
const has39002 = await queryHasMatch(relay, {
kinds: [39002],
"#d": [GROUP_ID],
"#p": [pubkey],
limit: 1,
});
if (has39002) joined = true;
} finally {
try {
relay.close();
} catch {}
}
}
export function closeJoinModal() {
if (busy) return;
modalOpen = false;
modalError = null;
pendingAction = null;
}
async function publishJoinRequest(code?: string) {
if (!auth.signer) throw new Error("Not logged in");
const tags: string[][] = [["h", GROUP_ID]];
if (code) tags.push(["code", code]);
const event = await auth.signer.signEvent({
kind: 9021,
created_at: Math.floor(Date.now() / 1000),
tags,
content: "",
});
const pool = new SimplePool();
try {
const timeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("Relay did not respond in time")), 8000),
);
await Promise.race([
Promise.all(pool.publish([RELAY_URL], event)),
timeout,
]);
} finally {
pool.destroy();
}
}
// Wraps a group action. If not yet joined, sends kind:9021 first, then the
// action. On failure, opens the join modal so the user can retry (with code if
// configured). Returns true on success, false if the modal was opened.
export async function withJoin(action: () => Promise<void>): Promise<boolean> {
if (joined) {
await action();
return true;
}
busy = true;
try {
await publishJoinRequest();
await action();
joined = true;
return true;
} catch (e) {
pendingAction = action;
modalError = e instanceof Error ? e.message : "Could not join the group";
modalOpen = true;
return false;
} finally {
busy = false;
}
}
export async function retryJoin(code?: string) {
if (!pendingAction || busy) return;
busy = true;
modalError = null;
try {
await publishJoinRequest(code);
await pendingAction();
joined = true;
modalOpen = false;
pendingAction = null;
} catch (e) {
modalError = e instanceof Error ? e.message : "Could not join the group";
} finally {
busy = false;
}
}

View file

@ -5,6 +5,7 @@
import LeftSidebar from "$lib/components/LeftSidebar.svelte"; import LeftSidebar from "$lib/components/LeftSidebar.svelte";
import ChatSidebar from "$lib/components/ChatSidebar.svelte"; import ChatSidebar from "$lib/components/ChatSidebar.svelte";
import LoginModal from "$lib/components/LoginModal.svelte"; import LoginModal from "$lib/components/LoginModal.svelte";
import JoinModal from "$lib/components/JoinModal.svelte";
import { page } from "$app/state"; import { page } from "$app/state";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { restoreSession } from "$lib/auth.svelte"; import { restoreSession } from "$lib/auth.svelte";
@ -48,3 +49,4 @@
</div> </div>
<LoginModal /> <LoginModal />
<JoinModal />

View file

@ -8,6 +8,7 @@
type PostData, type PostData,
} from "$lib/thread.svelte"; } from "$lib/thread.svelte";
import { auth, openLogin } from "$lib/auth.svelte"; import { auth, openLogin } from "$lib/auth.svelte";
import { withJoin } from "$lib/join.svelte";
import Reactions from "$lib/components/Reactions.svelte"; import Reactions from "$lib/components/Reactions.svelte";
import ThreadScrubber from "$lib/components/ThreadScrubber.svelte"; import ThreadScrubber from "$lib/components/ThreadScrubber.svelte";
import type { NostrUser } from "@nostr/gadgets/metadata"; import type { NostrUser } from "@nostr/gadgets/metadata";
@ -43,11 +44,15 @@
async function submitReply() { async function submitReply() {
if (!auth.user || !replyContent.trim()) return; if (!auth.user || !replyContent.trim()) return;
const content = replyContent.trim();
const pubkey = auth.user.pubkey;
replying = true; replying = true;
replyError = null; replyError = null;
try { try {
await sendReply(replyContent.trim(), auth.user!.pubkey); await withJoin(async () => {
await sendReply(content, pubkey);
replyContent = ""; replyContent = "";
});
} catch (e) { } catch (e) {
replyError = e instanceof Error ? e.message : "Failed to post reply"; replyError = e instanceof Error ? e.message : "Failed to post reply";
} finally { } finally {