diff --git a/README.md b/README.md index d62a1c7..adf846b 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,13 @@ Known gaps, tracked rather than hidden: is impossible for arbitrary self-hosted hosts. `softtrack://invite/` works, and falls back to asking you to sign in first, since a custom-scheme link carries no instance. +- **Push notifications are not built, because the API has no way to register a + device.** Issue #10 anticipated this ("requires a backend addition for device + token registration + push delivery"). There is no `/devices` endpoint and + nothing APNs- or FCM-shaped anywhere in the schema, so the in-app inbox and + the unread badge are what ship; the badge polls once a minute while the app is + in front rather than pretending to be pushed. Real push also needs a dev build + and signing credentials, neither of which exists yet. - **Member counts cost one request per team.** `TeamRead` carries no count, so the teams list asks each team for its members. A `member_count` field upstream would remove the fan-out and help the web too. diff --git a/src/api/__tests__/live-notifications.test.ts b/src/api/__tests__/live-notifications.test.ts new file mode 100644 index 0000000..d5ad3d7 --- /dev/null +++ b/src/api/__tests__/live-notifications.test.ts @@ -0,0 +1,164 @@ +/** + * @jest-environment node + */ +import { + loginAuthLoginPost, + registerAuthRegisterPost, +} from '@/api/generated/endpoints/auth/auth'; +import { createCommentIssuesIssueIdCommentsPost } from '@/api/generated/endpoints/comments/comments'; +import { + createIssueTeamsTeamIdIssuesPost, + deleteIssueIssuesIssueIdDelete, + updateIssueIssuesIssueIdPatch, +} from '@/api/generated/endpoints/issues/issues'; +import { + listNotificationsNotificationsGet, + markAllReadNotificationsReadAllPost, + unreadCountNotificationsUnreadCountGet, + updateNotificationNotificationsNotificationIdPatch, +} from '@/api/generated/endpoints/notifications/notifications'; +import { createInviteTeamsTeamIdInvitesPost } from '@/api/generated/endpoints/invites/invites'; +import { acceptInviteInvitesTokenAcceptPost } from '@/api/generated/endpoints/invites/invites'; +import { listMyTeamsTeamsGet } from '@/api/generated/endpoints/teams/teams'; +import type { TeamRead } from '@/api/generated/models'; +import { setInstanceUrl } from '@/api/instance'; +import { persistToken } from '@/auth/session'; + +/** + * The inbox against a real instance. + * + * Needs two accounts: the acceptance criterion says no notifications for your + * own actions, which cannot be shown with one. + */ +const LIVE_URL = process.env.SOFTTRACK_LIVE_URL; +const describeLive = LIVE_URL ? describe : describe.skip; + +const RUN = Date.now().toString(36).slice(-5); +const DEMO_EMAIL = process.env.SOFTTRACK_LIVE_EMAIL ?? 'demo@softtrack.dev'; +const DEMO_PASSWORD = process.env.SOFTTRACK_LIVE_PASSWORD ?? 'password123'; +const OTHER_EMAIL = `mob-notify-${RUN}@example.com`; + +describeLive('notifications against a live instance', () => { + let team: TeamRead; + let demoToken: string; + let otherToken: string; + let otherUsername: string; + let otherId: number; + const issues: number[] = []; + + beforeAll(async () => { + await setInstanceUrl(LIVE_URL!); + const demo = await loginAuthLoginPost({ + username: DEMO_EMAIL, + password: DEMO_PASSWORD, + }); + demoToken = demo.access_token; + await persistToken(demoToken); + team = (await listMyTeamsTeamsGet())[0]; + + const other = await registerAuthRegisterPost({ + email: OTHER_EMAIL, + password: 'password123', + full_name: 'Notify Tester', + }); + otherToken = other.access_token; + otherUsername = other.user.username; + otherId = other.user.id; + + // The second account has to be on the team to be assignable or mentionable. + await persistToken(demoToken); + const invite = await createInviteTeamsTeamIdInvitesPost(team.id, { + email: OTHER_EMAIL, + role: 'member', + }); + await persistToken(otherToken); + await acceptInviteInvitesTokenAcceptPost(invite.token); + }); + + afterAll(async () => { + await persistToken(demoToken); + for (const id of issues) await deleteIssueIssuesIssueIdDelete(id).catch(() => undefined); + }); + + it('says nothing about your own actions', async () => { + await persistToken(otherToken); + await markAllReadNotificationsReadAllPost(); + const before = await unreadCountNotificationsUnreadCountGet(); + + // The other account acts on its own issue, entirely by itself. + const own = await createIssueTeamsTeamIdIssuesPost(team.id, { + title: `Own action ${RUN}`, + assignee_id: otherId, + }); + issues.push(own.id); + await createCommentIssuesIssueIdCommentsPost(own.id, { body: 'talking to myself' }); + + const after = await unreadCountNotificationsUnreadCountGet(); + expect(after.unread).toBe(before.unread); + }); + + it('raises one when somebody assigns you', async () => { + await persistToken(demoToken); + const issue = await createIssueTeamsTeamIdIssuesPost(team.id, { + title: `Assign ${RUN}`, + }); + issues.push(issue.id); + await updateIssueIssuesIssueIdPatch(issue.id, { assignee_id: otherId }); + + await persistToken(otherToken); + const page = await listNotificationsNotificationsGet({ unread_only: true, limit: 25 }); + const hit = page.items.find((n) => n.issue.id === issue.id); + expect(hit).toBeDefined(); + expect(hit?.kind).toBe('assigned'); + // Both are what the row renders. + expect(hit?.actor?.full_name).toEqual(expect.any(String)); + expect(hit?.issue.identifier).toContain(team.key); + }); + + it('raises one when somebody mentions you', async () => { + await persistToken(demoToken); + const issue = await createIssueTeamsTeamIdIssuesPost(team.id, { + title: `Mention ${RUN}`, + }); + issues.push(issue.id); + await createCommentIssuesIssueIdCommentsPost(issue.id, { + body: `hey @${otherUsername} take a look`, + }); + + await persistToken(otherToken); + const page = await listNotificationsNotificationsGet({ unread_only: true, limit: 25 }); + const hit = page.items.find((n) => n.issue.id === issue.id); + expect(hit?.kind).toBe('mentioned'); + }); + + it('marks one read, and then all of them', async () => { + await persistToken(otherToken); + const unreadBefore = await unreadCountNotificationsUnreadCountGet(); + expect(unreadBefore.unread).toBeGreaterThan(0); + + const page = await listNotificationsNotificationsGet({ unread_only: true, limit: 1 }); + const one = page.items[0]; + await updateNotificationNotificationsNotificationIdPatch(one.id, { read: true }); + + const afterOne = await unreadCountNotificationsUnreadCountGet(); + expect(afterOne.unread).toBe(unreadBefore.unread - 1); + + await markAllReadNotificationsReadAllPost(); + expect((await unreadCountNotificationsUnreadCountGet()).unread).toBe(0); + // And the unread filter is genuinely a filter, not a styling choice. + const unreadOnly = await listNotificationsNotificationsGet({ + unread_only: true, + limit: 25, + }); + expect(unreadOnly.items).toEqual([]); + }); + + it('pages the full list', async () => { + await persistToken(otherToken); + const all = await listNotificationsNotificationsGet({ limit: 25 }); + expect(all.total).toBeGreaterThan(0); + const firstOnly = await listNotificationsNotificationsGet({ limit: 1, offset: 0 }); + expect(firstOnly.items).toHaveLength(1); + expect(firstOnly.total).toBe(all.total); + }); +}); diff --git a/src/app/(app)/inbox.tsx b/src/app/(app)/inbox.tsx index 59408ec..650da8f 100644 --- a/src/app/(app)/inbox.tsx +++ b/src/app/(app)/inbox.tsx @@ -1,5 +1 @@ -import { Placeholder } from '@/ui/placeholder'; - -export default function InboxRoute() { - return ; -} +export { InboxScreen as default } from '@/notifications/inbox-screen'; diff --git a/src/notifications/inbox-screen.tsx b/src/notifications/inbox-screen.tsx new file mode 100644 index 0000000..dcd74e3 --- /dev/null +++ b/src/notifications/inbox-screen.tsx @@ -0,0 +1,252 @@ +import { useState } from 'react'; +import { FlatList, Pressable, StyleSheet, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { router } from 'expo-router'; +import { useQueryClient } from '@tanstack/react-query'; + +import { + markAllReadNotificationsReadAllPost, + updateNotificationNotificationsNotificationIdPatch, + useListNotificationsNotificationsGet, +} from '@/api/generated/endpoints/notifications/notifications'; +import type { NotificationKind, NotificationRead } from '@/api/generated/models'; +import { errorDetail } from '@/api/errors'; +import { href } from '@/ui/href'; +import { Icon } from '@/ui/icon'; +import { useIsMultiPane } from '@/ui/layout'; +import { Alert, AppText, Avatar, Card, Loading } from '@/ui/primitives'; +import { useTokens } from '@/ui/theme'; + +/** + * The inbox, per `docs/design/mobile/147-notifications.svg`. + * + * Four kinds, and each one needs a different sentence -- "commented on" and + * "mentioned you in" are not the same event even though both are a comment. + */ +const PAGE = 25; + +function sentenceFor(notification: NotificationRead): string { + const who = notification.actor?.full_name ?? 'Someone'; + const kind: NotificationKind = notification.kind; + switch (kind) { + case 'assigned': + return `${who} assigned you`; + case 'mentioned': + return `${who} mentioned you`; + case 'commented': + return `${who} commented`; + case 'status_changed': + return `${who} changed the status`; + default: + return who; + } +} + +export function InboxScreen() { + const t = useTokens(); + const multiPane = useIsMultiPane(); + const queryClient = useQueryClient(); + + const [unreadOnly, setUnreadOnly] = useState(false); + const [limit, setLimit] = useState(PAGE); + const [error, setError] = useState(null); + + const notifications = useListNotificationsNotificationsGet({ + unread_only: unreadOnly || undefined, + limit, + offset: 0, + }); + + const items = notifications.data?.items ?? []; + const total = notifications.data?.total ?? 0; + + async function refresh() { + await queryClient.invalidateQueries({ queryKey: ['/notifications'] }); + // The tab badge reads its own endpoint, so it has to be told too. + void queryClient.invalidateQueries({ queryKey: ['/notifications/unread-count'] }); + } + + async function open(notification: NotificationRead) { + // Marked read on the way out rather than on arrival: opening it is what + // says it has been seen. + if (!notification.read) { + try { + await updateNotificationNotificationsNotificationIdPatch(notification.id, { + read: true, + }); + await refresh(); + } catch { + // Navigating still matters more than the read flag. + } + } + router.push(href(`/issue/${notification.issue.id}`)); + } + + return ( + + + + Inbox + + + setUnreadOnly((current) => !current)} + style={{ + paddingHorizontal: 10, + paddingVertical: 6, + borderRadius: t.radius.pill, + backgroundColor: unreadOnly ? t.line.navActive : t.line.ghost, + }} + > + + Unread + + + + { + setError(null); + try { + await markAllReadNotificationsReadAllPost(); + await refresh(); + } catch (err) { + setError(errorDetail(err, 'Could not mark everything read.')); + } + }} + > + + + + + {notifications.isPending ? ( + + ) : ( + String(item.id)} + contentContainerStyle={{ padding: 16, gap: 8 }} + ListHeaderComponent={error ? {error} : null} + ListEmptyComponent={ + + + {unreadOnly ? 'Nothing unread' : 'Nothing yet'} + + + You are told when you are assigned, mentioned, or when work you + watch moves. + + + } + ListFooterComponent={ + total > items.length ? ( + setLimit((current) => current + PAGE)} + style={{ + paddingVertical: 12, + alignItems: 'center', + borderRadius: t.radius.control, + borderWidth: StyleSheet.hairlineWidth, + borderColor: t.line.hairline, + }} + > + + Show more + + + ) : null + } + renderItem={({ item }) => ( + void open(item)} + > + + {item.actor ? ( + + ) : ( + + )} + + + + {sentenceFor(item)} + + + {item.issue.identifier} ยท {item.issue.title} + + {item.excerpt ? ( + + {item.excerpt} + + ) : null} + + + {!item.read ? ( + + ) : null} + + + )} + refreshing={notifications.isFetching && !notifications.isPending} + onRefresh={refresh} + /> + )} + + ); +} diff --git a/src/ui/__tests__/tab-bar.test.tsx b/src/ui/__tests__/tab-bar.test.tsx index 6848221..4b127a9 100644 --- a/src/ui/__tests__/tab-bar.test.tsx +++ b/src/ui/__tests__/tab-bar.test.tsx @@ -2,6 +2,7 @@ import type * as React from 'react'; import { render } from '@testing-library/react-native'; import { StyleSheet } from 'react-native'; import { SafeAreaProvider } from 'react-native-safe-area-context'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { RAIL_WIDTH, SoftTrackTabBar } from '@/ui/tab-bar'; import { ThemeProvider } from '@/ui/theme'; @@ -44,11 +45,17 @@ function tabBarProps(focusedIndex = 0): BarProps { } function renderBar(orientation: 'bar' | 'rail', focusedIndex = 0) { + // The bar polls the unread count for the inbox badge, so it needs a client. + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }); return render( - - - + + + + + , ); } diff --git a/src/ui/tab-bar.tsx b/src/ui/tab-bar.tsx index 391b099..6c46d34 100644 --- a/src/ui/tab-bar.tsx +++ b/src/ui/tab-bar.tsx @@ -2,6 +2,7 @@ import { Pressable, StyleSheet, Text, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import type { BottomTabBarProps } from 'expo-router/js-tabs'; +import { useUnreadCountNotificationsUnreadCountGet } from '@/api/generated/endpoints/notifications/notifications'; import { Icon, type IconName } from '@/ui/icon'; import { useTokens } from '@/ui/theme'; @@ -37,6 +38,19 @@ export function SoftTrackTabBar({ const insets = useSafeAreaInsets(); const rail = orientation === 'rail'; + /** + * The unread badge. + * + * Polled rather than pushed, at the same minute the web uses, and only while + * the app is in front -- there is no realtime channel, and a phone that polls + * in the background is a phone with a flat battery. Real push needs a backend + * that can register device tokens; see the README. + */ + const unread = useUnreadCountNotificationsUnreadCountGet({ + query: { refetchInterval: 60_000, refetchIntervalInBackground: false }, + }); + const unreadCount = unread.data?.unread ?? 0; + return ( + + {route.name === 'inbox' && unreadCount > 0 ? ( + + + {unreadCount > 99 ? '99+' : unreadCount} + + + ) : null}