From 77e2ebfbd6b319f146c8a1882dc992e09a743354 Mon Sep 17 00:00:00 2001 From: dtonon Date: Wed, 8 Apr 2026 14:24:54 +0100 Subject: [PATCH] Lazy join a group automatically before posting --- .env.example | 1 + src/lib/auth.svelte.ts | 3 + src/lib/components/JoinModal.svelte | 115 +++++++++++++++++++ src/lib/config.ts | 2 + src/lib/join.svelte.ts | 172 ++++++++++++++++++++++++++++ src/routes/+layout.svelte | 2 + src/routes/thread/[id]/+page.svelte | 9 +- 7 files changed, 302 insertions(+), 2 deletions(-) create mode 100644 src/lib/components/JoinModal.svelte create mode 100644 src/lib/join.svelte.ts diff --git a/.env.example b/.env.example index 0c784bd..94541e9 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,4 @@ PUBLIC_RELAY_URL=ws://localhost:3334 PUBLIC_GROUP_ID=mygrouprandomid PUBLIC_MODE=simple # simple | full +PUBLIC_JOINCODE=no # yes | no — show invite-code field on join failure diff --git a/src/lib/auth.svelte.ts b/src/lib/auth.svelte.ts index e792844..ebc8df6 100644 --- a/src/lib/auth.svelte.ts +++ b/src/lib/auth.svelte.ts @@ -3,6 +3,7 @@ import type { WindowNostr } from "@nostr/tools/nip07"; import type { EventTemplate, VerifiedEvent } from "@nostr/tools/core"; import * as nip19 from "@nostr/tools/nip19"; import { finalizeEvent, getPublicKey } from "@nostr/tools/pure"; +import { resetJoinState, initJoinForUser } from "$lib/join.svelte"; declare global { interface Window { @@ -58,6 +59,7 @@ function makeNsecSigner(secretKey: Uint8Array): Signer { async function setUser(pubkey: string) { const { loadNostrUser } = await import("@nostr/gadgets/metadata"); user = await loadNostrUser(pubkey); + initJoinForUser(pubkey); } export async function loginWithExtension() { @@ -108,6 +110,7 @@ export function logout() { localStorage.removeItem(PUBKEY_KEY); localStorage.removeItem(METHOD_KEY); localStorage.removeItem(NSEC_KEY); + resetJoinState(); } export async function restoreSession() { diff --git a/src/lib/components/JoinModal.svelte b/src/lib/components/JoinModal.svelte new file mode 100644 index 0000000..d8c0fb3 --- /dev/null +++ b/src/lib/components/JoinModal.svelte @@ -0,0 +1,115 @@ + + + + +{#if joinState.modalOpen} +
+ + +
+{/if} diff --git a/src/lib/config.ts b/src/lib/config.ts index 0325f3c..d3a6825 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -2,9 +2,11 @@ import { PUBLIC_RELAY_URL, PUBLIC_GROUP_ID, PUBLIC_MODE, + PUBLIC_JOINCODE, } from "$env/static/public"; export const RELAY_URL = PUBLIC_RELAY_URL; export const GROUP_ID = PUBLIC_GROUP_ID; export const MODE: "simple" | "full" = PUBLIC_MODE === "full" ? "full" : "simple"; +export const JOINCODE_REQUIRED = PUBLIC_JOINCODE === "yes"; diff --git a/src/lib/join.svelte.ts b/src/lib/join.svelte.ts new file mode 100644 index 0000000..be50ceb --- /dev/null +++ b/src/lib/join.svelte.ts @@ -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(null); +let busy = $state(false); +let pendingAction: (() => Promise) | 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[0][number], + timeoutMs = 3000, +): Promise { + 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((_, 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): Promise { + 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; + } +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 09c0421..043c043 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -5,6 +5,7 @@ import LeftSidebar from "$lib/components/LeftSidebar.svelte"; import ChatSidebar from "$lib/components/ChatSidebar.svelte"; import LoginModal from "$lib/components/LoginModal.svelte"; + import JoinModal from "$lib/components/JoinModal.svelte"; import { page } from "$app/state"; import { onMount } from "svelte"; import { restoreSession } from "$lib/auth.svelte"; @@ -48,3 +49,4 @@ + diff --git a/src/routes/thread/[id]/+page.svelte b/src/routes/thread/[id]/+page.svelte index 12eca35..ef3b404 100644 --- a/src/routes/thread/[id]/+page.svelte +++ b/src/routes/thread/[id]/+page.svelte @@ -8,6 +8,7 @@ type PostData, } from "$lib/thread.svelte"; import { auth, openLogin } from "$lib/auth.svelte"; + import { withJoin } from "$lib/join.svelte"; import Reactions from "$lib/components/Reactions.svelte"; import ThreadScrubber from "$lib/components/ThreadScrubber.svelte"; import type { NostrUser } from "@nostr/gadgets/metadata"; @@ -43,11 +44,15 @@ async function submitReply() { if (!auth.user || !replyContent.trim()) return; + const content = replyContent.trim(); + const pubkey = auth.user.pubkey; replying = true; replyError = null; try { - await sendReply(replyContent.trim(), auth.user!.pubkey); - replyContent = ""; + await withJoin(async () => { + await sendReply(content, pubkey); + replyContent = ""; + }); } catch (e) { replyError = e instanceof Error ? e.message : "Failed to post reply"; } finally {