diff --git a/src/components/crew/CordnNativePanel.tsx b/src/components/crew/CordnNativePanel.tsx index 0cdf29b..84f0139 100644 --- a/src/components/crew/CordnNativePanel.tsx +++ b/src/components/crew/CordnNativePanel.tsx @@ -43,6 +43,8 @@ import { isNativePlatform } from '@/lib/platform'; import { formatDistanceToNow } from '@/lib/dateFnsI18n'; import { cn } from '@/lib/utils'; import type { CordnRoom } from '@/lib/cordn/room'; +import { readCordnSeen, writeCordnSeen } from '@/lib/cordn/unread'; +import { clearPendingJoin, readPendingJoin, writePendingJoin } from '@/lib/cordn/pendingJoin'; type RoomScope = 'crew' | 'community'; @@ -60,6 +62,12 @@ interface CordnNativePanelProps { inviteUrl?: string; /** Community bot pubkey — shown with a friendly label in requests/members. */ botPubkey?: string; + /** Whether the private-chat tab is the visible tab (drives seen/unread). */ + active?: boolean; + /** Called when the room has inbound messages newer than the last seen cursor. */ + onUnreadChange?: (hasUnread: boolean) => void; + /** Called with the number of pending join requests (founder/admin). */ + onRequestsChange?: (count: number) => void; } type MemberRole = 'founder' | 'admin' | 'member'; @@ -82,6 +90,9 @@ export function CordnNativePanel({ scope = 'crew', inviteUrl: inviteUrlProp, botPubkey, + active = true, + onUnreadChange, + onRequestsChange, }: CordnNativePanelProps) { const { t } = useTranslation(); const isCommunity = scope === 'community'; @@ -127,7 +138,7 @@ export function CordnNativePanel({ ], }; const { user } = useCurrentUser(); - const native = useCordnNative({ crew, enabled: !!user && isMember }); + const native = useCordnNative({ crew, enabled: !!user && isMember, active }); // Stable callbacks (the hook's return object is recreated each render). const sendNative = native.send; const checkInviteNative = native.checkInvite; @@ -135,7 +146,9 @@ export function CordnNativePanel({ const [copied, setCopied] = useState(false); const [copiedLink, setCopiedLink] = useState(false); const [notice, setNotice] = useState(null); - const [awaiting, setAwaiting] = useState(false); + const [awaiting, setAwaiting] = useState(() => + user?.pubkey ? readPendingJoin(user.pubkey, native.groupId) : false, + ); const [pending, setPending] = useState(null); const [sentIds, setSentIds] = useState>(() => new Set()); const [inviteOpen, setInviteOpen] = useState(false); @@ -159,17 +172,80 @@ export function CordnNativePanel({ }, [native.messages.length, pending?.state]); useEffect(() => { - if (native.group && awaiting) setAwaiting(false); - }, [native.group, awaiting]); + if (!user?.pubkey) return; + setAwaiting(readPendingJoin(user.pubkey, native.groupId)); + }, [user?.pubkey, native.groupId]); + + useEffect(() => { + if (native.group && awaiting) { + if (user?.pubkey) clearPendingJoin(user.pubkey, native.groupId); + setAwaiting(false); + } + }, [native.group, awaiting, user?.pubkey, native.groupId]); // Founders should see waiting requests without hunting for the check button. - // One quiet read when the group opens; the manual button handles the rest. - const loadRequestsNative = native.loadRequests; + // Quiet reads keep the tab badge fresh without toggling the composer's busy + // state. Amber is excluded: a background ContextVM request would pop the + // signer; those users use the explicit Check requests button. + const loadRequestsQuietNative = native.loadRequestsQuiet; + const interactive = native.interactiveHintVisible; + const groupGid = native.group?.gid; useEffect(() => { - if (!canManage || !native.group || !native.ready || loadedRequestsRef.current) return; + // Hidden tabs only do this for nsec/bunker. Extension/Amber users either + // open the tab once (extension) or use the manual Check requests button. + if (!canManage || !groupGid || !native.ready || interactive || (!native.passiveSigner && !active) || loadedRequestsRef.current) return; loadedRequestsRef.current = true; - void loadRequestsNative(); - }, [canManage, native.group, native.ready, loadRequestsNative]); + void loadRequestsQuietNative(); + }, [canManage, groupGid, native.ready, interactive, native.passiveSigner, active, loadRequestsQuietNative]); + + useEffect(() => { + if (!canManage || !groupGid || !native.ready || interactive || !native.passiveSigner) return; + const id = window.setInterval(() => void loadRequestsQuietNative(), 60_000); + return () => window.clearInterval(id); + }, [canManage, groupGid, native.ready, interactive, native.passiveSigner, loadRequestsQuietNative]); + + useEffect(() => { + onRequestsChange?.(canManage ? native.requests.length : 0); + }, [canManage, native.requests.length, onRequestsChange]); + + // "New since last opened" marker. While the tab is visible we advance the + // cursor; while it is hidden we only compare inbound timestamps. The first + // observation seeds the cursor so a room's old history is not "unread". + const messageCursor = useMemo(() => { + const latest = native.messages + .filter((m) => m.direction === 'inbound' && m.sender !== user?.pubkey) + .reduce((max, m) => Math.max(max, m.createdAt), 0); + return { latest }; + }, [native.messages, user?.pubkey]); + + useEffect(() => { + if (!user?.pubkey || !groupGid || !native.ready) { + onUnreadChange?.(false); + return; + } + const gid = groupGid; + const latest = messageCursor.latest; + if (active) { + writeCordnSeen(user.pubkey, gid, latest); + onUnreadChange?.(false); + return; + } + const seen = readCordnSeen(user.pubkey, gid); + if (seen === null) { + writeCordnSeen(user.pubkey, gid, latest); + onUnreadChange?.(false); + } else { + onUnreadChange?.(latest > seen); + } + }, [active, groupGid, native.ready, user?.pubkey, messageCursor.latest, onUnreadChange]); + + // Opening the private tab pulls anything missed while it was hidden. Silent + // signers also poll in the hook; this makes tab activation immediate. + const refreshNative = native.refresh; + useEffect(() => { + if (!active || !native.ready || !groupGid || interactive) return; + void refreshNative(); + }, [active, native.ready, groupGid, interactive, refreshNative]); const markSent = useCallback((id: string) => { if (!id) return; @@ -261,20 +337,22 @@ export function CordnNativePanel({ const handleRequestJoin = useCallback(async () => { setNotice(null); const requested = await native.requestJoin(); - if (!requested) return; + if (!requested || !user?.pubkey) return; + writePendingJoin(user.pubkey, native.groupId); setAwaiting(true); - }, [native]); + }, [native, user?.pubkey]); const handleCheckInvite = useCallback(async () => { if (native.busy) return; setNotice(null); const joined = await checkNow(); if (joined) { + if (user?.pubkey) clearPendingJoin(user.pubkey, native.groupId); setAwaiting(false); return; } if (!awaiting) setNotice(t('cordnUi.noInvite')); - }, [checkNow, awaiting, t, native.busy]); + }, [checkNow, awaiting, t, native.busy, user?.pubkey, native.groupId]); // Keep the latest checker in a ref: the poll must not restart just because a // parent re-render gave the hook callbacks new identities. @@ -287,7 +365,7 @@ export function CordnNativePanel({ // 30s so a founder who approves later is picked up without a manual tap. // Interactive signers (Amber) are excluded — polling would pop their signer. useEffect(() => { - if (!awaiting || native.group || !native.ready || native.interactiveHintVisible) return; + if (!awaiting || native.group || !native.ready || native.interactiveHintVisible || (!active && !native.passiveSigner)) return; let cancelled = false; let timer: number | undefined; let delay = 10_000; @@ -310,7 +388,7 @@ export function CordnNativePanel({ cancelled = true; if (timer !== undefined) window.clearTimeout(timer); }; - }, [awaiting, native.group, native.ready, native.interactiveHintVisible]); + }, [awaiting, native.group, native.ready, native.interactiveHintVisible, active, native.passiveSigner]); // "Join our crew chat" invite. The chat needs the person to be a crew member // first, so the link just drops them on this crew's private-chat tab with the diff --git a/src/hooks/useCordnNative.ts b/src/hooks/useCordnNative.ts index 8491176..10f33a8 100644 --- a/src/hooks/useCordnNative.ts +++ b/src/hooks/useCordnNative.ts @@ -15,7 +15,7 @@ import type { CordnGroupInfo, CordnSession, } from '@/lib/cordn/native'; -import { isInteractiveSigner } from '@/lib/signerCapabilities'; +import { isInteractiveSigner, isPassiveSigner } from '@/lib/signerCapabilities'; import type { CordnRoom } from '@/lib/cordn/room'; /** Resolved coordinator endpoint for the native (ContextVM) client. */ @@ -70,10 +70,14 @@ export interface UseCordnNativeResult { /** Founder/admin: pending join requests for this group. */ requests: JoinRequest[]; loadRequests: () => Promise; + /** Background check that never toggles `busy` (for a tab/request badge). */ + loadRequestsQuiet: () => Promise; /** Founder/admin: add a requester (by pubkey) and deliver a welcome. */ approve: (identifier: string) => Promise; /** True for interactive signers (Amber) — manual refresh/send, no polling. */ interactiveHintVisible: boolean; + /** True for nsec/bunker: safe to poll while the private tab is hidden. */ + passiveSigner: boolean; /** Shareable `cordn1…` reference for the crew's group (once known). */ groupRef: string | null; /** True when this device is set up: it holds the group or a published key package. */ @@ -93,9 +97,12 @@ const POLL_MS = 6000; export function useCordnNative({ crew, enabled, + active = true, }: { crew: CordnRoom | null; enabled: boolean; + /** Visible private-chat tab; hidden tabs only poll for passive signers. */ + active?: boolean; }): UseCordnNativeResult { const { user } = useCurrentUser(); const { logins } = useNostrLogin(); @@ -126,6 +133,8 @@ export function useCordnNative({ const [reloadKey, setReloadKey] = useState(0); const interactive = isInteractiveSigner(user, logins); + const passiveSigner = isPassiveSigner(user, logins); + const canBackgroundPoll = active || passiveSigner; const retry = useCallback(() => { setError(null); @@ -212,10 +221,10 @@ export function useCordnNative({ // every request, so those users refresh/send manually (same rule as the DM // and sealed-relay chat hooks). useEffect(() => { - if (!ready || !group || interactive) return; + if (!ready || !group || interactive || !canBackgroundPoll) return; const id = setInterval(() => void refresh(), POLL_MS); return () => clearInterval(id); - }, [ready, group, interactive, refresh]); + }, [ready, group, interactive, canBackgroundPoll, refresh]); const run = useCallback(async (fn: () => Promise): Promise => { setBusy(true); @@ -306,6 +315,17 @@ export function useCordnNative({ }); }, [groupId, run]); + const loadRequestsQuiet = useCallback(async () => { + const session = sessionRef.current; + if (!session || !groupId) return; + try { + const pending = await session.fetchJoinRequests(groupId); + setRequests(pending); + } catch { + // Keep the previous count; the next quiet poll or manual check retries. + } + }, [groupId]); + const approve = useCallback( async (identifier: string) => { const session = sessionRef.current; @@ -336,8 +356,10 @@ export function useCordnNative({ checkInvite, requests, loadRequests, + loadRequestsQuiet, approve, interactiveHintVisible: interactive, + passiveSigner, groupRef, keyPackageReady, retry, diff --git a/src/lib/cordn/pendingJoin.test.ts b/src/lib/cordn/pendingJoin.test.ts new file mode 100644 index 0000000..3bc0ff5 --- /dev/null +++ b/src/lib/cordn/pendingJoin.test.ts @@ -0,0 +1,36 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { clearPendingJoin, readPendingJoin, writePendingJoin } from './pendingJoin'; + +const PK = 'a'.repeat(64); +const GID = 'skatespots:community'; + +describe('pendingJoin', () => { + afterEach(() => { + vi.useRealTimers(); + localStorage.clear(); + }); + + it('round-trips a pending request and scopes it by account + room', () => { + expect(readPendingJoin(PK, GID)).toBe(false); + writePendingJoin(PK, GID); + expect(readPendingJoin(PK, GID)).toBe(true); + expect(readPendingJoin('b'.repeat(64), GID)).toBe(false); + expect(readPendingJoin(PK, 'other:room')).toBe(false); + clearPendingJoin(PK, GID); + expect(readPendingJoin(PK, GID)).toBe(false); + }); + + it('expires stale requests', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-10-28T00:00:00Z')); + writePendingJoin(PK, GID); + vi.setSystemTime(new Date('2026-12-01T00:00:00Z')); + expect(readPendingJoin(PK, GID)).toBe(false); + }); + + it('does nothing for empty identifiers', () => { + writePendingJoin('', GID); + writePendingJoin(PK, ''); + expect(readPendingJoin('', GID)).toBe(false); + }); +}); diff --git a/src/lib/cordn/pendingJoin.ts b/src/lib/cordn/pendingJoin.ts new file mode 100644 index 0000000..921fd64 --- /dev/null +++ b/src/lib/cordn/pendingJoin.ts @@ -0,0 +1,44 @@ +/** + * Remember a join request across reloads so the panel can resume the + * coordinator poll instead of losing the “waiting for approval” state. + * + * Scoped by account + room and expires after 30 days; an old request should not + * make the panel poll forever if the user abandoned it. + */ + +const KEY_PREFIX = 'skatespots:cordn-pending:'; +const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; + +function key(pubkey: string, gid: string): string { + return `${KEY_PREFIX}${pubkey}:${gid}`; +} + +export function readPendingJoin(pubkey: string, gid: string): boolean { + if (!pubkey || !gid) return false; + try { + const raw = localStorage.getItem(key(pubkey, gid)); + if (raw === null) return false; + const at = Number(raw); + return Number.isFinite(at) && Date.now() - at < MAX_AGE_MS; + } catch { + return false; + } +} + +export function writePendingJoin(pubkey: string, gid: string): void { + if (!pubkey || !gid) return; + try { + localStorage.setItem(key(pubkey, gid), String(Date.now())); + } catch { + // private mode / quota — polling still works for this session + } +} + +export function clearPendingJoin(pubkey: string, gid: string): void { + if (!pubkey || !gid) return; + try { + localStorage.removeItem(key(pubkey, gid)); + } catch { + // ignore + } +} diff --git a/src/lib/cordn/unread.test.ts b/src/lib/cordn/unread.test.ts new file mode 100644 index 0000000..e2348d7 --- /dev/null +++ b/src/lib/cordn/unread.test.ts @@ -0,0 +1,28 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { clearCordnSeen, readCordnSeen, writeCordnSeen } from './unread'; + +const PK = 'a'.repeat(64); +const GID = 'skatespots:community'; + +describe('cordn unread cursor', () => { + afterEach(() => localStorage.clear()); + + it('distinguishes never-seen from seen-at-zero', () => { + expect(readCordnSeen(PK, GID)).toBeNull(); + writeCordnSeen(PK, GID, 0); + expect(readCordnSeen(PK, GID)).toBe(0); + }); + + it('round-trips the newest timestamp per account + room', () => { + writeCordnSeen(PK, GID, 1_700_000_000); + expect(readCordnSeen(PK, GID)).toBe(1_700_000_000); + expect(readCordnSeen('b'.repeat(64), GID)).toBeNull(); + expect(readCordnSeen(PK, 'other:room')).toBeNull(); + }); + + it('clears a cursor', () => { + writeCordnSeen(PK, GID, 42); + clearCordnSeen(PK, GID); + expect(readCordnSeen(PK, GID)).toBeNull(); + }); +}); diff --git a/src/lib/cordn/unread.ts b/src/lib/cordn/unread.ts new file mode 100644 index 0000000..616018b --- /dev/null +++ b/src/lib/cordn/unread.ts @@ -0,0 +1,41 @@ +/** + * Per-device "last seen" cursor for a native Cordn room. + * + * We store the newest inbound message timestamp (seconds), not a message count: + * it is stable across reconnects and only requires a tiny localStorage value. + * `null` means "never opened this room on this device" — callers seed it on the + * first observation so old history does not appear as unread. + */ + +const KEY_PREFIX = 'skatespots:cordn-seen:'; + +function key(pubkey: string, gid: string): string { + return `${KEY_PREFIX}${pubkey}:${gid}`; +} + +export function readCordnSeen(pubkey: string, gid: string): number | null { + try { + const raw = localStorage.getItem(key(pubkey, gid)); + if (raw === null) return null; + const value = Number(raw); + return Number.isFinite(value) ? value : null; + } catch { + return null; + } +} + +export function writeCordnSeen(pubkey: string, gid: string, at: number): void { + try { + localStorage.setItem(key(pubkey, gid), String(at)); + } catch { + // private mode / quota — unread is best-effort + } +} + +export function clearCordnSeen(pubkey: string, gid: string): void { + try { + localStorage.removeItem(key(pubkey, gid)); + } catch { + // ignore + } +} diff --git a/src/lib/signerCapabilities.ts b/src/lib/signerCapabilities.ts index a08d251..b6d88a7 100644 --- a/src/lib/signerCapabilities.ts +++ b/src/lib/signerCapabilities.ts @@ -87,6 +87,15 @@ export function isInteractiveSigner( return login.type === 'x-amber'; } +/** True when background signing is silent: nsec / remote bunker (not Amber or a browser extension). */ +export function isPassiveSigner( + user: MaybeUser | null | undefined, + logins: readonly MaybeLogin[], +): boolean { + const login = resolveLogin(user, logins); + return login?.type === 'nsec' || login?.type === 'bunker'; +} + /** True if the user's signer can do NIP-04 encryption. Never throws. */ export function hasNip04( user: MaybeUser | null | undefined, diff --git a/src/pages/CrewDetailPage.tsx b/src/pages/CrewDetailPage.tsx index 072f94a..e915ef1 100644 --- a/src/pages/CrewDetailPage.tsx +++ b/src/pages/CrewDetailPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useSeoMeta } from '@unhead/react'; import { useParams, Link, useSearchParams } from 'react-router-dom'; import { nip19 } from 'nostr-tools'; @@ -71,6 +71,10 @@ export default function CrewDetailPage() { : null; const [activeTab, setActiveTab] = useState(validTab ?? 'events'); const tabTouchedRef = useRef(Boolean(validTab)); + const [cordnUnread, setCordnUnread] = useState(false); + const [cordnRequests, setCordnRequests] = useState(0); + const handleCordnUnread = useCallback((value: boolean) => setCordnUnread(value), []); + const handleCordnRequests = useCallback((count: number) => setCordnRequests(count), []); // The crew's native MLS group id (the crew coordinate) and whether this device // already holds the group — used to default the tab and to nudge the founder. @@ -362,6 +366,16 @@ export default function CrewDetailPage() { {' '} {t('cordnUi.title')} + {(cordnRequests > 0 || cordnUnread) && ( + 0 + ? 'h-1.5 w-1.5 shrink-0 rounded-full bg-red-400' + : 'h-1.5 w-1.5 shrink-0 rounded-full bg-teal-300' + } + title={cordnRequests > 0 ? t('cordnUi.requests') : t('settings.notifications')} + /> + )} @@ -398,8 +412,19 @@ export default function CrewDetailPage() { {/* Private chat — native Cordn / MLS (real end-to-end encryption). */} - - + +