Lazy join a group automatically before posting
This commit is contained in:
parent
fab4edd721
commit
77e2ebfbd6
7 changed files with 302 additions and 2 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
115
src/lib/components/JoinModal.svelte
Normal file
115
src/lib/components/JoinModal.svelte
Normal 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}
|
||||||
|
|
@ -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
172
src/lib/join.svelte.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 />
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
||||||
replyContent = "";
|
await sendReply(content, pubkey);
|
||||||
|
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 {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue