feat(cordn): unread and join-request badges; persist pending joins
This commit is contained in:
parent
2cec2b4782
commit
7da67cc2c0
8 changed files with 303 additions and 20 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
36
src/lib/cordn/pendingJoin.test.ts
Normal file
36
src/lib/cordn/pendingJoin.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
44
src/lib/cordn/pendingJoin.ts
Normal file
44
src/lib/cordn/pendingJoin.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/lib/cordn/unread.test.ts
Normal file
28
src/lib/cordn/unread.test.ts
Normal 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
41
src/lib/cordn/unread.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue