diff --git a/packages/bsky/src/api/app/bsky/notification/listNotifications.ts b/packages/bsky/src/api/app/bsky/notification/listNotifications.ts index 6058abe75af..8f4fa2f073e 100644 --- a/packages/bsky/src/api/app/bsky/notification/listNotifications.ts +++ b/packages/bsky/src/api/app/bsky/notification/listNotifications.ts @@ -43,6 +43,56 @@ export default function (server: Server, ctx: AppContext) { }) } +const paginateNotifications = async (opts: { + ctx: Context + priority: boolean + reasons?: string[] + cursor?: string + limit: number + viewer: string +}) => { + const { ctx, priority, reasons, limit, viewer } = opts + + // if not filtering, then just pass through the response from dataplane + if (!reasons) { + const res = await ctx.hydrator.dataplane.getNotifications({ + actorDid: viewer, + priority, + cursor: opts.cursor, + limit, + }) + return { + notifications: res.notifications, + cursor: res.cursor, + } + } + + let nextCursor: string | undefined = opts.cursor + let toReturn: Notification[] = [] + const maxAttempts = 10 + const attemptSize = Math.ceil(limit / 2) + for (let i = 0; i < maxAttempts; i++) { + const res = await ctx.hydrator.dataplane.getNotifications({ + actorDid: viewer, + priority, + cursor: nextCursor, + limit, + }) + const filtered = res.notifications.filter((notif) => + reasons.includes(notif.reason), + ) + toReturn = [...toReturn, ...filtered] + nextCursor = res.cursor ?? undefined + if (toReturn.length >= attemptSize || !nextCursor) { + break + } + } + return { + notifications: toReturn, + cursor: nextCursor, + } +} + const skeleton = async ( input: SkeletonFnInput, ): Promise => { @@ -56,11 +106,13 @@ const skeleton = async ( return { notifs: [], priority } } const [res, lastSeenRes] = await Promise.all([ - ctx.hydrator.dataplane.getNotifications({ - actorDid: viewer, + paginateNotifications({ + ctx, priority, + reasons: params.reasons, cursor: params.cursor, limit: params.limit, + viewer, }), ctx.hydrator.dataplane.getNotificationSeen({ actorDid: viewer, diff --git a/packages/bsky/tests/views/__snapshots__/notifications.test.ts.snap b/packages/bsky/tests/views/__snapshots__/notifications.test.ts.snap index 385604e1faa..2e87ea241cf 100644 --- a/packages/bsky/tests/views/__snapshots__/notifications.test.ts.snap +++ b/packages/bsky/tests/views/__snapshots__/notifications.test.ts.snap @@ -1263,6 +1263,263 @@ Array [ ] `; +exports[`notification views filters notifications by multiple reasons 1`] = ` +Object { + "notifications": Array [ + Object { + "author": Object { + "did": "user(0)", + "handle": "carol.test", + "labels": Array [], + "viewer": Object { + "blockedBy": false, + "followedBy": "record(2)", + "following": "record(1)", + "muted": false, + }, + }, + "cid": "cids(0)", + "indexedAt": "1970-01-01T00:00:00.000Z", + "isRead": false, + "labels": Array [], + "reason": "reply", + "reasonSubject": "record(3)", + "record": Object { + "$type": "app.bsky.feed.post", + "createdAt": "1970-01-01T00:00:00.000Z", + "reply": Object { + "parent": Object { + "cid": "cids(2)", + "uri": "record(3)", + }, + "root": Object { + "cid": "cids(1)", + "uri": "record(4)", + }, + }, + "text": "indeed", + }, + "uri": "record(0)", + }, + Object { + "author": Object { + "did": "user(0)", + "handle": "carol.test", + "labels": Array [], + "viewer": Object { + "blockedBy": false, + "followedBy": "record(2)", + "following": "record(1)", + "muted": false, + }, + }, + "cid": "cids(3)", + "indexedAt": "1970-01-01T00:00:00.000Z", + "isRead": false, + "labels": Array [], + "reason": "reply", + "reasonSubject": "record(4)", + "record": Object { + "$type": "app.bsky.feed.post", + "createdAt": "1970-01-01T00:00:00.000Z", + "reply": Object { + "parent": Object { + "cid": "cids(1)", + "uri": "record(4)", + }, + "root": Object { + "cid": "cids(1)", + "uri": "record(4)", + }, + }, + "text": "of course", + }, + "uri": "record(5)", + }, + Object { + "author": Object { + "avatar": "https://bsky.public.url/img/avatar/plain/user(2)/cids(5)@jpeg", + "createdAt": "1970-01-01T00:00:00.000Z", + "description": "hi im bob label_me", + "did": "user(1)", + "displayName": "bobby", + "handle": "bob.test", + "indexedAt": "1970-01-01T00:00:00.000Z", + "labels": Array [], + "viewer": Object { + "blockedBy": false, + "followedBy": "record(8)", + "following": "record(7)", + "muted": false, + }, + }, + "cid": "cids(4)", + "indexedAt": "1970-01-01T00:00:00.000Z", + "isRead": false, + "labels": Array [ + Object { + "cid": "cids(4)", + "cts": "1970-01-01T00:00:00.000Z", + "src": "did:example:labeler", + "uri": "record(6)", + "val": "test-label", + }, + Object { + "cid": "cids(4)", + "cts": "1970-01-01T00:00:00.000Z", + "src": "did:example:labeler", + "uri": "record(6)", + "val": "test-label-2", + }, + ], + "reason": "reply", + "reasonSubject": "record(4)", + "record": Object { + "$type": "app.bsky.feed.post", + "createdAt": "1970-01-01T00:00:00.000Z", + "embed": Object { + "$type": "app.bsky.embed.images", + "images": Array [ + Object { + "alt": "../dev-env/src/seed/img/key-landscape-small.jpg", + "image": Object { + "$type": "blob", + "mimeType": "image/jpeg", + "ref": Object { + "$link": "cids(6)", + }, + "size": 4114, + }, + }, + ], + }, + "reply": Object { + "parent": Object { + "cid": "cids(1)", + "uri": "record(4)", + }, + "root": Object { + "cid": "cids(1)", + "uri": "record(4)", + }, + }, + "text": "hear that label_me label_me_2", + }, + "uri": "record(6)", + }, + Object { + "author": Object { + "associated": Object { + "chat": Object { + "allowIncoming": "none", + }, + }, + "did": "user(3)", + "handle": "dan.test", + "labels": Array [], + "viewer": Object { + "blockedBy": false, + "following": "record(10)", + "muted": false, + }, + }, + "cid": "cids(7)", + "indexedAt": "1970-01-01T00:00:00.000Z", + "isRead": false, + "labels": Array [], + "reason": "mention", + "record": Object { + "$type": "app.bsky.feed.post", + "createdAt": "1970-01-01T00:00:00.000Z", + "embed": Object { + "$type": "app.bsky.embed.record", + "record": Object { + "cid": "cids(8)", + "uri": "record(11)", + }, + }, + "facets": Array [ + Object { + "features": Array [ + Object { + "$type": "app.bsky.richtext.facet#mention", + "did": "user(4)", + }, + ], + "index": Object { + "byteEnd": 18, + "byteStart": 0, + }, + }, + ], + "text": "@alice.bluesky.xyz is the best", + }, + "uri": "record(9)", + }, + ], + "priority": false, + "seenAt": "1970-01-01T00:00:00.000Z", +} +`; + +exports[`notification views filters notifications by reason 1`] = ` +Object { + "notifications": Array [ + Object { + "author": Object { + "associated": Object { + "chat": Object { + "allowIncoming": "none", + }, + }, + "did": "user(0)", + "handle": "dan.test", + "labels": Array [], + "viewer": Object { + "blockedBy": false, + "following": "record(1)", + "muted": false, + }, + }, + "cid": "cids(0)", + "indexedAt": "1970-01-01T00:00:00.000Z", + "isRead": false, + "labels": Array [], + "reason": "mention", + "record": Object { + "$type": "app.bsky.feed.post", + "createdAt": "1970-01-01T00:00:00.000Z", + "embed": Object { + "$type": "app.bsky.embed.record", + "record": Object { + "cid": "cids(1)", + "uri": "record(2)", + }, + }, + "facets": Array [ + Object { + "features": Array [ + Object { + "$type": "app.bsky.richtext.facet#mention", + "did": "user(1)", + }, + ], + "index": Object { + "byteEnd": 18, + "byteStart": 0, + }, + }, + ], + "text": "@alice.bluesky.xyz is the best", + }, + "uri": "record(0)", + }, + ], + "priority": false, + "seenAt": "1970-01-01T00:00:00.000Z", +} +`; + exports[`notification views generates notifications for quotes 1`] = ` Array [ Object { diff --git a/packages/bsky/tests/views/notifications.test.ts b/packages/bsky/tests/views/notifications.test.ts index 560916fd687..ca933d3085e 100644 --- a/packages/bsky/tests/views/notifications.test.ts +++ b/packages/bsky/tests/views/notifications.test.ts @@ -417,6 +417,84 @@ describe('notification views', () => { ), ).toBe(true) expect(forSnapshot(notifs.data)).toMatchSnapshot() + await agent.api.app.bsky.notification.putPreferences( + { priority: false }, + { + encoding: 'application/json', + headers: await network.serviceHeaders( + sc.dids.carol, + ids.AppBskyNotificationPutPreferences, + ), + }, + ) + await network.processAll() + }) + + it('filters notifications by reason', async () => { + const res = await agent.app.bsky.notification.listNotifications( + { + reasons: ['mention'], + }, + { + headers: await network.serviceHeaders( + sc.dids.alice, + ids.AppBskyNotificationListNotifications, + ), + }, + ) + expect(res.data.notifications.length).toBe(1) + expect(forSnapshot(res.data)).toMatchSnapshot() + }) + + it('filters notifications by multiple reasons', async () => { + const res = await agent.app.bsky.notification.listNotifications( + { + reasons: ['mention', 'reply'], + }, + { + headers: await network.serviceHeaders( + sc.dids.alice, + ids.AppBskyNotificationListNotifications, + ), + }, + ) + expect(res.data.notifications.length).toBe(4) + expect(forSnapshot(res.data)).toMatchSnapshot() + }) + + it('paginates filtered notifications', async () => { + const results = (results) => + sort(results.flatMap((res) => res.notifications)) + const paginator = async (cursor?: string) => { + const res = await agent.app.bsky.notification.listNotifications( + { reasons: ['mention', 'reply'], cursor, limit: 2 }, + { + headers: await network.serviceHeaders( + alice, + ids.AppBskyNotificationListNotifications, + ), + }, + ) + return res.data + } + + const paginatedAll = await paginateAll(paginator) + paginatedAll.forEach((res) => + expect(res.notifications.length).toBeLessThanOrEqual(2), + ) + + const full = await agent.app.bsky.notification.listNotifications( + { reasons: ['mention', 'reply'] }, + { + headers: await network.serviceHeaders( + alice, + ids.AppBskyNotificationListNotifications, + ), + }, + ) + + expect(full.data.notifications.length).toBe(4) + expect(results(paginatedAll)).toEqual(results([full.data])) }) it('fails open on clearly bad cursor.', async () => {