Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,13 @@ Known gaps, tracked rather than hidden:
is impossible for arbitrary self-hosted hosts. `softtrack://invite/<token>`
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.
Expand Down
164 changes: 164 additions & 0 deletions src/api/__tests__/live-notifications.test.ts
Original file line number Diff line number Diff line change
@@ -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';

Check warning on line 20 in src/api/__tests__/live-notifications.test.ts

View workflow job for this annotation

GitHub Actions / Mobile · lint + typecheck + test + bundle

'/home/runner/work/mobile/mobile/src/api/generated/endpoints/invites/invites.ts' imported multiple times
import { acceptInviteInvitesTokenAcceptPost } from '@/api/generated/endpoints/invites/invites';

Check warning on line 21 in src/api/__tests__/live-notifications.test.ts

View workflow job for this annotation

GitHub Actions / Mobile · lint + typecheck + test + bundle

'/home/runner/work/mobile/mobile/src/api/generated/endpoints/invites/invites.ts' imported multiple times
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);
});
});
6 changes: 1 addition & 5 deletions src/app/(app)/inbox.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1 @@
import { Placeholder } from '@/ui/placeholder';

export default function InboxRoute() {
return <Placeholder title="Inbox" issue={10} />;
}
export { InboxScreen as default } from '@/notifications/inbox-screen';
Loading
Loading