From 1c07c6251f6eded97ed35a3ff3d036affe9a59fd Mon Sep 17 00:00:00 2001 From: scotej <134114466+scotej@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:34:14 +1000 Subject: [PATCH] =?UTF-8?q?feat(friends):=20persistent=20pending-invite=20?= =?UTF-8?q?rows=20=E2=80=94=20invites=20outlive=20the=20toast?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Invite envelopes are valid 5 minutes, but the only accept affordance was a default-duration sonner toast; a recipient tabbed away missed it and the invite was unrecoverable on their side while the host saw 'Invite sent'. - pendingInvitesStore (features/friends, alertsUiStore precedent) holds each ValidInvite keyed by sender+session (re-sends replace, F6 retry path) until expiry/dismiss/accept - PendingInvites(View) renders calm rows on the main view with a countdown, Accept (funnels through the same topic-gated accept path as the toast) and Dismiss - runGuestJoin re-checks expires_at at accept time — a stale row can't join a dead session — and removes the entry once a join proceeds; refusing while in a session keeps the row for after leaving Fixes #47 item B1. Co-Authored-By: Claude Fable 5 --- src/features/friends/InboxBoot.tsx | 5 + src/features/friends/PendingInvites.tsx | 132 ++++++++++++++++++++ src/features/friends/index.ts | 11 ++ src/features/friends/pendingInvitesStore.ts | 58 +++++++++ src/routes/Home.tsx | 19 ++- src/stories/PendingInvites.stories.tsx | 59 +++++++++ src/strings.ts | 10 ++ tests/unit/pending-invites-store.test.ts | 66 ++++++++++ 8 files changed, 359 insertions(+), 1 deletion(-) create mode 100644 src/features/friends/PendingInvites.tsx create mode 100644 src/features/friends/pendingInvitesStore.ts create mode 100644 src/stories/PendingInvites.stories.tsx create mode 100644 tests/unit/pending-invites-store.test.ts diff --git a/src/features/friends/InboxBoot.tsx b/src/features/friends/InboxBoot.tsx index 54928eb..0abe7ff 100644 --- a/src/features/friends/InboxBoot.tsx +++ b/src/features/friends/InboxBoot.tsx @@ -16,6 +16,7 @@ import { strings } from '@/strings' import { notifyFriendOnline } from './friendOnlineNotify' import { subscribeToOwnInbox, type ValidInvite } from './inbox' +import { usePendingInvitesStore } from './pendingInvitesStore' import { inviteRetryManager } from './invite' import { isOnline, startPresence, type PresenceMap } from './presence' @@ -197,6 +198,10 @@ async function handleValidInvite( strings.friends.inbox.senderFallback const message = strings.friends.inbox.inviteBody(senderName) + // #47 B1 — hold the invite on the persistent main-view surface for its + // full 5-minute validity; the toast below is just the immediate nudge. + usePendingInvitesStore.getState().add(invite) + toast(message, { action: { label: strings.friends.inbox.acceptAction, diff --git a/src/features/friends/PendingInvites.tsx b/src/features/friends/PendingInvites.tsx new file mode 100644 index 0000000..888bd8e --- /dev/null +++ b/src/features/friends/PendingInvites.tsx @@ -0,0 +1,132 @@ +import { useEffect, useState } from 'react' +import { MailIcon } from 'lucide-react' + +import { Button } from '@/components/ui/button' +import { tokens } from '@/design/tokens' +import { strings } from '@/strings' + +import type { ValidInvite } from './inbox' +import { + usePendingInvitesStore, + type PendingInviteEntry, +} from './pendingInvitesStore' + +// #47 B1 — the persistent accept surface for incoming invites. The sonner +// toast (InboxBoot) stays as the immediate affordance; these rows survive on +// the main view until the envelope's expires_at passes, so a recipient who +// was tabbed away for a minute still sees and can accept the invite. + +export type PendingInvitesViewProps = { + entries: ReadonlyArray + now: number + onAccept: (entry: PendingInviteEntry) => void + onDismiss: (entry: PendingInviteEntry) => void +} + +function senderName(invite: ValidInvite): string { + return ( + invite.payload.our_display_name?.trim() || + strings.friends.inbox.senderFallback + ) +} + +export function PendingInvitesView({ + entries, + now, + onAccept, + onDismiss, +}: PendingInvitesViewProps) { + if (entries.length === 0) return null + return ( +
+
    + {entries.map((entry) => { + const name = senderName(entry.invite) + const minutesLeft = Math.max( + 1, + Math.ceil((entry.invite.payload.expires_at - now) / 60_000) + ) + return ( +
  • + + + + + + +
  • + ) + })} +
