Skip to content

Commit a5ab190

Browse files
Merge pull request #35 from blacksky-algorithms/fix/private-space-notifications
feat(bsky): add private space notification queries
2 parents 8affdf1 + 2c9dc88 commit a5ab190

16 files changed

Lines changed: 1081 additions & 97 deletions

File tree

.changeset/tender-spaces-notify.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@atproto/bsky': minor
3+
---
4+
5+
Add private-space-aware notification list and unread-count queries while keeping standard notification queries public-only.
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
{
2+
"lexicon": 1,
3+
"id": "community.blacksky.notification.getUnreadCount",
4+
"defs": {
5+
"main": {
6+
"type": "query",
7+
"description": "Count unread public and authorized permissioned-space notifications for the requesting account.",
8+
"parameters": {
9+
"type": "params",
10+
"properties": {
11+
"priority": { "type": "boolean" },
12+
"seenAt": { "type": "string", "format": "datetime" }
13+
}
14+
},
15+
"output": {
16+
"encoding": "application/json",
17+
"schema": {
18+
"type": "object",
19+
"required": ["count"],
20+
"properties": {
21+
"count": { "type": "integer" }
22+
}
23+
}
24+
}
25+
}
26+
}
27+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
{
2+
"lexicon": 1,
3+
"id": "community.blacksky.notification.listNotifications",
4+
"defs": {
5+
"main": {
6+
"type": "query",
7+
"description": "Enumerate public and authorized permissioned-space notifications for the requesting account. An empty page may include a cursor and should be continued.",
8+
"parameters": {
9+
"type": "params",
10+
"properties": {
11+
"reasons": {
12+
"description": "Notification reasons to include in response.",
13+
"type": "array",
14+
"items": {
15+
"type": "string",
16+
"description": "A reason that matches the reason property of #notification."
17+
}
18+
},
19+
"limit": {
20+
"type": "integer",
21+
"minimum": 1,
22+
"maximum": 100,
23+
"default": 50
24+
},
25+
"priority": { "type": "boolean" },
26+
"cursor": { "type": "string" },
27+
"seenAt": { "type": "string", "format": "datetime" }
28+
}
29+
},
30+
"output": {
31+
"encoding": "application/json",
32+
"schema": {
33+
"type": "object",
34+
"required": ["notifications"],
35+
"properties": {
36+
"cursor": { "type": "string" },
37+
"notifications": {
38+
"type": "array",
39+
"items": { "type": "ref", "ref": "#notification" }
40+
},
41+
"priority": { "type": "boolean" },
42+
"seenAt": { "type": "string", "format": "datetime" }
43+
}
44+
}
45+
}
46+
},
47+
"notification": {
48+
"type": "object",
49+
"required": [
50+
"uri",
51+
"cid",
52+
"author",
53+
"reason",
54+
"record",
55+
"isRead",
56+
"indexedAt"
57+
],
58+
"properties": {
59+
"uri": {
60+
"type": "string",
61+
"description": "The public AT URI or permissioned-space record URI that caused the notification."
62+
},
63+
"cid": { "type": "string", "format": "cid" },
64+
"author": { "type": "ref", "ref": "app.bsky.actor.defs#profileView" },
65+
"reason": {
66+
"type": "string",
67+
"description": "The reason why this notification was delivered.",
68+
"knownValues": [
69+
"like",
70+
"repost",
71+
"follow",
72+
"mention",
73+
"reply",
74+
"quote",
75+
"starterpack-joined",
76+
"verified",
77+
"unverified",
78+
"like-via-repost",
79+
"repost-via-repost",
80+
"subscribed-post",
81+
"contact-match"
82+
]
83+
},
84+
"reasonSubject": {
85+
"type": "string",
86+
"description": "The public AT URI or permissioned-space record URI that is the notification subject."
87+
},
88+
"record": { "type": "unknown" },
89+
"starterPack": {
90+
"description": "The starter pack associated with this notification.",
91+
"type": "ref",
92+
"ref": "app.bsky.graph.defs#starterPackViewBasic"
93+
},
94+
"isRead": { "type": "boolean" },
95+
"indexedAt": { "type": "string", "format": "datetime" },
96+
"labels": {
97+
"type": "array",
98+
"items": { "type": "ref", "ref": "com.atproto.label.defs#label" }
99+
}
100+
}
101+
}
102+
}
103+
}

packages/bsky/proto/bsky.proto

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -898,6 +898,7 @@ message GetNotificationsRequest {
898898
int32 limit = 2;
899899
string cursor = 3;
900900
bool priority = 4;
901+
bool include_space_notifications = 5;
901902
}
902903

903904
message Notification {
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { describe, expect, test } from 'vitest'
2+
import { classifyNotificationDomain } from './domain.js'
3+
4+
const publicPost = 'at://did:plc:alice/app.bsky.feed.post/3kpublic'
5+
const firstSpace = 'at://did:plc:tenant/space/community.blacksky.feed/first'
6+
const secondSpace = 'at://did:plc:tenant/space/community.blacksky.feed/second'
7+
const firstPost = `${firstSpace}/did:plc:alice/app.bsky.feed.post/3kfirst`
8+
const secondPost = `${secondSpace}/did:plc:alice/app.bsky.feed.post/3ksecond`
9+
10+
describe(classifyNotificationDomain, () => {
11+
test.each([
12+
{
13+
note: 'public record without subject',
14+
notification: { uri: publicPost },
15+
expected: { type: 'public' },
16+
},
17+
{
18+
note: 'public record with public subject',
19+
notification: { uri: publicPost, reasonSubject: publicPost },
20+
expected: { type: 'public' },
21+
},
22+
{
23+
note: 'space record without subject',
24+
notification: { uri: firstPost },
25+
expected: { type: 'space', spaceUri: firstSpace },
26+
},
27+
{
28+
note: 'space record with same-space subject',
29+
notification: { uri: firstPost, reasonSubject: firstPost },
30+
expected: { type: 'space', spaceUri: firstSpace },
31+
},
32+
{
33+
note: 'public record with private subject',
34+
notification: { uri: publicPost, reasonSubject: firstPost },
35+
expected: { type: 'invalid' },
36+
},
37+
{
38+
note: 'private record with public subject',
39+
notification: { uri: firstPost, reasonSubject: publicPost },
40+
expected: { type: 'invalid' },
41+
},
42+
{
43+
note: 'cross-space pair',
44+
notification: { uri: firstPost, reasonSubject: secondPost },
45+
expected: { type: 'invalid' },
46+
},
47+
{
48+
note: 'malformed space record',
49+
notification: {
50+
uri: 'at://did:plc:tenant/space/community.blacksky.feed/first/broken',
51+
},
52+
expected: { type: 'invalid' },
53+
},
54+
{
55+
note: 'malformed ordinary uri',
56+
notification: { uri: 'not-a-uri' },
57+
expected: { type: 'invalid' },
58+
},
59+
])('$note', ({ notification, expected }) => {
60+
expect(classifyNotificationDomain(notification)).toEqual(expected)
61+
})
62+
})
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { isAtUriString } from '@atproto/syntax'
2+
import {
3+
parseSpaceRecordUri,
4+
spaceUriOf,
5+
} from '../../../community/blacksky/space-uri.js'
6+
7+
export type NotificationDomain =
8+
{ type: 'public' } | { type: 'space'; spaceUri: string } | { type: 'invalid' }
9+
10+
const uriDomain = (
11+
uri: string | null | undefined,
12+
):
13+
| { type: 'public' }
14+
| { type: 'space'; spaceUri: string }
15+
| { type: 'invalid' } => {
16+
if (!uri) return { type: 'invalid' }
17+
const space = parseSpaceRecordUri(uri)
18+
if (space) return { type: 'space', spaceUri: spaceUriOf(space) }
19+
const parts = uri.startsWith('at://') ? uri.slice(5).split('/') : []
20+
if (parts[1] === 'space') return { type: 'invalid' }
21+
return isAtUriString(uri) ? { type: 'public' } : { type: 'invalid' }
22+
}
23+
24+
export function classifyNotificationDomain(notification: {
25+
uri: string
26+
reasonSubject?: string
27+
}): NotificationDomain {
28+
const record = uriDomain(notification.uri)
29+
if (record.type === 'invalid') return record
30+
if (!notification.reasonSubject) return record
31+
32+
const subject = uriDomain(notification.reasonSubject)
33+
if (subject.type === 'invalid' || subject.type !== record.type) {
34+
return { type: 'invalid' }
35+
}
36+
if (
37+
record.type === 'space' &&
38+
subject.type === 'space' &&
39+
record.spaceUri !== subject.spaceUri
40+
) {
41+
return { type: 'invalid' }
42+
}
43+
return record
44+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest'
2+
import { canViewSpace } from '../../../community/blacksky/tenant-gate.js'
3+
import { runNotificationCount } from './getUnreadCount.js'
4+
5+
vi.mock('../../../community/blacksky/tenant-gate.js', () => ({
6+
canViewSpace: vi.fn(),
7+
}))
8+
9+
const firstSpace = 'at://did:plc:tenant/space/community.blacksky.feed/first'
10+
const secondSpace = 'at://did:plc:tenant/space/community.blacksky.feed/second'
11+
const viewer = 'did:plc:viewer'
12+
13+
const context = () => {
14+
const getUnreadNotificationSpaces = vi.fn(async () => ({
15+
spaces: [
16+
{ postUri: `${firstSpace}/post/one`, spaceUri: firstSpace },
17+
{ postUri: `${firstSpace}/post/two`, spaceUri: firstSpace },
18+
{ postUri: `${secondSpace}/post/three`, spaceUri: secondSpace },
19+
],
20+
}))
21+
const getUnreadNotificationCount = vi.fn(
22+
async ({ allowedSpaceUris }: { allowedSpaceUris: string[] }) => ({
23+
count: 2 + allowedSpaceUris.length,
24+
}),
25+
)
26+
return {
27+
ctx: {
28+
hydrator: {
29+
dataplane: {
30+
getUnreadNotificationSpaces,
31+
getUnreadNotificationCount,
32+
},
33+
},
34+
} as any,
35+
getUnreadNotificationSpaces,
36+
getUnreadNotificationCount,
37+
}
38+
}
39+
40+
describe(runNotificationCount, () => {
41+
beforeEach(() => {
42+
vi.mocked(canViewSpace).mockReset()
43+
})
44+
45+
it('deduplicates candidates and counts public plus authorized spaces', async () => {
46+
const { ctx, getUnreadNotificationCount } = context()
47+
vi.mocked(canViewSpace).mockImplementation(async (_ctx, spaceUri) =>
48+
Promise.resolve(spaceUri === firstSpace),
49+
)
50+
51+
await expect(
52+
runNotificationCount({ viewer }, ctx, 'authorized-union'),
53+
).resolves.toEqual({ count: 3 })
54+
expect(canViewSpace).toHaveBeenCalledTimes(2)
55+
expect(canViewSpace).toHaveBeenCalledWith(ctx, firstSpace, viewer)
56+
expect(canViewSpace).toHaveBeenCalledWith(ctx, secondSpace, viewer)
57+
expect(getUnreadNotificationCount).toHaveBeenCalledWith({
58+
actorDid: viewer,
59+
priority: false,
60+
allowedSpaceUris: [firstSpace],
61+
})
62+
})
63+
64+
it('fails closed when one space authorization throws', async () => {
65+
const { ctx, getUnreadNotificationCount } = context()
66+
vi.mocked(canViewSpace).mockImplementation(async (_ctx, spaceUri) => {
67+
if (spaceUri === firstSpace) throw new Error('authorization unavailable')
68+
return true
69+
})
70+
71+
await expect(
72+
runNotificationCount({ viewer }, ctx, 'authorized-union'),
73+
).resolves.toEqual({ count: 3 })
74+
expect(getUnreadNotificationCount).toHaveBeenCalledWith({
75+
actorDid: viewer,
76+
priority: false,
77+
allowedSpaceUris: [secondSpace],
78+
})
79+
})
80+
81+
it('makes no candidate or authorization calls in public-only mode', async () => {
82+
const { ctx, getUnreadNotificationSpaces, getUnreadNotificationCount } =
83+
context()
84+
85+
await expect(
86+
runNotificationCount({ viewer, priority: true }, ctx, 'public-only'),
87+
).resolves.toEqual({ count: 2 })
88+
expect(getUnreadNotificationSpaces).not.toHaveBeenCalled()
89+
expect(canViewSpace).not.toHaveBeenCalled()
90+
expect(getUnreadNotificationCount).toHaveBeenCalledWith({
91+
actorDid: viewer,
92+
priority: true,
93+
allowedSpaceUris: [],
94+
})
95+
})
96+
})

0 commit comments

Comments
 (0)