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
5 changes: 5 additions & 0 deletions .changeset/tender-spaces-notify.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@atproto/bsky': minor
---

Add private-space-aware notification list and unread-count queries while keeping standard notification queries public-only.
27 changes: 27 additions & 0 deletions lexicons/community/blacksky/notification/getUnreadCount.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"lexicon": 1,
"id": "community.blacksky.notification.getUnreadCount",
"defs": {
"main": {
"type": "query",
"description": "Count unread public and authorized permissioned-space notifications for the requesting account.",
"parameters": {
"type": "params",
"properties": {
"priority": { "type": "boolean" },
"seenAt": { "type": "string", "format": "datetime" }
}
},
"output": {
"encoding": "application/json",
"schema": {
"type": "object",
"required": ["count"],
"properties": {
"count": { "type": "integer" }
}
}
}
}
}
}
103 changes: 103 additions & 0 deletions lexicons/community/blacksky/notification/listNotifications.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
{
"lexicon": 1,
"id": "community.blacksky.notification.listNotifications",
"defs": {
"main": {
"type": "query",
"description": "Enumerate public and authorized permissioned-space notifications for the requesting account. An empty page may include a cursor and should be continued.",
"parameters": {
"type": "params",
"properties": {
"reasons": {
"description": "Notification reasons to include in response.",
"type": "array",
"items": {
"type": "string",
"description": "A reason that matches the reason property of #notification."
}
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"default": 50
},
"priority": { "type": "boolean" },
"cursor": { "type": "string" },
"seenAt": { "type": "string", "format": "datetime" }
}
},
"output": {
"encoding": "application/json",
"schema": {
"type": "object",
"required": ["notifications"],
"properties": {
"cursor": { "type": "string" },
"notifications": {
"type": "array",
"items": { "type": "ref", "ref": "#notification" }
},
"priority": { "type": "boolean" },
"seenAt": { "type": "string", "format": "datetime" }
}
}
}
},
"notification": {
"type": "object",
"required": [
"uri",
"cid",
"author",
"reason",
"record",
"isRead",
"indexedAt"
],
"properties": {
"uri": {
"type": "string",
"description": "The public AT URI or permissioned-space record URI that caused the notification."
},
"cid": { "type": "string", "format": "cid" },
"author": { "type": "ref", "ref": "app.bsky.actor.defs#profileView" },
"reason": {
"type": "string",
"description": "The reason why this notification was delivered.",
"knownValues": [
"like",
"repost",
"follow",
"mention",
"reply",
"quote",
"starterpack-joined",
"verified",
"unverified",
"like-via-repost",
"repost-via-repost",
"subscribed-post",
"contact-match"
]
},
"reasonSubject": {
"type": "string",
"description": "The public AT URI or permissioned-space record URI that is the notification subject."
},
"record": { "type": "unknown" },
"starterPack": {
"description": "The starter pack associated with this notification.",
"type": "ref",
"ref": "app.bsky.graph.defs#starterPackViewBasic"
},
"isRead": { "type": "boolean" },
"indexedAt": { "type": "string", "format": "datetime" },
"labels": {
"type": "array",
"items": { "type": "ref", "ref": "com.atproto.label.defs#label" }
}
}
}
}
}
1 change: 1 addition & 0 deletions packages/bsky/proto/bsky.proto
Original file line number Diff line number Diff line change
Expand Up @@ -898,6 +898,7 @@ message GetNotificationsRequest {
int32 limit = 2;
string cursor = 3;
bool priority = 4;
bool include_space_notifications = 5;
}