+
+ ) +} + +export type PendingInvitesProps = { + onAccept: (invite: ValidInvite) => void +} + +export function PendingInvites({ onAccept }: PendingInvitesProps) { + const pending = usePendingInvitesStore((s) => s.pending) + const [now, setNow] = useState(() => Date.now()) + + // Refresh the countdown + drop expired rows on a slow tick, only while + // anything is pending. Acceptance-time expiry is separately re-checked in + // Home's runGuestJoin, so a stale row can never join a dead session. + useEffect(() => { + if (pending.length === 0) return + const id = setInterval(() => { + setNow(Date.now()) + usePendingInvitesStore.getState().prune() + }, 10_000) + return () => clearInterval(id) + }, [pending.length]) + + return ( + onAccept(entry.invite)} + onDismiss={(entry) => usePendingInvitesStore.getState().remove(entry.key)} + /> + ) +} diff --git a/src/features/friends/index.ts b/src/features/friends/index.ts index 5f1c397..3367883 100644 --- a/src/features/friends/index.ts +++ b/src/features/friends/index.ts @@ -20,6 +20,17 @@ export { export { FriendsList, type FriendsListProps } from './FriendsList' export { FriendsListView, type FriendsListViewProps } from './FriendsListView' export { InboxBoot, type InboxBootProps } from './InboxBoot' +export { + PendingInvites, + PendingInvitesView, + type PendingInvitesProps, + type PendingInvitesViewProps, +} from './PendingInvites' +export { + pendingInviteKey, + usePendingInvitesStore, + type PendingInviteEntry, +} from './pendingInvitesStore' export { PairDeepLinkBoot, type PairDeepLinkBootProps, diff --git a/src/features/friends/pendingInvitesStore.ts b/src/features/friends/pendingInvitesStore.ts new file mode 100644 index 0000000..19b9d86 --- /dev/null +++ b/src/features/friends/pendingInvitesStore.ts @@ -0,0 +1,58 @@ +import { create } from 'zustand' + +import type { ValidInvite } from './inbox' + +// #47 B1 — a persistent home for incoming invites. Envelopes are valid for +// INVITE_TTL_MS (5 min) but the only accept affordance was a ~4s sonner +// toast; a recipient who was tabbed away missed it and the invite was +// unrecoverable on their side while the host saw "Invite sent". Each valid +// invite is held here until it expires, is dismissed, or is accepted; +// PendingInvites renders the list as rows on the main view. Lives in +// features/friends (the alertsUiStore precedent), not src/stores — stores/ +// modules must not import feature types. + +export type PendingInviteEntry = { + key: string + invite: ValidInvite + receivedAt: number +} + +// One row per sender+session: a re-sent invite for the same session (the F6 +// retry path) replaces the earlier entry instead of stacking duplicates. +export function pendingInviteKey(invite: ValidInvite): string { + return `${invite.from_ed_pubkey}:${invite.payload.session_topic}` +} + +type PendingInvitesState = { + pending: PendingInviteEntry[] + add: (invite: ValidInvite, now?: number) => void + remove: (key: string) => void + // Drop entries whose expires_at has passed. Called on a slow interval by + // the banner while anything is pending. + prune: (now?: number) => void + clear: () => void +} + +export const usePendingInvitesStore = create((set) => ({ + pending: [], + add: (invite, now = Date.now()) => + set((s) => { + const key = pendingInviteKey(invite) + const kept = s.pending.filter( + (e) => e.key !== key && e.invite.payload.expires_at > now + ) + return { pending: [...kept, { key, invite, receivedAt: now }] } + }), + remove: (key) => + set((s) => + s.pending.some((e) => e.key === key) + ? { pending: s.pending.filter((e) => e.key !== key) } + : s + ), + prune: (now = Date.now()) => + set((s) => { + const kept = s.pending.filter((e) => e.invite.payload.expires_at > now) + return kept.length === s.pending.length ? s : { pending: kept } + }), + clear: () => set({ pending: [] }), +})) diff --git a/src/routes/Home.tsx b/src/routes/Home.tsx index bcd937e..ffb00df 100644 --- a/src/routes/Home.tsx +++ b/src/routes/Home.tsx @@ -29,6 +29,9 @@ import { InviteRelayError, InviteTimeoutError, PairDeepLinkBoot, + PendingInvites, + pendingInviteKey, + usePendingInvitesStore, type ContactImportSource, type PresenceMap, } from '@/features/friends' @@ -137,11 +140,21 @@ export function Home() { const runGuestJoin = useCallback((invite: ValidInvite) => { // Joining while already in a session would tear down the existing one; // refuse — the user explicitly leaves first. (Moved here from InboxBoot - // so the gate + guard share one decision point.) + // so the gate + guard share one decision point.) The invite stays on the + // #47 B1 pending surface for after they leave. if (useSessionStore.getState().status === 'active') { toast.error(strings.errors.leaveSessionFirst) return } + // #47 B1 — re-check expiry at accept time: the toast/banner row may be + // minutes old, and joining a dead session would strand the user on a + // waiting tile. + if (invite.payload.expires_at <= Date.now()) { + usePendingInvitesStore.getState().remove(pendingInviteKey(invite)) + toast.error(strings.friends.inbox.pending.expired) + return + } + usePendingInvitesStore.getState().remove(pendingInviteKey(invite)) try { joinSession(invite.payload.session_topic, invite.payload.session_password) } catch (err) { @@ -369,6 +382,10 @@ export function Home() { {strings.settings.heading} + {/* #47 B1 — pending incoming invites persist here for their full + 5-minute validity; accepting funnels through the same topic-gated + path as the toast. */} + setAddOpen(true)} diff --git a/src/stories/PendingInvites.stories.tsx b/src/stories/PendingInvites.stories.tsx new file mode 100644 index 0000000..90a08a5 --- /dev/null +++ b/src/stories/PendingInvites.stories.tsx @@ -0,0 +1,59 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' + +import { PendingInvitesView } from '@/features/friends' +import type { PendingInviteEntry } from '@/features/friends' + +// #47 B1 — persistent pending-invite rows on the main view (pure view; +// store + expiry wiring live in the PendingInvites container). + +const NOW = 1_700_000_000_000 + +function entry( + seed: string, + name: string | null, + msLeft: number +): PendingInviteEntry { + return { + key: `${seed}:topic-${seed}`, + receivedAt: NOW - 30_000, + invite: { + from_ed_pubkey: seed.repeat(64).slice(0, 64), + payload: { + session_topic: `topic-${seed}`, + session_password: 'pw', + our_display_name: name ?? '', + expires_at: NOW + msLeft, + sig: '', + }, + }, + } +} + +const meta = { + title: 'Friends/PendingInvites', + component: PendingInvitesView, + args: { + now: NOW, + onAccept: () => {}, + onDismiss: () => {}, + entries: [entry('a', 'Alex', 4 * 60_000), entry('b', null, 90_000)], + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +// Two pending invites: a named sender with minutes left and a fallback-named +// sender about to expire. +export const TwoPending: Story = {} + +export const SingleInvite: Story = { + args: { + entries: [entry('a', 'Alex', 3 * 60_000)], + }, +} + +// Renders nothing — the section only exists while invites are pending. +export const Empty: Story = { + args: { entries: [] }, +} diff --git a/src/strings.ts b/src/strings.ts index 193bb4e..5ae3f63 100644 --- a/src/strings.ts +++ b/src/strings.ts @@ -425,6 +425,16 @@ export const strings = { senderFallback: 'A friend', inviteBody: (name: string) => `${name} invites you to study`, acceptAction: 'Accept', + // #47 B1 — persistent pending-invite rows on the main view (invites + // are valid 5 minutes; the toast alone was missable). + pending: { + listAriaLabel: 'Pending invites', + expiresIn: (min: number) => `Expires in ${min} min`, + dismissCta: 'Dismiss', + dismissAriaLabel: (name: string) => `Dismiss the invite from ${name}`, + acceptAriaLabel: (name: string) => `Accept the invite from ${name}`, + expired: 'That invite expired. Ask your friend to invite you again.', + }, }, inviteSent: (name: string) => `Invite sent to ${name}.`, inviteSendErrorFallback: "Couldn't send the invite.", diff --git a/tests/unit/pending-invites-store.test.ts b/tests/unit/pending-invites-store.test.ts new file mode 100644 index 0000000..941e8d2 --- /dev/null +++ b/tests/unit/pending-invites-store.test.ts @@ -0,0 +1,66 @@ +import { beforeEach, describe, expect, test } from 'vitest' + +import type { ValidInvite } from '@/features/friends' +import { pendingInviteKey, usePendingInvitesStore } from '@/features/friends' + +const NOW = 1_700_000_000_000 + +function invite(sender: string, topic: string, expiresAt: number): ValidInvite { + return { + from_ed_pubkey: sender, + payload: { + session_topic: topic, + session_password: 'pw', + our_display_name: 'Alex', + expires_at: expiresAt, + sig: '00', + }, + } +} + +describe('pendingInvitesStore (#47 B1)', () => { + beforeEach(() => { + usePendingInvitesStore.getState().clear() + }) + + test('add holds an invite until its expiry', () => { + usePendingInvitesStore.getState().add(invite('a', 't1', NOW + 60_000), NOW) + expect(usePendingInvitesStore.getState().pending).toHaveLength(1) + }) + + test('a re-sent invite for the same sender+session replaces, not stacks', () => { + const store = usePendingInvitesStore.getState() + store.add(invite('a', 't1', NOW + 60_000), NOW) + store.add(invite('a', 't1', NOW + 120_000), NOW + 1_000) + const pending = usePendingInvitesStore.getState().pending + expect(pending).toHaveLength(1) + expect(pending[0].invite.payload.expires_at).toBe(NOW + 120_000) + }) + + test('distinct senders and sessions coexist', () => { + const store = usePendingInvitesStore.getState() + store.add(invite('a', 't1', NOW + 60_000), NOW) + store.add(invite('b', 't2', NOW + 60_000), NOW) + expect(usePendingInvitesStore.getState().pending).toHaveLength(2) + }) + + test('prune drops only expired entries', () => { + const store = usePendingInvitesStore.getState() + store.add(invite('a', 't1', NOW + 10_000), NOW) + store.add(invite('b', 't2', NOW + 120_000), NOW) + usePendingInvitesStore.getState().prune(NOW + 30_000) + const pending = usePendingInvitesStore.getState().pending + expect(pending).toHaveLength(1) + expect(pending[0].key).toBe(pendingInviteKey(invite('b', 't2', 0))) + }) + + test('remove deletes by key; unknown keys are a no-op', () => { + const store = usePendingInvitesStore.getState() + const inv = invite('a', 't1', NOW + 60_000) + store.add(inv, NOW) + usePendingInvitesStore.getState().remove('nope:nope') + expect(usePendingInvitesStore.getState().pending).toHaveLength(1) + usePendingInvitesStore.getState().remove(pendingInviteKey(inv)) + expect(usePendingInvitesStore.getState().pending).toHaveLength(0) + }) +})