feat(cordn): unread and join-request badges; persist pending joins

This commit is contained in:
SkateSpots 2026-10-28 08:39:01 +07:00
parent 2cec2b4782
commit 7da67cc2c0
8 changed files with 303 additions and 20 deletions

View file

@ -43,6 +43,8 @@ import { isNativePlatform } from '@/lib/platform';
import { formatDistanceToNow } from '@/lib/dateFnsI18n'; import { formatDistanceToNow } from '@/lib/dateFnsI18n';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { CordnRoom } from '@/lib/cordn/room'; 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'; type RoomScope = 'crew' | 'community';
@ -60,6 +62,12 @@ interface CordnNativePanelProps {
inviteUrl?: string; inviteUrl?: string;
/** Community bot pubkey — shown with a friendly label in requests/members. */ /** Community bot pubkey — shown with a friendly label in requests/members. */
botPubkey?: string; 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'; type MemberRole = 'founder' | 'admin' | 'member';
@ -82,6 +90,9 @@ export function CordnNativePanel({
scope = 'crew', scope = 'crew',
inviteUrl: inviteUrlProp, inviteUrl: inviteUrlProp,
botPubkey, botPubkey,
active = true,
onUnreadChange,
onRequestsChange,
}: CordnNativePanelProps) { }: CordnNativePanelProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const isCommunity = scope === 'community'; const isCommunity = scope === 'community';
@ -127,7 +138,7 @@ export function CordnNativePanel({
], ],
}; };
const { user } = useCurrentUser(); 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). // Stable callbacks (the hook's return object is recreated each render).
const sendNative = native.send; const sendNative = native.send;
const checkInviteNative = native.checkInvite; const checkInviteNative = native.checkInvite;
@ -135,7 +146,9 @@ export function CordnNativePanel({
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [copiedLink, setCopiedLink] = useState(false); const [copiedLink, setCopiedLink] = useState(false);
const [notice, setNotice] = useState<string | null>(null); const [notice, setNotice] = useState<string | null>(null);
const [awaiting, setAwaiting] = useState(false); const [awaiting, setAwaiting] = useState(() =>
user?.pubkey ? readPendingJoin(user.pubkey, native.groupId) : false,
);
const [pending, setPending] = useState<PendingMessage | null>(null); const [pending, setPending] = useState<PendingMessage | null>(null);
const [sentIds, setSentIds] = useState<Set<string>>(() => new Set()); const [sentIds, setSentIds] = useState<Set<string>>(() => new Set());
const [inviteOpen, setInviteOpen] = useState(false); const [inviteOpen, setInviteOpen] = useState(false);
@ -159,17 +172,80 @@ export function CordnNativePanel({
}, [native.messages.length, pending?.state]); }, [native.messages.length, pending?.state]);
useEffect(() => { useEffect(() => {
if (native.group && awaiting) setAwaiting(false); if (!user?.pubkey) return;
}, [native.group, awaiting]); 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. // Founders should see waiting requests without hunting for the check button.
// One quiet read when the group opens; the manual button handles the rest. // Quiet reads keep the tab badge fresh without toggling the composer's busy
const loadRequestsNative = native.loadRequests; // 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(() => { 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; loadedRequestsRef.current = true;
void loadRequestsNative(); void loadRequestsQuietNative();
}, [canManage, native.group, native.ready, loadRequestsNative]); }, [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) => { const markSent = useCallback((id: string) => {
if (!id) return; if (!id) return;
@ -261,20 +337,22 @@ export function CordnNativePanel({
const handleRequestJoin = useCallback(async () => { const handleRequestJoin = useCallback(async () => {
setNotice(null); setNotice(null);
const requested = await native.requestJoin(); const requested = await native.requestJoin();
if (!requested) return; if (!requested || !user?.pubkey) return;
writePendingJoin(user.pubkey, native.groupId);
setAwaiting(true); setAwaiting(true);
}, [native]); }, [native, user?.pubkey]);
const handleCheckInvite = useCallback(async () => { const handleCheckInvite = useCallback(async () => {
if (native.busy) return; if (native.busy) return;
setNotice(null); setNotice(null);
const joined = await checkNow(); const joined = await checkNow();
if (joined) { if (joined) {
if (user?.pubkey) clearPendingJoin(user.pubkey, native.groupId);
setAwaiting(false); setAwaiting(false);
return; return;
} }
if (!awaiting) setNotice(t('cordnUi.noInvite')); 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 // Keep the latest checker in a ref: the poll must not restart just because a
// parent re-render gave the hook callbacks new identities. // 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. // 30s so a founder who approves later is picked up without a manual tap.
// Interactive signers (Amber) are excluded — polling would pop their signer. // Interactive signers (Amber) are excluded — polling would pop their signer.
useEffect(() => { 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 cancelled = false;
let timer: number | undefined; let timer: number | undefined;
let delay = 10_000; let delay = 10_000;
@ -310,7 +388,7 @@ export function CordnNativePanel({
cancelled = true; cancelled = true;
if (timer !== undefined) window.clearTimeout(timer); 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 // "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 // first, so the link just drops them on this crew's private-chat tab with the

View file

@ -15,7 +15,7 @@ import type {
CordnGroupInfo, CordnGroupInfo,
CordnSession, CordnSession,
} from '@/lib/cordn/native'; } from '@/lib/cordn/native';
import { isInteractiveSigner } from '@/lib/signerCapabilities'; import { isInteractiveSigner, isPassiveSigner } from '@/lib/signerCapabilities';
import type { CordnRoom } from '@/lib/cordn/room'; import type { CordnRoom } from '@/lib/cordn/room';
/** Resolved coordinator endpoint for the native (ContextVM) client. */ /** Resolved coordinator endpoint for the native (ContextVM) client. */
@ -70,10 +70,14 @@ export interface UseCordnNativeResult {
/** Founder/admin: pending join requests for this group. */ /** Founder/admin: pending join requests for this group. */
requests: JoinRequest[]; requests: JoinRequest[];
loadRequests: () => Promise<void>; loadRequests: () => Promise<void>;
/** Background check that never toggles `busy` (for a tab/request badge). */
loadRequestsQuiet: () => Promise<void>;
/** Founder/admin: add a requester (by pubkey) and deliver a welcome. */ /** Founder/admin: add a requester (by pubkey) and deliver a welcome. */
approve: (identifier: string) => Promise<boolean>; approve: (identifier: string) => Promise<boolean>;
/** True for interactive signers (Amber) — manual refresh/send, no polling. */ /** True for interactive signers (Amber) — manual refresh/send, no polling. */
interactiveHintVisible: boolean; 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). */ /** Shareable `cordn1…` reference for the crew's group (once known). */
groupRef: string | null; groupRef: string | null;
/** True when this device is set up: it holds the group or a published key package. */ /** 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({ export function useCordnNative({
crew, crew,
enabled, enabled,
active = true,
}: { }: {
crew: CordnRoom | null; crew: CordnRoom | null;
enabled: boolean; enabled: boolean;
/** Visible private-chat tab; hidden tabs only poll for passive signers. */
active?: boolean;
}): UseCordnNativeResult { }): UseCordnNativeResult {
const { user } = useCurrentUser(); const { user } = useCurrentUser();
const { logins } = useNostrLogin(); const { logins } = useNostrLogin();
@ -126,6 +133,8 @@ export function useCordnNative({
const [reloadKey, setReloadKey] = useState(0); const [reloadKey, setReloadKey] = useState(0);
const interactive = isInteractiveSigner(user, logins); const interactive = isInteractiveSigner(user, logins);
const passiveSigner = isPassiveSigner(user, logins);
const canBackgroundPoll = active || passiveSigner;
const retry = useCallback(() => { const retry = useCallback(() => {
setError(null); setError(null);
@ -212,10 +221,10 @@ export function useCordnNative({
// every request, so those users refresh/send manually (same rule as the DM // every request, so those users refresh/send manually (same rule as the DM
// and sealed-relay chat hooks). // and sealed-relay chat hooks).
useEffect(() => { useEffect(() => {
if (!ready || !group || interactive) return; if (!ready || !group || interactive || !canBackgroundPoll) return;
const id = setInterval(() => void refresh(), POLL_MS); const id = setInterval(() => void refresh(), POLL_MS);
return () => clearInterval(id); return () => clearInterval(id);
}, [ready, group, interactive, refresh]); }, [ready, group, interactive, canBackgroundPoll, refresh]);
const run = useCallback(async <T,>(fn: () => Promise<T>): Promise<T | undefined> => { const run = useCallback(async <T,>(fn: () => Promise<T>): Promise<T | undefined> => {
setBusy(true); setBusy(true);
@ -306,6 +315,17 @@ export function useCordnNative({
}); });
}, [groupId, run]); }, [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( const approve = useCallback(
async (identifier: string) => { async (identifier: string) => {
const session = sessionRef.current; const session = sessionRef.current;
@ -336,8 +356,10 @@ export function useCordnNative({
checkInvite, checkInvite,
requests, requests,
loadRequests, loadRequests,
loadRequestsQuiet,
approve, approve,
interactiveHintVisible: interactive, interactiveHintVisible: interactive,
passiveSigner,
groupRef, groupRef,
keyPackageReady, keyPackageReady,
retry, retry,

View file

@ -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);
});
});

View file

@ -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
}
}

View file

@ -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();
});
});

41
src/lib/cordn/unread.ts Normal file
View file

@ -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
}
}

View file

@ -87,6 +87,15 @@ export function isInteractiveSigner(
return login.type === 'x-amber'; 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. */ /** True if the user's signer can do NIP-04 encryption. Never throws. */
export function hasNip04( export function hasNip04(
user: MaybeUser | null | undefined, user: MaybeUser | null | undefined,

View file

@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { useSeoMeta } from '@unhead/react'; import { useSeoMeta } from '@unhead/react';
import { useParams, Link, useSearchParams } from 'react-router-dom'; import { useParams, Link, useSearchParams } from 'react-router-dom';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
@ -71,6 +71,10 @@ export default function CrewDetailPage() {
: null; : null;
const [activeTab, setActiveTab] = useState(validTab ?? 'events'); const [activeTab, setActiveTab] = useState(validTab ?? 'events');
const tabTouchedRef = useRef(Boolean(validTab)); 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 // 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. // already holds the group — used to default the tab and to nudge the founder.
@ -362,6 +366,16 @@ export default function CrewDetailPage() {
<TabsTrigger value="mls" className="min-w-0 gap-1.5 overflow-hidden text-[13px] data-[state=active]:bg-teal-500/20 data-[state=active]:text-teal-300"> <TabsTrigger value="mls" className="min-w-0 gap-1.5 overflow-hidden text-[13px] data-[state=active]:bg-teal-500/20 data-[state=active]:text-teal-300">
<Lock className="h-4 w-4 shrink-0" />{' '} <Lock className="h-4 w-4 shrink-0" />{' '}
<span className="min-w-0 truncate">{t('cordnUi.title')}</span> <span className="min-w-0 truncate">{t('cordnUi.title')}</span>
{(cordnRequests > 0 || cordnUnread) && (
<span
className={
cordnRequests > 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')}
/>
)}
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
@ -398,8 +412,19 @@ export default function CrewDetailPage() {
</TabsContent> </TabsContent>
{/* Private chat — native Cordn / MLS (real end-to-end encryption). */} {/* Private chat — native Cordn / MLS (real end-to-end encryption). */}
<TabsContent value="mls" className="mt-4"> <TabsContent
<CordnNativePanel crew={crew} canManage={canManage} isMember={isCrewMember} /> value="mls"
forceMount
className={activeTab === 'mls' ? 'mt-4' : 'mt-4 hidden'}
>
<CordnNativePanel
crew={crew}
canManage={canManage}
isMember={isCrewMember}
active={activeTab === 'mls'}
onUnreadChange={handleCordnUnread}
onRequestsChange={handleCordnRequests}
/>
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</div> </div>