message Notification {
Expand Down
62 changes: 62 additions & 0 deletions packages/bsky/src/api/app/bsky/notification/domain.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { describe, expect, test } from 'vitest'
import { classifyNotificationDomain } from './domain.js'

const publicPost = 'at://did:plc:alice/app.bsky.feed.post/3kpublic'
const firstSpace = 'at://did:plc:tenant/space/community.blacksky.feed/first'
const secondSpace = 'at://did:plc:tenant/space/community.blacksky.feed/second'
const firstPost = `${firstSpace}/did:plc:alice/app.bsky.feed.post/3kfirst`
const secondPost = `${secondSpace}/did:plc:alice/app.bsky.feed.post/3ksecond`

describe(classifyNotificationDomain, () => {
test.each([
{
note: 'public record without subject',
notification: { uri: publicPost },
expected: { type: 'public' },
},
{
note: 'public record with public subject',
notification: { uri: publicPost, reasonSubject: publicPost },
expected: { type: 'public' },
},
{
note: 'space record without subject',
notification: { uri: firstPost },
expected: { type: 'space', spaceUri: firstSpace },
},
{
note: 'space record with same-space subject',
notification: { uri: firstPost, reasonSubject: firstPost },
expected: { type: 'space', spaceUri: firstSpace },
},
{
note: 'public record with private subject',
notification: { uri: publicPost, reasonSubject: firstPost },
expected: { type: 'invalid' },
},
{
note: 'private record with public subject',
notification: { uri: firstPost, reasonSubject: publicPost },
expected: { type: 'invalid' },
},
{
note: 'cross-space pair',
notification: { uri: firstPost, reasonSubject: secondPost },
expected: { type: 'invalid' },
},
{
note: 'malformed space record',
notification: {
uri: 'at://did:plc:tenant/space/community.blacksky.feed/first/broken',
},
expected: { type: 'invalid' },
},
{
note: 'malformed ordinary uri',
notification: { uri: 'not-a-uri' },
expected: { type: 'invalid' },
},
])('$note', ({ notification, expected }) => {
expect(classifyNotificationDomain(notification)).toEqual(expected)
})
})
44 changes: 44 additions & 0 deletions packages/bsky/src/api/app/bsky/notification/domain.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { isAtUriString } from '@atproto/syntax'
import {
parseSpaceRecordUri,
spaceUriOf,
} from '../../../community/blacksky/space-uri.js'

export type NotificationDomain =
{ type: 'public' } | { type: 'space'; spaceUri: string } | { type: 'invalid' }

const uriDomain = (
uri: string | null | undefined,
):
| { type: 'public' }
| { type: 'space'; spaceUri: string }
| { type: 'invalid' } => {
if (!uri) return { type: 'invalid' }
const space = parseSpaceRecordUri(uri)
if (space) return { type: 'space', spaceUri: spaceUriOf(space) }
const parts = uri.startsWith('at://') ? uri.slice(5).split('/') : []
if (parts[1] === 'space') return { type: 'invalid' }
return isAtUriString(uri) ? { type: 'public' } : { type: 'invalid' }
}

export function classifyNotificationDomain(notification: {
uri: string
reasonSubject?: string
}): NotificationDomain {
const record = uriDomain(notification.uri)
if (record.type === 'invalid') return record
if (!notification.reasonSubject) return record

const subject = uriDomain(notification.reasonSubject)
if (subject.type === 'invalid' || subject.type !== record.type) {
return { type: 'invalid' }
}
if (
record.type === 'space' &&
subject.type === 'space' &&
record.spaceUri !== subject.spaceUri
) {
return { type: 'invalid' }
}
return record
}
96 changes: 96 additions & 0 deletions packages/bsky/src/api/app/bsky/notification/getUnreadCount.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { canViewSpace } from '../../../community/blacksky/tenant-gate.js'
import { runNotificationCount } from './getUnreadCount.js'

vi.mock('../../../community/blacksky/tenant-gate.js', () => ({
canViewSpace: vi.fn(),
}))

const firstSpace = 'at://did:plc:tenant/space/community.blacksky.feed/first'
const secondSpace = 'at://did:plc:tenant/space/community.blacksky.feed/second'
const viewer = 'did:plc:viewer'

const context = () => {
const getUnreadNotificationSpaces = vi.fn(async () => ({
spaces: [
{ postUri: `${firstSpace}/post/one`, spaceUri: firstSpace },
{ postUri: `${firstSpace}/post/two`, spaceUri: firstSpace },
{ postUri: `${secondSpace}/post/three`, spaceUri: secondSpace },
],
}))
const getUnreadNotificationCount = vi.fn(
async ({ allowedSpaceUris }: { allowedSpaceUris: string[] }) => ({
count: 2 + allowedSpaceUris.length,
}),
)
return {
ctx: {
hydrator: {
dataplane: {
getUnreadNotificationSpaces,
getUnreadNotificationCount,
},
},
} as any,
getUnreadNotificationSpaces,
getUnreadNotificationCount,
}
}

describe(runNotificationCount, () => {
beforeEach(() => {
vi.mocked(canViewSpace).mockReset()
})

it('deduplicates candidates and counts public plus authorized spaces', async () => {
const { ctx, getUnreadNotificationCount } = context()
vi.mocked(canViewSpace).mockImplementation(async (_ctx, spaceUri) =>
Promise.resolve(spaceUri === firstSpace),
)

await expect(
runNotificationCount({ viewer }, ctx, 'authorized-union'),
).resolves.toEqual({ count: 3 })
expect(canViewSpace).toHaveBeenCalledTimes(2)
expect(canViewSpace).toHaveBeenCalledWith(ctx, firstSpace, viewer)
expect(canViewSpace).toHaveBeenCalledWith(ctx, secondSpace, viewer)
expect(getUnreadNotificationCount).toHaveBeenCalledWith({
actorDid: viewer,
priority: false,
allowedSpaceUris: [firstSpace],
})
})

it('fails closed when one space authorization throws', async () => {
const { ctx, getUnreadNotificationCount } = context()
vi.mocked(canViewSpace).mockImplementation(async (_ctx, spaceUri) => {
if (spaceUri === firstSpace) throw new Error('authorization unavailable')
return true
})

await expect(
runNotificationCount({ viewer }, ctx, 'authorized-union'),
).resolves.toEqual({ count: 3 })
expect(getUnreadNotificationCount).toHaveBeenCalledWith({
actorDid: viewer,
priority: false,
allowedSpaceUris: [secondSpace],
})
})

it('makes no candidate or authorization calls in public-only mode', async () => {
const { ctx, getUnreadNotificationSpaces, getUnreadNotificationCount } =
context()

await expect(
runNotificationCount({ viewer, priority: true }, ctx, 'public-only'),
).resolves.toEqual({ count: 2 })
expect(getUnreadNotificationSpaces).not.toHaveBeenCalled()
expect(canViewSpace).not.toHaveBeenCalled()
expect(getUnreadNotificationCount).toHaveBeenCalledWith({
actorDid: viewer,
priority: true,
allowedSpaceUris: [],
})
})
})
Loading
Loading