From 6f419790acc7241e594ff22af2a4937a9a50c7c8 Mon Sep 17 00:00:00 2001 From: devin ivy Date: Tue, 12 Sep 2023 12:07:31 -0400 Subject: [PATCH] Feature: block lists (#1531) * lexicons for block lists * reorg blockset functionality into graph service, impl block/mute filtering * apply filterBlocksAndMutes() throughout appview except feeds * update local feeds to pass through cleanFeedSkeleton(), offload block/mute application * impl for grabbing block/mute details by did pair * refactor getActorInfos away, use actor service * experiment with moving getFeedGenerators over to a pipeline * move getPostThread over to a pipeline * move feeds over to pipelines * move suggestions and likes over to pipelines * move reposted-by, follows, followers over to pipelines, tidy author feed and post thread * remove old block/mute checks * unify post presentation logic * move profiles endpoints over to pipelines * tidy * tidy * misc fixes * unify some profile hydration/presentation in appview * profile detail, split hydration and presentation, misc fixes * unify feed hydration w/ profile hydration * unify hydration step for embeds, tidy application of labels * setup indexing of list-blocks in bsky appview * apply list-blocks, impl getListBlocks, tidy getList, tests * tidy * update pds proxy snaps * update pds proxy snaps * fix snap * make algos return feed items, save work in getFeed * misc changes, tidy * tidy * fix aturi import * hoist actors out of composeThread() * tidy * run ci on all prs * format * build * proxy graph.getListBlocks * remove unneeded index * build pds * setup noop listblock indexer on pds * remove build --------- Co-authored-by: dholms --- .github/workflows/repo.yaml | 2 +- lexicons/app/bsky/graph/defs.json | 3 +- lexicons/app/bsky/graph/getListBlocks.json | 36 ++ lexicons/app/bsky/graph/listblock.json | 19 + packages/api/src/client/index.ts | 82 +++ packages/api/src/client/lexicons.ts | 74 +++ .../src/client/types/app/bsky/graph/defs.ts | 1 + .../types/app/bsky/graph/getListBlocks.ts | 38 ++ .../client/types/app/bsky/graph/listblock.ts | 26 + .../bsky/src/api/app/bsky/actor/getProfile.ts | 107 +++- .../src/api/app/bsky/actor/getProfiles.ts | 69 ++- .../src/api/app/bsky/actor/getSuggestions.ts | 154 +++-- .../src/api/app/bsky/actor/searchActors.ts | 2 +- .../app/bsky/actor/searchActorsTypeahead.ts | 11 +- .../src/api/app/bsky/feed/getActorFeeds.ts | 7 +- .../src/api/app/bsky/feed/getActorLikes.ts | 144 +++-- .../src/api/app/bsky/feed/getAuthorFeed.ts | 232 +++++--- .../bsky/src/api/app/bsky/feed/getFeed.ts | 182 ++++-- .../src/api/app/bsky/feed/getFeedGenerator.ts | 3 +- .../api/app/bsky/feed/getFeedGenerators.ts | 73 ++- .../src/api/app/bsky/feed/getFeedSkeleton.ts | 19 +- .../bsky/src/api/app/bsky/feed/getLikes.ts | 155 +++-- .../src/api/app/bsky/feed/getPostThread.ts | 173 +++--- .../bsky/src/api/app/bsky/feed/getPosts.ts | 102 +++- .../src/api/app/bsky/feed/getRepostedBy.ts | 134 +++-- .../api/app/bsky/feed/getSuggestedFeeds.ts | 4 +- .../bsky/src/api/app/bsky/feed/getTimeline.ts | 122 ++-- .../bsky/src/api/app/bsky/graph/getBlocks.ts | 5 +- .../src/api/app/bsky/graph/getFollowers.ts | 182 ++++-- .../bsky/src/api/app/bsky/graph/getFollows.ts | 183 ++++-- .../bsky/src/api/app/bsky/graph/getList.ts | 171 +++--- .../src/api/app/bsky/graph/getListBlocks.ts | 114 ++++ .../bsky/src/api/app/bsky/graph/getLists.ts | 11 +- .../bsky/src/api/app/bsky/graph/getMutes.ts | 2 +- .../bsky/notification/listNotifications.ts | 283 +++++---- .../unspecced/getPopularFeedGenerators.ts | 3 +- .../app/bsky/unspecced/getTimelineSkeleton.ts | 14 +- packages/bsky/src/api/index.ts | 2 + packages/bsky/src/db/database-schema.ts | 2 + .../20230904T211011773Z-block-lists.ts | 24 + packages/bsky/src/db/migrations/index.ts | 1 + packages/bsky/src/db/tables/list-block.ts | 15 + packages/bsky/src/feed-gen/best-of-follows.ts | 5 - packages/bsky/src/feed-gen/bsky-team.ts | 8 +- packages/bsky/src/feed-gen/hot-classic.ts | 8 +- packages/bsky/src/feed-gen/mutuals.ts | 6 +- packages/bsky/src/feed-gen/types.ts | 17 +- packages/bsky/src/feed-gen/whats-hot.ts | 8 +- packages/bsky/src/feed-gen/with-friends.ts | 1 + packages/bsky/src/lexicon/index.ts | 12 + packages/bsky/src/lexicon/lexicons.ts | 74 +++ .../src/lexicon/types/app/bsky/graph/defs.ts | 1 + .../types/app/bsky/graph/getListBlocks.ts | 48 ++ .../lexicon/types/app/bsky/graph/listblock.ts | 26 + packages/bsky/src/pipeline.ts | 22 + packages/bsky/src/services/actor/index.ts | 2 + packages/bsky/src/services/actor/types.ts | 74 +++ packages/bsky/src/services/actor/views.ts | 436 +++++++------- packages/bsky/src/services/feed/index.ts | 420 +++---------- packages/bsky/src/services/feed/types.ts | 34 +- packages/bsky/src/services/feed/views.ts | 68 ++- packages/bsky/src/services/graph/index.ts | 325 ++++++---- packages/bsky/src/services/graph/types.ts | 9 + packages/bsky/src/services/indexing/index.ts | 7 + .../services/indexing/plugins/list-block.ts | 90 +++ packages/bsky/src/services/label/index.ts | 14 +- .../__snapshots__/block-lists.test.ts.snap | 557 ++++++++++++++++++ .../views/__snapshots__/follows.test.ts.snap | 192 +++--- .../views/__snapshots__/likes.test.ts.snap | 18 +- .../__snapshots__/mute-lists.test.ts.snap | 48 +- packages/bsky/tests/views/block-lists.test.ts | 407 +++++++++++++ .../api/app/bsky/graph/getListBlocks.ts | 19 + .../pds/src/app-view/api/app/bsky/index.ts | 2 + .../src/app-view/services/indexing/index.ts | 10 +- .../app-view/services/indexing/processor.ts | 17 + packages/pds/src/lexicon/index.ts | 12 + packages/pds/src/lexicon/lexicons.ts | 74 +++ .../src/lexicon/types/app/bsky/graph/defs.ts | 1 + .../types/app/bsky/graph/getListBlocks.ts | 48 ++ .../lexicon/types/app/bsky/graph/listblock.ts | 26 + .../proxied/__snapshots__/views.test.ts.snap | 214 ++++--- packages/pds/tests/proxied/views.test.ts | 23 + 82 files changed, 4562 insertions(+), 1807 deletions(-) create mode 100644 lexicons/app/bsky/graph/getListBlocks.json create mode 100644 lexicons/app/bsky/graph/listblock.json create mode 100644 packages/api/src/client/types/app/bsky/graph/getListBlocks.ts create mode 100644 packages/api/src/client/types/app/bsky/graph/listblock.ts create mode 100644 packages/bsky/src/api/app/bsky/graph/getListBlocks.ts create mode 100644 packages/bsky/src/db/migrations/20230904T211011773Z-block-lists.ts create mode 100644 packages/bsky/src/db/tables/list-block.ts create mode 100644 packages/bsky/src/lexicon/types/app/bsky/graph/getListBlocks.ts create mode 100644 packages/bsky/src/lexicon/types/app/bsky/graph/listblock.ts create mode 100644 packages/bsky/src/pipeline.ts create mode 100644 packages/bsky/src/services/actor/types.ts create mode 100644 packages/bsky/src/services/graph/types.ts create mode 100644 packages/bsky/src/services/indexing/plugins/list-block.ts create mode 100644 packages/bsky/tests/views/__snapshots__/block-lists.test.ts.snap create mode 100644 packages/bsky/tests/views/block-lists.test.ts create mode 100644 packages/pds/src/app-view/api/app/bsky/graph/getListBlocks.ts create mode 100644 packages/pds/src/lexicon/types/app/bsky/graph/getListBlocks.ts create mode 100644 packages/pds/src/lexicon/types/app/bsky/graph/listblock.ts diff --git a/.github/workflows/repo.yaml b/.github/workflows/repo.yaml index e387c442321..8380fff8a63 100644 --- a/.github/workflows/repo.yaml +++ b/.github/workflows/repo.yaml @@ -3,7 +3,7 @@ name: Test on: pull_request: branches: - - main + - '*' concurrency: group: '${{ github.workflow }}-${{ github.head_ref || github.ref }}' diff --git a/lexicons/app/bsky/graph/defs.json b/lexicons/app/bsky/graph/defs.json index 4366f9df368..44cf55875b4 100644 --- a/lexicons/app/bsky/graph/defs.json +++ b/lexicons/app/bsky/graph/defs.json @@ -56,7 +56,8 @@ "listViewerState": { "type": "object", "properties": { - "muted": { "type": "boolean" } + "muted": { "type": "boolean" }, + "blocked": { "type": "string", "format": "at-uri" } } } } diff --git a/lexicons/app/bsky/graph/getListBlocks.json b/lexicons/app/bsky/graph/getListBlocks.json new file mode 100644 index 00000000000..709d77aa68b --- /dev/null +++ b/lexicons/app/bsky/graph/getListBlocks.json @@ -0,0 +1,36 @@ +{ + "lexicon": 1, + "id": "app.bsky.graph.getListBlocks", + "defs": { + "main": { + "type": "query", + "description": "Which lists is the requester's account blocking?", + "parameters": { + "type": "params", + "properties": { + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + }, + "cursor": { "type": "string" } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["lists"], + "properties": { + "cursor": { "type": "string" }, + "lists": { + "type": "array", + "items": { "type": "ref", "ref": "app.bsky.graph.defs#listView" } + } + } + } + } + } + } +} diff --git a/lexicons/app/bsky/graph/listblock.json b/lexicons/app/bsky/graph/listblock.json new file mode 100644 index 00000000000..b3a839c5316 --- /dev/null +++ b/lexicons/app/bsky/graph/listblock.json @@ -0,0 +1,19 @@ +{ + "lexicon": 1, + "id": "app.bsky.graph.listblock", + "defs": { + "main": { + "type": "record", + "description": "A block of an entire list of actors.", + "key": "tid", + "record": { + "type": "object", + "required": ["subject", "createdAt"], + "properties": { + "subject": { "type": "string", "format": "at-uri" }, + "createdAt": { "type": "string", "format": "datetime" } + } + } + } + } +} diff --git a/packages/api/src/client/index.ts b/packages/api/src/client/index.ts index f9ebf0ade63..f2921d3e5a5 100644 --- a/packages/api/src/client/index.ts +++ b/packages/api/src/client/index.ts @@ -110,10 +110,12 @@ import * as AppBskyGraphGetBlocks from './types/app/bsky/graph/getBlocks' import * as AppBskyGraphGetFollowers from './types/app/bsky/graph/getFollowers' import * as AppBskyGraphGetFollows from './types/app/bsky/graph/getFollows' import * as AppBskyGraphGetList from './types/app/bsky/graph/getList' +import * as AppBskyGraphGetListBlocks from './types/app/bsky/graph/getListBlocks' import * as AppBskyGraphGetListMutes from './types/app/bsky/graph/getListMutes' import * as AppBskyGraphGetLists from './types/app/bsky/graph/getLists' import * as AppBskyGraphGetMutes from './types/app/bsky/graph/getMutes' import * as AppBskyGraphList from './types/app/bsky/graph/list' +import * as AppBskyGraphListblock from './types/app/bsky/graph/listblock' import * as AppBskyGraphListitem from './types/app/bsky/graph/listitem' import * as AppBskyGraphMuteActor from './types/app/bsky/graph/muteActor' import * as AppBskyGraphMuteActorList from './types/app/bsky/graph/muteActorList' @@ -232,10 +234,12 @@ export * as AppBskyGraphGetBlocks from './types/app/bsky/graph/getBlocks' export * as AppBskyGraphGetFollowers from './types/app/bsky/graph/getFollowers' export * as AppBskyGraphGetFollows from './types/app/bsky/graph/getFollows' export * as AppBskyGraphGetList from './types/app/bsky/graph/getList' +export * as AppBskyGraphGetListBlocks from './types/app/bsky/graph/getListBlocks' export * as AppBskyGraphGetListMutes from './types/app/bsky/graph/getListMutes' export * as AppBskyGraphGetLists from './types/app/bsky/graph/getLists' export * as AppBskyGraphGetMutes from './types/app/bsky/graph/getMutes' export * as AppBskyGraphList from './types/app/bsky/graph/list' +export * as AppBskyGraphListblock from './types/app/bsky/graph/listblock' export * as AppBskyGraphListitem from './types/app/bsky/graph/listitem' export * as AppBskyGraphMuteActor from './types/app/bsky/graph/muteActor' export * as AppBskyGraphMuteActorList from './types/app/bsky/graph/muteActorList' @@ -1631,6 +1635,7 @@ export class GraphNS { block: BlockRecord follow: FollowRecord list: ListRecord + listblock: ListblockRecord listitem: ListitemRecord constructor(service: AtpServiceClient) { @@ -1638,6 +1643,7 @@ export class GraphNS { this.block = new BlockRecord(service) this.follow = new FollowRecord(service) this.list = new ListRecord(service) + this.listblock = new ListblockRecord(service) this.listitem = new ListitemRecord(service) } @@ -1685,6 +1691,17 @@ export class GraphNS { }) } + getListBlocks( + params?: AppBskyGraphGetListBlocks.QueryParams, + opts?: AppBskyGraphGetListBlocks.CallOptions, + ): Promise { + return this._service.xrpc + .call('app.bsky.graph.getListBlocks', params, undefined, opts) + .catch((e) => { + throw AppBskyGraphGetListBlocks.toKnownErr(e) + }) + } + getListMutes( params?: AppBskyGraphGetListMutes.QueryParams, opts?: AppBskyGraphGetListMutes.CallOptions, @@ -1946,6 +1963,71 @@ export class ListRecord { } } +export class ListblockRecord { + _service: AtpServiceClient + + constructor(service: AtpServiceClient) { + this._service = service + } + + async list( + params: Omit, + ): Promise<{ + cursor?: string + records: { uri: string; value: AppBskyGraphListblock.Record }[] + }> { + const res = await this._service.xrpc.call('com.atproto.repo.listRecords', { + collection: 'app.bsky.graph.listblock', + ...params, + }) + return res.data + } + + async get( + params: Omit, + ): Promise<{ + uri: string + cid: string + value: AppBskyGraphListblock.Record + }> { + const res = await this._service.xrpc.call('com.atproto.repo.getRecord', { + collection: 'app.bsky.graph.listblock', + ...params, + }) + return res.data + } + + async create( + params: Omit< + ComAtprotoRepoCreateRecord.InputSchema, + 'collection' | 'record' + >, + record: AppBskyGraphListblock.Record, + headers?: Record, + ): Promise<{ uri: string; cid: string }> { + record.$type = 'app.bsky.graph.listblock' + const res = await this._service.xrpc.call( + 'com.atproto.repo.createRecord', + undefined, + { collection: 'app.bsky.graph.listblock', ...params, record }, + { encoding: 'application/json', headers }, + ) + return res.data + } + + async delete( + params: Omit, + headers?: Record, + ): Promise { + await this._service.xrpc.call( + 'com.atproto.repo.deleteRecord', + undefined, + { collection: 'app.bsky.graph.listblock', ...params }, + { headers }, + ) + } +} + export class ListitemRecord { _service: AtpServiceClient diff --git a/packages/api/src/client/lexicons.ts b/packages/api/src/client/lexicons.ts index 193b9c39d37..c49b098002b 100644 --- a/packages/api/src/client/lexicons.ts +++ b/packages/api/src/client/lexicons.ts @@ -5728,6 +5728,10 @@ export const schemaDict = { muted: { type: 'boolean', }, + blocked: { + type: 'string', + format: 'at-uri', + }, }, }, }, @@ -5956,6 +5960,49 @@ export const schemaDict = { }, }, }, + AppBskyGraphGetListBlocks: { + lexicon: 1, + id: 'app.bsky.graph.getListBlocks', + defs: { + main: { + type: 'query', + description: "Which lists is the requester's account blocking?", + parameters: { + type: 'params', + properties: { + limit: { + type: 'integer', + minimum: 1, + maximum: 100, + default: 50, + }, + cursor: { + type: 'string', + }, + }, + }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['lists'], + properties: { + cursor: { + type: 'string', + }, + lists: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:app.bsky.graph.defs#listView', + }, + }, + }, + }, + }, + }, + }, + }, AppBskyGraphGetListMutes: { lexicon: 1, id: 'app.bsky.graph.getListMutes', @@ -6141,6 +6188,31 @@ export const schemaDict = { }, }, }, + AppBskyGraphListblock: { + lexicon: 1, + id: 'app.bsky.graph.listblock', + defs: { + main: { + type: 'record', + description: 'A block of an entire list of actors.', + key: 'tid', + record: { + type: 'object', + required: ['subject', 'createdAt'], + properties: { + subject: { + type: 'string', + format: 'at-uri', + }, + createdAt: { + type: 'string', + format: 'datetime', + }, + }, + }, + }, + }, + }, AppBskyGraphListitem: { lexicon: 1, id: 'app.bsky.graph.listitem', @@ -6798,10 +6870,12 @@ export const ids = { AppBskyGraphGetFollowers: 'app.bsky.graph.getFollowers', AppBskyGraphGetFollows: 'app.bsky.graph.getFollows', AppBskyGraphGetList: 'app.bsky.graph.getList', + AppBskyGraphGetListBlocks: 'app.bsky.graph.getListBlocks', AppBskyGraphGetListMutes: 'app.bsky.graph.getListMutes', AppBskyGraphGetLists: 'app.bsky.graph.getLists', AppBskyGraphGetMutes: 'app.bsky.graph.getMutes', AppBskyGraphList: 'app.bsky.graph.list', + AppBskyGraphListblock: 'app.bsky.graph.listblock', AppBskyGraphListitem: 'app.bsky.graph.listitem', AppBskyGraphMuteActor: 'app.bsky.graph.muteActor', AppBskyGraphMuteActorList: 'app.bsky.graph.muteActorList', diff --git a/packages/api/src/client/types/app/bsky/graph/defs.ts b/packages/api/src/client/types/app/bsky/graph/defs.ts index 2e70bef750e..566ea2446d8 100644 --- a/packages/api/src/client/types/app/bsky/graph/defs.ts +++ b/packages/api/src/client/types/app/bsky/graph/defs.ts @@ -81,6 +81,7 @@ export const MODLIST = 'app.bsky.graph.defs#modlist' export interface ListViewerState { muted?: boolean + blocked?: string [k: string]: unknown } diff --git a/packages/api/src/client/types/app/bsky/graph/getListBlocks.ts b/packages/api/src/client/types/app/bsky/graph/getListBlocks.ts new file mode 100644 index 00000000000..052587c603e --- /dev/null +++ b/packages/api/src/client/types/app/bsky/graph/getListBlocks.ts @@ -0,0 +1,38 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { Headers, XRPCError } from '@atproto/xrpc' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { isObj, hasProp } from '../../../../util' +import { lexicons } from '../../../../lexicons' +import { CID } from 'multiformats/cid' +import * as AppBskyGraphDefs from './defs' + +export interface QueryParams { + limit?: number + cursor?: string +} + +export type InputSchema = undefined + +export interface OutputSchema { + cursor?: string + lists: AppBskyGraphDefs.ListView[] + [k: string]: unknown +} + +export interface CallOptions { + headers?: Headers +} + +export interface Response { + success: boolean + headers: Headers + data: OutputSchema +} + +export function toKnownErr(e: any) { + if (e instanceof XRPCError) { + } + return e +} diff --git a/packages/api/src/client/types/app/bsky/graph/listblock.ts b/packages/api/src/client/types/app/bsky/graph/listblock.ts new file mode 100644 index 00000000000..770dfbb0775 --- /dev/null +++ b/packages/api/src/client/types/app/bsky/graph/listblock.ts @@ -0,0 +1,26 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { isObj, hasProp } from '../../../../util' +import { lexicons } from '../../../../lexicons' +import { CID } from 'multiformats/cid' + +export interface Record { + subject: string + createdAt: string + [k: string]: unknown +} + +export function isRecord(v: unknown): v is Record { + return ( + isObj(v) && + hasProp(v, '$type') && + (v.$type === 'app.bsky.graph.listblock#main' || + v.$type === 'app.bsky.graph.listblock') + ) +} + +export function validateRecord(v: unknown): ValidationResult { + return lexicons.validate('app.bsky.graph.listblock#main', v) +} diff --git a/packages/bsky/src/api/app/bsky/actor/getProfile.ts b/packages/bsky/src/api/app/bsky/actor/getProfile.ts index aab10478a32..09699b8914b 100644 --- a/packages/bsky/src/api/app/bsky/actor/getProfile.ts +++ b/packages/bsky/src/api/app/bsky/actor/getProfile.ts @@ -1,48 +1,107 @@ import { InvalidRequestError } from '@atproto/xrpc-server' import { Server } from '../../../../lexicon' +import { QueryParams } from '../../../../lexicon/types/app/bsky/actor/getProfile' import { softDeleted } from '../../../../db/util' import AppContext from '../../../../context' +import { Database } from '../../../../db' +import { Actor } from '../../../../db/tables/actor' +import { + ActorService, + ProfileDetailHydrationState, +} from '../../../../services/actor' import { setRepoRev } from '../../../util' +import { createPipeline, noRules } from '../../../../pipeline' export default function (server: Server, ctx: AppContext) { + const getProfile = createPipeline(skeleton, hydration, noRules, presentation) server.app.bsky.actor.getProfile({ auth: ctx.authOptionalAccessOrRoleVerifier, handler: async ({ auth, params, res }) => { - const { actor } = params - const requester = 'did' in auth.credentials ? auth.credentials.did : null - const canViewTakendownProfile = - auth.credentials.type === 'role' && auth.credentials.triage const db = ctx.db.getReplica() const actorService = ctx.services.actor(db) + const viewer = 'did' in auth.credentials ? auth.credentials.did : null + const canViewTakendownProfile = + auth.credentials.type === 'role' && auth.credentials.triage - const [actorRes, repoRev] = await Promise.all([ - actorService.getActor(actor, true), - actorService.getRepoRev(requester), + const [result, repoRev] = await Promise.allSettled([ + getProfile( + { ...params, viewer, canViewTakendownProfile }, + { db, actorService }, + ), + actorService.getRepoRev(viewer), ]) - setRepoRev(res, repoRev) - if (!actorRes) { - throw new InvalidRequestError('Profile not found') - } - if (!canViewTakendownProfile && softDeleted(actorRes)) { - throw new InvalidRequestError( - 'Account has been taken down', - 'AccountTakedown', - ) + if (repoRev.status === 'fulfilled') { + setRepoRev(res, repoRev.value) } - const profile = await actorService.views.profileDetailed( - actorRes, - requester, - { includeSoftDeleted: canViewTakendownProfile }, - ) - if (!profile) { - throw new InvalidRequestError('Profile not found') + if (result.status === 'rejected') { + throw result.reason } return { encoding: 'application/json', - body: profile, + body: result.value, } }, }) } + +const skeleton = async ( + params: Params, + ctx: Context, +): Promise => { + const { actorService } = ctx + const { canViewTakendownProfile } = params + const actor = await actorService.getActor(params.actor, true) + if (!actor) { + throw new InvalidRequestError('Profile not found') + } + if (!canViewTakendownProfile && softDeleted(actor)) { + throw new InvalidRequestError( + 'Account has been taken down', + 'AccountTakedown', + ) + } + return { params, actor } +} + +const hydration = async (state: SkeletonState, ctx: Context) => { + const { actorService } = ctx + const { params, actor } = state + const { viewer, canViewTakendownProfile } = params + const hydration = await actorService.views.profileDetailHydration( + [actor.did], + { viewer, includeSoftDeleted: canViewTakendownProfile }, + ) + return { ...state, ...hydration } +} + +const presentation = (state: HydrationState, ctx: Context) => { + const { actorService } = ctx + const { params, actor } = state + const { viewer } = params + const profiles = actorService.views.profileDetailPresentation( + [actor.did], + state, + { viewer }, + ) + const profile = profiles[actor.did] + if (!profile) { + throw new InvalidRequestError('Profile not found') + } + return profile +} + +type Context = { + db: Database + actorService: ActorService +} + +type Params = QueryParams & { + viewer: string | null + canViewTakendownProfile: boolean +} + +type SkeletonState = { params: Params; actor: Actor } + +type HydrationState = SkeletonState & ProfileDetailHydrationState diff --git a/packages/bsky/src/api/app/bsky/actor/getProfiles.ts b/packages/bsky/src/api/app/bsky/actor/getProfiles.ts index 0fd7cdf844c..f2e0eb3fd50 100644 --- a/packages/bsky/src/api/app/bsky/actor/getProfiles.ts +++ b/packages/bsky/src/api/app/bsky/actor/getProfiles.ts @@ -1,31 +1,78 @@ +import { mapDefined } from '@atproto/common' import { Server } from '../../../../lexicon' +import { QueryParams } from '../../../../lexicon/types/app/bsky/actor/getProfiles' import AppContext from '../../../../context' +import { Database } from '../../../../db' +import { + ActorService, + ProfileDetailHydrationState, +} from '../../../../services/actor' import { setRepoRev } from '../../../util' +import { createPipeline, noRules } from '../../../../pipeline' export default function (server: Server, ctx: AppContext) { + const getProfile = createPipeline(skeleton, hydration, noRules, presentation) server.app.bsky.actor.getProfiles({ auth: ctx.authOptionalVerifier, handler: async ({ auth, params, res }) => { - const { actors } = params - const requester = auth.credentials.did const db = ctx.db.getReplica() const actorService = ctx.services.actor(db) + const viewer = auth.credentials.did - const [actorsRes, repoRev] = await Promise.all([ - actorService.getActors(actors), - actorService.getRepoRev(requester), + const [result, repoRev] = await Promise.all([ + getProfile({ ...params, viewer }, { db, actorService }), + actorService.getRepoRev(viewer), ]) + setRepoRev(res, repoRev) return { encoding: 'application/json', - body: { - profiles: await actorService.views.hydrateProfilesDetailed( - actorsRes, - requester, - ), - }, + body: result, } }, }) } + +const skeleton = async ( + params: Params, + ctx: Context, +): Promise => { + const { actorService } = ctx + const actors = await actorService.getActors(params.actors) + return { params, dids: actors.map((a) => a.did) } +} + +const hydration = async (state: SkeletonState, ctx: Context) => { + const { actorService } = ctx + const { params, dids } = state + const { viewer } = params + const hydration = await actorService.views.profileDetailHydration(dids, { + viewer, + }) + return { ...state, ...hydration } +} + +const presentation = (state: HydrationState, ctx: Context) => { + const { actorService } = ctx + const { params, dids } = state + const { viewer } = params + const profiles = actorService.views.profileDetailPresentation(dids, state, { + viewer, + }) + const profileViews = mapDefined(dids, (did) => profiles[did]) + return { profiles: profileViews } +} + +type Context = { + db: Database + actorService: ActorService +} + +type Params = QueryParams & { + viewer: string | null +} + +type SkeletonState = { params: Params; dids: string[] } + +type HydrationState = SkeletonState & ProfileDetailHydrationState diff --git a/packages/bsky/src/api/app/bsky/actor/getSuggestions.ts b/packages/bsky/src/api/app/bsky/actor/getSuggestions.ts index 6a142519965..18ab99debe2 100644 --- a/packages/bsky/src/api/app/bsky/actor/getSuggestions.ts +++ b/packages/bsky/src/api/app/bsky/actor/getSuggestions.ts @@ -1,66 +1,126 @@ +import { mapDefined } from '@atproto/common' import AppContext from '../../../../context' +import { Database } from '../../../../db' +import { Actor } from '../../../../db/tables/actor' import { notSoftDeletedClause } from '../../../../db/util' import { Server } from '../../../../lexicon' +import { QueryParams } from '../../../../lexicon/types/app/bsky/actor/getSuggestions' +import { createPipeline } from '../../../../pipeline' +import { ActorInfoMap, ActorService } from '../../../../services/actor' +import { BlockAndMuteState, GraphService } from '../../../../services/graph' export default function (server: Server, ctx: AppContext) { + const getSuggestions = createPipeline( + skeleton, + hydration, + noBlocksOrMutes, + presentation, + ) server.app.bsky.actor.getSuggestions({ auth: ctx.authOptionalVerifier, handler: async ({ params, auth }) => { - const { limit, cursor } = params - const viewer = auth.credentials.did - const db = ctx.db.getReplica() const actorService = ctx.services.actor(db) const graphService = ctx.services.graph(db) + const viewer = auth.credentials.did - const { ref } = db.db.dynamic - - let suggestionsQb = db.db - .selectFrom('suggested_follow') - .innerJoin('actor', 'actor.did', 'suggested_follow.did') - .innerJoin('profile_agg', 'profile_agg.did', 'actor.did') - .where(notSoftDeletedClause(ref('actor'))) - .where('suggested_follow.did', '!=', viewer ?? '') - .whereNotExists((qb) => - qb - .selectFrom('follow') - .selectAll() - .where('creator', '=', viewer ?? '') - .whereRef('subjectDid', '=', ref('actor.did')), - ) - .whereNotExists(graphService.blockQb(viewer, [ref('actor.did')])) - .selectAll() - .select('profile_agg.postsCount as postsCount') - .limit(limit) - .orderBy('suggested_follow.order', 'asc') - - if (cursor) { - const cursorRow = await db.db - .selectFrom('suggested_follow') - .where('did', '=', cursor) - .selectAll() - .executeTakeFirst() - if (cursorRow) { - suggestionsQb = suggestionsQb.where( - 'suggested_follow.order', - '>', - cursorRow.order, - ) - } - } - - const suggestionsRes = await suggestionsQb.execute() + const result = await getSuggestions( + { ...params, viewer }, + { db, actorService, graphService }, + ) return { encoding: 'application/json', - body: { - cursor: suggestionsRes.at(-1)?.did, - actors: await actorService.views.hydrateProfiles( - suggestionsRes, - viewer, - ), - }, + body: result, } }, }) } + +const skeleton = async ( + params: Params, + ctx: Context, +): Promise => { + const { db } = ctx + const { limit, cursor, viewer } = params + const { ref } = db.db.dynamic + let suggestionsQb = db.db + .selectFrom('suggested_follow') + .innerJoin('actor', 'actor.did', 'suggested_follow.did') + .innerJoin('profile_agg', 'profile_agg.did', 'actor.did') + .where(notSoftDeletedClause(ref('actor'))) + .where('suggested_follow.did', '!=', viewer ?? '') + .whereNotExists((qb) => + qb + .selectFrom('follow') + .selectAll() + .where('creator', '=', viewer ?? '') + .whereRef('subjectDid', '=', ref('actor.did')), + ) + .selectAll() + .select('profile_agg.postsCount as postsCount') + .limit(limit) + .orderBy('suggested_follow.order', 'asc') + + if (cursor) { + const cursorRow = await db.db + .selectFrom('suggested_follow') + .where('did', '=', cursor) + .selectAll() + .executeTakeFirst() + if (cursorRow) { + suggestionsQb = suggestionsQb.where( + 'suggested_follow.order', + '>', + cursorRow.order, + ) + } + } + const suggestions = await suggestionsQb.execute() + return { params, suggestions, cursor: suggestions.at(-1)?.did } +} + +const hydration = async (state: SkeletonState, ctx: Context) => { + const { graphService, actorService } = ctx + const { params, suggestions } = state + const { viewer } = params + const [actors, bam] = await Promise.all([ + actorService.views.profiles(suggestions, viewer), + graphService.getBlockAndMuteState( + viewer ? suggestions.map((sug) => [viewer, sug.did]) : [], + ), + ]) + return { ...state, bam, actors } +} + +const noBlocksOrMutes = (state: HydrationState) => { + const { viewer } = state.params + if (!viewer) return state + state.suggestions = state.suggestions.filter( + (item) => + !state.bam.block([viewer, item.did]) && + !state.bam.mute([viewer, item.did]), + ) + return state +} + +const presentation = (state: HydrationState) => { + const { suggestions, actors, cursor } = state + const suggestedActors = mapDefined(suggestions, (sug) => actors[sug.did]) + return { actors: suggestedActors, cursor } +} + +type Context = { + db: Database + actorService: ActorService + graphService: GraphService +} + +type Params = QueryParams & { viewer: string | null } + +type SkeletonState = { params: Params; suggestions: Actor[]; cursor?: string } + +type HydrationState = SkeletonState & { + bam: BlockAndMuteState + actors: ActorInfoMap +} diff --git a/packages/bsky/src/api/app/bsky/actor/searchActors.ts b/packages/bsky/src/api/app/bsky/actor/searchActors.ts index 9f462608885..df5821a03f9 100644 --- a/packages/bsky/src/api/app/bsky/actor/searchActors.ts +++ b/packages/bsky/src/api/app/bsky/actor/searchActors.ts @@ -27,7 +27,7 @@ export default function (server: Server, ctx: AppContext) { const actors = await ctx.services .actor(db) - .views.hydrateProfiles(results, requester) + .views.profilesList(results, requester) const filtered = actors.filter( (actor) => !actor.viewer?.blocking && !actor.viewer?.blockedBy, ) diff --git a/packages/bsky/src/api/app/bsky/actor/searchActorsTypeahead.ts b/packages/bsky/src/api/app/bsky/actor/searchActorsTypeahead.ts index b8533073be5..64bcd811d02 100644 --- a/packages/bsky/src/api/app/bsky/actor/searchActorsTypeahead.ts +++ b/packages/bsky/src/api/app/bsky/actor/searchActorsTypeahead.ts @@ -23,11 +23,14 @@ export default function (server: Server, ctx: AppContext) { const actors = await ctx.services .actor(db) - .views.hydrateProfilesBasic(results, requester) + .views.profilesBasic(results, requester, { omitLabels: true }) - const filtered = actors.filter( - (actor) => !actor.viewer?.blocking && !actor.viewer?.blockedBy, - ) + const SKIP = [] + const filtered = results.flatMap((res) => { + const actor = actors[res.did] + if (actor.viewer?.blocking || actor.viewer?.blockedBy) return SKIP + return actor + }) return { encoding: 'application/json', diff --git a/packages/bsky/src/api/app/bsky/feed/getActorFeeds.ts b/packages/bsky/src/api/app/bsky/feed/getActorFeeds.ts index a9e02d2cd59..deb5c3a5a1b 100644 --- a/packages/bsky/src/api/app/bsky/feed/getActorFeeds.ts +++ b/packages/bsky/src/api/app/bsky/feed/getActorFeeds.ts @@ -34,14 +34,13 @@ export default function (server: Server, ctx: AppContext) { keyset, }) - const [feedsRes, creatorProfile] = await Promise.all([ + const [feedsRes, profiles] = await Promise.all([ feedsQb.execute(), - actorService.views.profile(creatorRes, viewer), + actorService.views.profiles([creatorRes], viewer), ]) - if (!creatorProfile) { + if (!profiles[creatorRes.did]) { throw new InvalidRequestError(`Actor not found: ${actor}`) } - const profiles = { [creatorProfile.did]: creatorProfile } const feeds = feedsRes.map((row) => { const feed = { diff --git a/packages/bsky/src/api/app/bsky/feed/getActorLikes.ts b/packages/bsky/src/api/app/bsky/feed/getActorLikes.ts index 7c634e0a810..73b8b070262 100644 --- a/packages/bsky/src/api/app/bsky/feed/getActorLikes.ts +++ b/packages/bsky/src/api/app/bsky/feed/getActorLikes.ts @@ -1,71 +1,127 @@ +import { InvalidRequestError } from '@atproto/xrpc-server' import { Server } from '../../../../lexicon' +import { QueryParams } from '../../../../lexicon/types/app/bsky/feed/getActorLikes' import { FeedKeyset } from '../util/feed' import { paginate } from '../../../../db/pagination' import AppContext from '../../../../context' -import { InvalidRequestError } from '@atproto/xrpc-server' import { setRepoRev } from '../../../util' +import { + FeedHydrationState, + FeedRow, + FeedService, +} from '../../../../services/feed' +import { Database } from '../../../../db' +import { ActorService } from '../../../../services/actor' +import { GraphService } from '../../../../services/graph' +import { createPipeline } from '../../../../pipeline' export default function (server: Server, ctx: AppContext) { + const getActorLikes = createPipeline( + skeleton, + hydration, + noPostBlocks, + presentation, + ) server.app.bsky.feed.getActorLikes({ auth: ctx.authOptionalVerifier, handler: async ({ params, auth, res }) => { - const { actor, limit, cursor } = params const viewer = auth.credentials.did const db = ctx.db.getReplica() - const { ref } = db.db.dynamic - const actorService = ctx.services.actor(db) const feedService = ctx.services.feed(db) const graphService = ctx.services.graph(db) - // maybe resolve did first - const actorRes = await actorService.getActor(actor) - if (!actorRes) { - throw new InvalidRequestError('Profile not found') - } - const actorDid = actorRes.did + const [result, repoRev] = await Promise.all([ + getActorLikes( + { ...params, viewer }, + { db, actorService, feedService, graphService }, + ), + actorService.getRepoRev(viewer), + ]) + + setRepoRev(res, repoRev) - if (!viewer || viewer !== actorDid) { - throw new InvalidRequestError('Profile not found') + return { + encoding: 'application/json', + body: result, } + }, + }) +} - let feedItemsQb = feedService - .selectFeedItemQb() - .innerJoin('like', 'like.subject', 'feed_item.uri') - .where('like.creator', '=', actorDid) +const skeleton = async ( + params: Params, + ctx: Context, +): Promise => { + const { db, actorService, feedService } = ctx + const { actor, limit, cursor, viewer } = params + const { ref } = db.db.dynamic - if (viewer !== null) { - feedItemsQb = feedItemsQb.whereNotExists( - graphService.blockQb(viewer, [ref('post.creator')]), - ) - } + const actorRes = await actorService.getActor(actor) + if (!actorRes) { + throw new InvalidRequestError('Profile not found') + } + const actorDid = actorRes.did - const keyset = new FeedKeyset( - ref('feed_item.sortAt'), - ref('feed_item.cid'), - ) + if (!viewer || viewer !== actorDid) { + throw new InvalidRequestError('Profile not found') + } - feedItemsQb = paginate(feedItemsQb, { - limit, - cursor, - keyset, - }) + let feedItemsQb = feedService + .selectFeedItemQb() + .innerJoin('like', 'like.subject', 'feed_item.uri') + .where('like.creator', '=', actorDid) - const [feedItems, repoRev] = await Promise.all([ - feedItemsQb.execute(), - actorService.getRepoRev(viewer), - ]) - setRepoRev(res, repoRev) + const keyset = new FeedKeyset(ref('feed_item.sortAt'), ref('feed_item.cid')) - const feed = await feedService.hydrateFeed(feedItems, viewer) + feedItemsQb = paginate(feedItemsQb, { + limit, + cursor, + keyset, + }) - return { - encoding: 'application/json', - body: { - feed, - cursor: keyset.packFromResult(feedItems), - }, - } - }, + const feedItems = await feedItemsQb.execute() + + return { params, feedItems, cursor: keyset.packFromResult(feedItems) } +} + +const hydration = async (state: SkeletonState, ctx: Context) => { + const { feedService } = ctx + const { params, feedItems } = state + const refs = feedService.feedItemRefs(feedItems) + const hydrated = await feedService.feedHydration({ + ...refs, + viewer: params.viewer, }) + return { ...state, ...hydrated } +} + +const noPostBlocks = (state: HydrationState) => { + const { viewer } = state.params + state.feedItems = state.feedItems.filter( + (item) => !viewer || !state.bam.block([viewer, item.postAuthorDid]), + ) + return state } + +const presentation = (state: HydrationState, ctx: Context) => { + const { feedService } = ctx + const { feedItems, cursor, params } = state + const feed = feedService.views.formatFeed(feedItems, state, { + viewer: params.viewer, + }) + return { feed, cursor } +} + +type Context = { + db: Database + feedService: FeedService + actorService: ActorService + graphService: GraphService +} + +type Params = QueryParams & { viewer: string | null } + +type SkeletonState = { params: Params; feedItems: FeedRow[]; cursor?: string } + +type HydrationState = SkeletonState & FeedHydrationState diff --git a/packages/bsky/src/api/app/bsky/feed/getAuthorFeed.ts b/packages/bsky/src/api/app/bsky/feed/getAuthorFeed.ts index 06b93513444..9f25eb131e1 100644 --- a/packages/bsky/src/api/app/bsky/feed/getAuthorFeed.ts +++ b/packages/bsky/src/api/app/bsky/feed/getAuthorFeed.ts @@ -1,111 +1,171 @@ +import { InvalidRequestError } from '@atproto/xrpc-server' import { Server } from '../../../../lexicon' +import { QueryParams } from '../../../../lexicon/types/app/bsky/feed/getAuthorFeed' import { FeedKeyset } from '../util/feed' import { paginate } from '../../../../db/pagination' import AppContext from '../../../../context' -import { InvalidRequestError } from '@atproto/xrpc-server' import { setRepoRev } from '../../../util' +import { Database } from '../../../../db' +import { + FeedHydrationState, + FeedRow, + FeedService, +} from '../../../../services/feed' +import { ActorService } from '../../../../services/actor' +import { GraphService } from '../../../../services/graph' +import { createPipeline } from '../../../../pipeline' export default function (server: Server, ctx: AppContext) { + const getAuthorFeed = createPipeline( + skeleton, + hydration, + noBlocksOrMutedReposts, + presentation, + ) server.app.bsky.feed.getAuthorFeed({ auth: ctx.authOptionalAccessOrRoleVerifier, handler: async ({ params, auth, res }) => { - const { actor, limit, cursor, filter } = params - const viewer = - auth.credentials.type === 'access' ? auth.credentials.did : null - const db = ctx.db.getReplica() - const { ref } = db.db.dynamic - - // first verify there is not a block between requester & subject - if (viewer !== null) { - const blocks = await ctx.services.graph(db).getBlocks(viewer, actor) - if (blocks.blocking) { - throw new InvalidRequestError( - `Requester has blocked actor: ${actor}`, - 'BlockedActor', - ) - } else if (blocks.blockedBy) { - throw new InvalidRequestError( - `Requester is blocked by actor: $${actor}`, - 'BlockedByActor', - ) - } - } - const actorService = ctx.services.actor(db) const feedService = ctx.services.feed(db) const graphService = ctx.services.graph(db) + const viewer = + auth.credentials.type === 'access' ? auth.credentials.did : null - // maybe resolve did first - const actorRes = await actorService.getActor(actor) - if (!actorRes) { - throw new InvalidRequestError('Profile not found') - } - const actorDid = actorRes.did - - // defaults to posts, reposts, and replies - let feedItemsQb = feedService - .selectFeedItemQb() - .where('originatorDid', '=', actorDid) - - if (filter === 'posts_with_media') { - feedItemsQb = feedItemsQb - // and only your own posts/reposts - .where('post.creator', '=', actorDid) - // only posts with media - .whereExists((qb) => - qb - .selectFrom('post_embed_image') - .select('post_embed_image.postUri') - .whereRef('post_embed_image.postUri', '=', 'feed_item.postUri'), - ) - } else if (filter === 'posts_no_replies') { - feedItemsQb = feedItemsQb.where((qb) => - qb - .where('post.replyParent', 'is', null) - .orWhere('type', '=', 'repost'), - ) - } - - if (viewer !== null) { - feedItemsQb = feedItemsQb - .where((qb) => - // Hide reposts of muted content - qb - .where('type', '=', 'post') - .orWhere((qb) => - graphService.whereNotMuted(qb, viewer, [ref('post.creator')]), - ), - ) - .whereNotExists(graphService.blockQb(viewer, [ref('post.creator')])) - } - - const keyset = new FeedKeyset( - ref('feed_item.sortAt'), - ref('feed_item.cid'), - ) - - feedItemsQb = paginate(feedItemsQb, { - limit, - cursor, - keyset, - }) - - const [feedItems, repoRev] = await Promise.all([ - feedItemsQb.execute(), + const [result, repoRev] = await Promise.all([ + getAuthorFeed( + { ...params, viewer }, + { db, actorService, feedService, graphService }, + ), actorService.getRepoRev(viewer), ]) - setRepoRev(res, repoRev) - const feed = await feedService.hydrateFeed(feedItems, viewer) + setRepoRev(res, repoRev) return { encoding: 'application/json', - body: { - feed, - cursor: keyset.packFromResult(feedItems), - }, + body: result, } }, }) } + +export const skeleton = async ( + params: Params, + ctx: Context, +): Promise => { + const { cursor, limit, actor, filter, viewer } = params + const { db, actorService, feedService, graphService } = ctx + const { ref } = db.db.dynamic + + // maybe resolve did first + const actorRes = await actorService.getActor(actor) + if (!actorRes) { + throw new InvalidRequestError('Profile not found') + } + const actorDid = actorRes.did + + // verify there is not a block between requester & subject + if (viewer !== null) { + const blocks = await graphService.getBlockState([[viewer, actorDid]]) + if (blocks.blocking([viewer, actorDid])) { + throw new InvalidRequestError( + `Requester has blocked actor: ${actor}`, + 'BlockedActor', + ) + } + if (blocks.blockedBy([viewer, actorDid])) { + throw new InvalidRequestError( + `Requester is blocked by actor: $${actor}`, + 'BlockedByActor', + ) + } + } + + // defaults to posts, reposts, and replies + let feedItemsQb = feedService + .selectFeedItemQb() + .where('originatorDid', '=', actorDid) + + if (filter === 'posts_with_media') { + feedItemsQb = feedItemsQb + // and only your own posts/reposts + .where('post.creator', '=', actorDid) + // only posts with media + .whereExists((qb) => + qb + .selectFrom('post_embed_image') + .select('post_embed_image.postUri') + .whereRef('post_embed_image.postUri', '=', 'feed_item.postUri'), + ) + } else if (filter === 'posts_no_replies') { + feedItemsQb = feedItemsQb.where((qb) => + qb.where('post.replyParent', 'is', null).orWhere('type', '=', 'repost'), + ) + } + + const keyset = new FeedKeyset(ref('feed_item.sortAt'), ref('feed_item.cid')) + + feedItemsQb = paginate(feedItemsQb, { + limit, + cursor, + keyset, + }) + + const feedItems = await feedItemsQb.execute() + + return { + params, + feedItems, + cursor: keyset.packFromResult(feedItems), + } +} + +const hydration = async (state: SkeletonState, ctx: Context) => { + const { feedService } = ctx + const { params, feedItems } = state + const refs = feedService.feedItemRefs(feedItems) + const hydrated = await feedService.feedHydration({ + ...refs, + viewer: params.viewer, + }) + return { ...state, ...hydrated } +} + +const noBlocksOrMutedReposts = (state: HydrationState) => { + const { viewer } = state.params + state.feedItems = state.feedItems.filter((item) => { + if (!viewer) return true + return ( + !state.bam.block([viewer, item.postAuthorDid]) && + (item.type === 'post' || !state.bam.mute([viewer, item.postAuthorDid])) + ) + }) + return state +} + +const presentation = (state: HydrationState, ctx: Context) => { + const { feedService } = ctx + const { feedItems, cursor, params } = state + const feed = feedService.views.formatFeed(feedItems, state, { + viewer: params.viewer, + }) + return { feed, cursor } +} + +type Context = { + db: Database + actorService: ActorService + feedService: FeedService + graphService: GraphService +} + +type Params = QueryParams & { viewer: string | null } + +type SkeletonState = { + params: Params + feedItems: FeedRow[] + cursor?: string +} + +type HydrationState = SkeletonState & FeedHydrationState diff --git a/packages/bsky/src/api/app/bsky/feed/getFeed.ts b/packages/bsky/src/api/app/bsky/feed/getFeed.ts index 910ea514e94..8af159decd3 100644 --- a/packages/bsky/src/api/app/bsky/feed/getFeed.ts +++ b/packages/bsky/src/api/app/bsky/feed/getFeed.ts @@ -13,45 +13,45 @@ import { import { AtpAgent, AppBskyFeedGetFeedSkeleton } from '@atproto/api' import { QueryParams as GetFeedParams } from '../../../../lexicon/types/app/bsky/feed/getFeed' import { OutputSchema as SkeletonOutput } from '../../../../lexicon/types/app/bsky/feed/getFeedSkeleton' +import { SkeletonFeedPost } from '../../../../lexicon/types/app/bsky/feed/defs' import { Server } from '../../../../lexicon' import AppContext from '../../../../context' import { AlgoResponse } from '../../../../feed-gen/types' import { Database } from '../../../../db' +import { + FeedHydrationState, + FeedRow, + FeedService, +} from '../../../../services/feed' +import { createPipeline } from '../../../../pipeline' export default function (server: Server, ctx: AppContext) { + const getFeed = createPipeline( + skeleton, + hydration, + noBlocksOrMutes, + presentation, + ) server.app.bsky.feed.getFeed({ auth: ctx.authVerifierAnyAudience, handler: async ({ params, auth, req }) => { - const { feed } = params - const viewer = auth.credentials.did - const db = ctx.db.getReplica() const feedService = ctx.services.feed(db) - const localAlgo = ctx.algos[feed] - - const timerSkele = new ServerTimer('skele').start() - const { feedItems, ...rest } = - localAlgo !== undefined - ? await localAlgo(ctx, params, viewer) - : await skeletonFromFeedGen( - ctx, - db, - params, - viewer, - req.headers['authorization'], - ) - timerSkele.stop() - - const timerHydr = new ServerTimer('hydr').start() - const hydrated = await feedService.hydrateFeed(feedItems, viewer) - timerHydr.stop() + const viewer = auth.credentials.did + + const { timerSkele, timerHydr, ...result } = await getFeed( + { ...params, viewer }, + { + db, + feedService, + appCtx: ctx, + authorization: req.headers['authorization'], + }, + ) return { encoding: 'application/json', - body: { - ...rest, - feed: hydrated, - }, + body: result, headers: { 'server-timing': serverTimingHeader([timerSkele, timerHydr]), }, @@ -60,13 +60,94 @@ export default function (server: Server, ctx: AppContext) { }) } -async function skeletonFromFeedGen( - ctx: AppContext, - db: Database, +const skeleton = async ( + params: Params, + ctx: Context, +): Promise => { + const timerSkele = new ServerTimer('skele').start() + const localAlgo = ctx.appCtx.algos[params.feed] + const feedParams: GetFeedParams = { + feed: params.feed, + limit: params.limit, + cursor: params.cursor, + } + const { feedItems, cursor, ...passthrough } = + localAlgo !== undefined + ? await localAlgo(ctx.appCtx, params, params.viewer) + : await skeletonFromFeedGen(ctx, feedParams) + return { + params, + cursor, + feedItems, + timerSkele: timerSkele.stop(), + passthrough, + } +} + +const hydration = async (state: SkeletonState, ctx: Context) => { + const timerHydr = new ServerTimer('hydr').start() + const { feedService } = ctx + const { params, feedItems } = state + const refs = feedService.feedItemRefs(feedItems) + const hydrated = await feedService.feedHydration({ + ...refs, + viewer: params.viewer, + }) + return { ...state, ...hydrated, timerHydr: timerHydr.stop() } +} + +const noBlocksOrMutes = (state: HydrationState) => { + const { viewer } = state.params + state.feedItems = state.feedItems.filter( + (item) => + !state.bam.block([viewer, item.postAuthorDid]) && + !state.bam.block([viewer, item.originatorDid]) && + !state.bam.mute([viewer, item.postAuthorDid]) && + !state.bam.mute([viewer, item.originatorDid]), + ) + return state +} + +const presentation = (state: HydrationState, ctx: Context) => { + const { feedService } = ctx + const { feedItems, cursor, passthrough, params } = state + const feed = feedService.views.formatFeed(feedItems, state, { + viewer: params.viewer, + }) + return { + feed, + cursor, + timerSkele: state.timerSkele, + timerHydr: state.timerHydr, + ...passthrough, + } +} + +type Context = { + db: Database + feedService: FeedService + appCtx: AppContext + authorization?: string +} + +type Params = GetFeedParams & { viewer: string } + +type SkeletonState = { + params: Params + feedItems: FeedRow[] + passthrough: Record // pass through additional items in feedgen response + cursor?: string + timerSkele: ServerTimer +} + +type HydrationState = SkeletonState & + FeedHydrationState & { feedItems: FeedRow[]; timerHydr: ServerTimer } + +const skeletonFromFeedGen = async ( + ctx: Context, params: GetFeedParams, - viewer: string, - authorization?: string, -): Promise { +): Promise => { + const { db, appCtx, authorization } = ctx const { feed } = params // Resolve and fetch feed skeleton const found = await db.db @@ -81,7 +162,7 @@ async function skeletonFromFeedGen( let resolved: DidDocument | null try { - resolved = await ctx.idResolver.did.resolve(feedDid) + resolved = await appCtx.idResolver.did.resolve(feedDid) } catch (err) { if (err instanceof PoorlyFormattedDidDocumentError) { throw new InvalidRequestError(`invalid did document: ${feedDid}`) @@ -105,7 +186,7 @@ async function skeletonFromFeedGen( try { // @TODO currently passthrough auth headers from pds const headers: Record = authorization - ? { authorization } + ? { authorization: authorization } : {} const result = await agent.api.app.bsky.feed.getFeedSkeleton(params, { headers, @@ -129,13 +210,34 @@ async function skeletonFromFeedGen( throw err } - const { feed: skeletonFeed, ...rest } = skeleton - const cleanedFeed = await ctx.services - .feed(db) - .cleanFeedSkeleton(skeletonFeed, params.limit, viewer) + const { feed: feedSkele, ...skele } = skeleton + const feedItems = await skeletonToFeedItems( + feedSkele.slice(0, params.limit), + ctx, + ) - return { - ...rest, - feedItems: cleanedFeed, + return { ...skele, feedItems } +} + +const skeletonToFeedItems = async ( + skeleton: SkeletonFeedPost[], + ctx: Context, +): Promise => { + const { feedService } = ctx + const feedItemUris = skeleton.map(getSkeleFeedItemUri) + const feedItemsRaw = await feedService.getFeedItems(feedItemUris) + const results: FeedRow[] = [] + for (const skeleItem of skeleton) { + const feedItem = feedItemsRaw[getSkeleFeedItemUri(skeleItem)] + if (feedItem && feedItem.postUri === skeleItem.post) { + results.push(feedItem) + } } + return results +} + +const getSkeleFeedItemUri = (item: SkeletonFeedPost) => { + return typeof item.reason?.repost === 'string' + ? item.reason.repost + : item.post } diff --git a/packages/bsky/src/api/app/bsky/feed/getFeedGenerator.ts b/packages/bsky/src/api/app/bsky/feed/getFeedGenerator.ts index 2f433e3f0db..6207ba1e1aa 100644 --- a/packages/bsky/src/api/app/bsky/feed/getFeedGenerator.ts +++ b/packages/bsky/src/api/app/bsky/feed/getFeedGenerator.ts @@ -16,6 +16,7 @@ export default function (server: Server, ctx: AppContext) { const db = ctx.db.getReplica() const feedService = ctx.services.feed(db) + const actorService = ctx.services.actor(db) const got = await feedService.getFeedGeneratorInfos([feed], viewer) const feedInfo = got[feed] @@ -46,7 +47,7 @@ export default function (server: Server, ctx: AppContext) { ) } - const profiles = await feedService.getActorInfos( + const profiles = await actorService.views.profilesBasic( [feedInfo.creator], viewer, ) diff --git a/packages/bsky/src/api/app/bsky/feed/getFeedGenerators.ts b/packages/bsky/src/api/app/bsky/feed/getFeedGenerators.ts index a81d962cb8b..a973ee6c2fb 100644 --- a/packages/bsky/src/api/app/bsky/feed/getFeedGenerators.ts +++ b/packages/bsky/src/api/app/bsky/feed/getFeedGenerators.ts @@ -1,32 +1,79 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { FeedGenInfo, FeedService } from '../../../../services/feed' +import { createPipeline, noRules } from '../../../../pipeline' +import { ActorInfoMap, ActorService } from '../../../../services/actor' +import { Database } from '../../../../db' export default function (server: Server, ctx: AppContext) { + const getFeedGenerators = createPipeline( + skeleton, + hydration, + noRules, + presentation, + ) server.app.bsky.feed.getFeedGenerators({ auth: ctx.authOptionalVerifier, handler: async ({ params, auth }) => { const { feeds } = params - const requester = auth.credentials.did - + const viewer = auth.credentials.did const db = ctx.db.getReplica() const feedService = ctx.services.feed(db) + const actorService = ctx.services.actor(db) - const genInfos = await feedService.getFeedGeneratorInfos(feeds, requester) - const genList = Object.values(genInfos) - - const creators = genList.map((gen) => gen.creator) - const profiles = await feedService.getActorInfos(creators, requester) - - const feedViews = genList.map((gen) => - feedService.views.formatFeedGeneratorView(gen, profiles), + const view = await getFeedGenerators( + { feeds, viewer }, + { db, feedService, actorService }, ) return { encoding: 'application/json', - body: { - feeds: feedViews, - }, + body: view, } }, }) } + +const skeleton = async (params: Params, ctx: Context) => { + const { feedService } = ctx + const genInfos = await feedService.getFeedGeneratorInfos( + params.feeds, + params.viewer, + ) + return { + params, + generators: Object.values(genInfos), + } +} + +const hydration = async (state: SkeletonState, ctx: Context) => { + const { actorService } = ctx + const profiles = await actorService.views.profilesBasic( + state.generators.map((gen) => gen.creator), + state.params.viewer, + ) + return { + ...state, + profiles, + } +} + +const presentation = (state: HydrationState, ctx: Context) => { + const { feedService } = ctx + const feeds = state.generators.map((gen) => + feedService.views.formatFeedGeneratorView(gen, state.profiles), + ) + return { feeds } +} + +type Context = { + db: Database + feedService: FeedService + actorService: ActorService +} + +type Params = { viewer: string | null; feeds: string[] } + +type SkeletonState = { params: Params; generators: FeedGenInfo[] } + +type HydrationState = SkeletonState & { profiles: ActorInfoMap } diff --git a/packages/bsky/src/api/app/bsky/feed/getFeedSkeleton.ts b/packages/bsky/src/api/app/bsky/feed/getFeedSkeleton.ts index 9f808b35726..5d65044f86f 100644 --- a/packages/bsky/src/api/app/bsky/feed/getFeedSkeleton.ts +++ b/packages/bsky/src/api/app/bsky/feed/getFeedSkeleton.ts @@ -1,6 +1,7 @@ import { InvalidRequestError } from '@atproto/xrpc-server' import { Server } from '../../../../lexicon' import AppContext from '../../../../context' +import { toSkeletonItem } from '../../../../feed-gen/types' export default function (server: Server, ctx: AppContext) { server.app.bsky.feed.getFeedSkeleton({ @@ -14,24 +15,14 @@ export default function (server: Server, ctx: AppContext) { throw new InvalidRequestError('Unknown feed', 'UnknownFeed') } - const { cursor, feedItems } = await localAlgo(ctx, params, viewer) - - const skeleton = feedItems.map((item) => ({ - post: item.postUri, - reason: - item.uri === item.postUri - ? undefined - : { - $type: 'app.bsky.feed.defs#skeletonReasonRepost', - repost: item.uri, - }, - })) + const result = await localAlgo(ctx, params, viewer) return { encoding: 'application/json', body: { - cursor, - feed: skeleton, + // @TODO should we proactively filter blocks/mutes from the skeleton, or treat this similar to other custom feeds? + feed: result.feedItems.map(toSkeletonItem), + cursor: result.cursor, }, } }, diff --git a/packages/bsky/src/api/app/bsky/feed/getLikes.ts b/packages/bsky/src/api/app/bsky/feed/getLikes.ts index 7ecd3b4e2db..893617f6bb0 100644 --- a/packages/bsky/src/api/app/bsky/feed/getLikes.ts +++ b/packages/bsky/src/api/app/bsky/feed/getLikes.ts @@ -1,70 +1,127 @@ import { mapDefined } from '@atproto/common' import { Server } from '../../../../lexicon' +import { QueryParams } from '../../../../lexicon/types/app/bsky/feed/getLikes' import { paginate, TimeCidKeyset } from '../../../../db/pagination' import AppContext from '../../../../context' import { notSoftDeletedClause } from '../../../../db/util' +import { BlockAndMuteState, GraphService } from '../../../../services/graph' +import { ActorInfoMap, ActorService } from '../../../../services/actor' +import { Actor } from '../../../../db/tables/actor' +import { Database } from '../../../../db' +import { createPipeline } from '../../../../pipeline' export default function (server: Server, ctx: AppContext) { + const getLikes = createPipeline(skeleton, hydration, noBlocks, presentation) server.app.bsky.feed.getLikes({ auth: ctx.authOptionalVerifier, handler: async ({ params, auth }) => { - const { uri, limit, cursor, cid } = params - const requester = auth.credentials.did - const db = ctx.db.getReplica() + const actorService = ctx.services.actor(db) const graphService = ctx.services.graph(db) + const viewer = auth.credentials.did - const { ref } = db.db.dynamic - - let builder = db.db - .selectFrom('like') - .where('like.subject', '=', uri) - .innerJoin('actor as creator', 'creator.did', 'like.creator') - .where(notSoftDeletedClause(ref('creator'))) - .whereNotExists(graphService.blockQb(requester, [ref('like.creator')])) - .selectAll('creator') - .select([ - 'like.cid as cid', - 'like.createdAt as createdAt', - 'like.indexedAt as indexedAt', - 'like.sortAt as sortAt', - ]) - - if (cid) { - builder = builder.where('like.subjectCid', '=', cid) - } - - const keyset = new TimeCidKeyset(ref('like.sortAt'), ref('like.cid')) - builder = paginate(builder, { - limit, - cursor, - keyset, - }) - - const likesRes = await builder.execute() - const actors = await ctx.services - .actor(db) - .views.profiles(likesRes, requester) - - const likes = mapDefined(likesRes, (row) => - actors[row.did] - ? { - createdAt: row.createdAt, - indexedAt: row.indexedAt, - actor: actors[row.did], - } - : undefined, + const result = await getLikes( + { ...params, viewer }, + { db, actorService, graphService }, ) return { encoding: 'application/json', - body: { - uri, - cid, - cursor: keyset.packFromResult(likesRes), - likes, - }, + body: result, } }, }) } + +const skeleton = async ( + params: Params, + ctx: Context, +): Promise => { + const { db } = ctx + const { uri, cid, limit, cursor } = params + const { ref } = db.db.dynamic + + let builder = db.db + .selectFrom('like') + .where('like.subject', '=', uri) + .innerJoin('actor as creator', 'creator.did', 'like.creator') + .where(notSoftDeletedClause(ref('creator'))) + .selectAll('creator') + .select([ + 'like.cid as cid', + 'like.createdAt as createdAt', + 'like.indexedAt as indexedAt', + 'like.sortAt as sortAt', + ]) + + if (cid) { + builder = builder.where('like.subjectCid', '=', cid) + } + + const keyset = new TimeCidKeyset(ref('like.sortAt'), ref('like.cid')) + builder = paginate(builder, { + limit, + cursor, + keyset, + }) + + const likes = await builder.execute() + + return { params, likes, cursor: keyset.packFromResult(likes) } +} + +const hydration = async (state: SkeletonState, ctx: Context) => { + const { graphService, actorService } = ctx + const { params, likes } = state + const { viewer } = params + const [actors, bam] = await Promise.all([ + actorService.views.profiles(likes, viewer), + graphService.getBlockAndMuteState( + viewer ? likes.map((like) => [viewer, like.did]) : [], + ), + ]) + return { ...state, bam, actors } +} + +const noBlocks = (state: HydrationState) => { + const { viewer } = state.params + if (!viewer) return state + state.likes = state.likes.filter( + (item) => !state.bam.block([viewer, item.did]), + ) + return state +} + +const presentation = (state: HydrationState) => { + const { params, likes, actors, cursor } = state + const { uri, cid } = params + const likesView = mapDefined(likes, (like) => + actors[like.did] + ? { + createdAt: like.createdAt, + indexedAt: like.indexedAt, + actor: actors[like.did], + } + : undefined, + ) + return { likes: likesView, cursor, uri, cid } +} + +type Context = { + db: Database + actorService: ActorService + graphService: GraphService +} + +type Params = QueryParams & { viewer: string | null } + +type SkeletonState = { + params: Params + likes: (Actor & { createdAt: string })[] + cursor?: string +} + +type HydrationState = SkeletonState & { + bam: BlockAndMuteState + actors: ActorInfoMap +} diff --git a/packages/bsky/src/api/app/bsky/feed/getPostThread.ts b/packages/bsky/src/api/app/bsky/feed/getPostThread.ts index 95bc83e86c1..2d10ff98006 100644 --- a/packages/bsky/src/api/app/bsky/feed/getPostThread.ts +++ b/packages/bsky/src/api/app/bsky/feed/getPostThread.ts @@ -1,97 +1,106 @@ import { InvalidRequestError } from '@atproto/xrpc-server' import { Server } from '../../../../lexicon' -import AppContext from '../../../../context' -import { - FeedRow, - ActorInfoMap, - PostEmbedViews, - PostBlocksMap, -} from '../../../../services/feed/types' -import { FeedService, PostInfoMap } from '../../../../services/feed' -import { Labels } from '../../../../services/label' import { BlockedPost, NotFoundPost, ThreadViewPost, isNotFoundPost, } from '../../../../lexicon/types/app/bsky/feed/defs' +import { QueryParams } from '../../../../lexicon/types/app/bsky/feed/getPostThread' +import AppContext from '../../../../context' +import { + FeedService, + FeedRow, + FeedHydrationState, +} from '../../../../services/feed' import { getAncestorsAndSelfQb, getDescendentsQb, } from '../../../../services/util/post' import { Database } from '../../../../db' import { setRepoRev } from '../../../util' - -export type PostThread = { - post: FeedRow - parent?: PostThread | ParentNotFoundError - replies?: PostThread[] -} +import { createPipeline, noRules } from '../../../../pipeline' +import { ActorInfoMap, ActorService } from '../../../../services/actor' export default function (server: Server, ctx: AppContext) { + const getPostThread = createPipeline( + skeleton, + hydration, + noRules, // handled in presentation: 3p block-violating replies are turned to #blockedPost, viewer blocks turned to #notFoundPost. + presentation, + ) server.app.bsky.feed.getPostThread({ auth: ctx.authOptionalVerifier, handler: async ({ params, auth, res }) => { - const { uri, depth, parentHeight } = params - const requester = auth.credentials.did - + const viewer = auth.credentials.did const db = ctx.db.getReplica('thread') - const actorService = ctx.services.actor(db) const feedService = ctx.services.feed(db) - const labelService = ctx.services.label(db) + const actorService = ctx.services.actor(db) - const [threadData, repoRev] = await Promise.all([ - getThreadData(ctx, db, uri, depth, parentHeight), - actorService.getRepoRev(requester), + const [result, repoRev] = await Promise.allSettled([ + getPostThread({ ...params, viewer }, { db, feedService, actorService }), + actorService.getRepoRev(viewer), ]) - setRepoRev(res, repoRev) - if (!threadData) { - throw new InvalidRequestError(`Post not found: ${uri}`, 'NotFound') + if (repoRev.status === 'fulfilled') { + setRepoRev(res, repoRev.value) } - const relevant = getRelevantIds(threadData) - const [actors, posts, labels] = await Promise.all([ - feedService.getActorInfos(Array.from(relevant.dids), requester, { - skipLabels: true, - }), - feedService.getPostInfos(Array.from(relevant.uris), requester), - labelService.getLabelsForSubjects([...relevant.uris, ...relevant.dids]), - ]) - const blocks = await feedService.blocksForPosts(posts) - const embeds = await feedService.embedsForPosts(posts, blocks, requester) - - const thread = composeThread( - threadData, - feedService, - posts, - actors, - embeds, - blocks, - labels, - ) - - if (isNotFoundPost(thread)) { - // @TODO technically this could be returned as a NotFoundPost based on lexicon - throw new InvalidRequestError(`Post not found: ${uri}`, 'NotFound') + if (result.status === 'rejected') { + throw result.reason } return { encoding: 'application/json', - body: { thread }, + body: result.value, } }, }) } +const skeleton = async (params: Params, ctx: Context) => { + const threadData = await getThreadData(params, ctx) + if (!threadData) { + throw new InvalidRequestError(`Post not found: ${params.uri}`, 'NotFound') + } + return { params, threadData } +} + +const hydration = async (state: SkeletonState, ctx: Context) => { + const { feedService } = ctx + const { + threadData, + params: { viewer }, + } = state + const relevant = getRelevantIds(threadData) + const hydrated = await feedService.feedHydration({ ...relevant, viewer }) + return { ...state, ...hydrated } +} + +const presentation = (state: HydrationState, ctx: Context) => { + const { params, profiles } = state + const { actorService } = ctx + const actors = actorService.views.profileBasicPresentation( + Object.keys(profiles), + state, + { viewer: params.viewer }, + ) + const thread = composeThread(state.threadData, actors, state, ctx) + if (isNotFoundPost(thread)) { + // @TODO technically this could be returned as a NotFoundPost based on lexicon + throw new InvalidRequestError(`Post not found: ${params.uri}`, 'NotFound') + } + return { thread } +} + const composeThread = ( threadData: PostThread, - feedService: FeedService, - posts: PostInfoMap, actors: ActorInfoMap, - embeds: PostEmbedViews, - blocks: PostBlocksMap, - labels: Labels, + state: HydrationState, + ctx: Context, ) => { + const { feedService } = ctx + const { posts, embeds, blocks, labels } = state + const post = feedService.views.formatPostView( threadData.post.postUri, actors, @@ -134,30 +143,14 @@ const composeThread = ( notFound: true, } } else { - parent = composeThread( - threadData.parent, - feedService, - posts, - actors, - embeds, - blocks, - labels, - ) + parent = composeThread(threadData.parent, actors, state, ctx) } } let replies: (ThreadViewPost | NotFoundPost | BlockedPost)[] | undefined if (threadData.replies) { replies = threadData.replies.flatMap((reply) => { - const thread = composeThread( - reply, - feedService, - posts, - actors, - embeds, - blocks, - labels, - ) + const thread = composeThread(reply, actors, state, ctx) // e.g. don't bother including #postNotFound reply placeholders for takedowns. either way matches api contract. const skip = [] return isNotFoundPost(thread) ? skip : thread @@ -195,13 +188,12 @@ const getRelevantIds = ( } const getThreadData = async ( - ctx: AppContext, - db: Database, - uri: string, - depth: number, - parentHeight: number, + params: Params, + ctx: Context, ): Promise => { - const feedService = ctx.services.feed(db) + const { db, feedService } = ctx + const { uri, depth, parentHeight } = params + const [parents, children] = await Promise.all([ getAncestorsAndSelfQb(db.db, { uri, parentHeight }) .selectFrom('ancestor') @@ -278,3 +270,24 @@ class ParentNotFoundError extends Error { super(`Parent not found: ${uri}`) } } + +type PostThread = { + post: FeedRow + parent?: PostThread | ParentNotFoundError + replies?: PostThread[] +} + +type Context = { + db: Database + feedService: FeedService + actorService: ActorService +} + +type Params = QueryParams & { viewer: string | null } + +type SkeletonState = { + params: Params + threadData: PostThread +} + +type HydrationState = SkeletonState & FeedHydrationState diff --git a/packages/bsky/src/api/app/bsky/feed/getPosts.ts b/packages/bsky/src/api/app/bsky/feed/getPosts.ts index 4b092a5e717..fc35b203034 100644 --- a/packages/bsky/src/api/app/bsky/feed/getPosts.ts +++ b/packages/bsky/src/api/app/bsky/feed/getPosts.ts @@ -1,36 +1,96 @@ -import * as common from '@atproto/common' +import { dedupeStrs } from '@atproto/common' +import { AtUri } from '@atproto/syntax' import { Server } from '../../../../lexicon' +import { QueryParams } from '../../../../lexicon/types/app/bsky/feed/getPosts' import AppContext from '../../../../context' -import { PostView } from '../../../../lexicon/types/app/bsky/feed/defs' +import { Database } from '../../../../db' +import { FeedHydrationState, FeedService } from '../../../../services/feed' +import { createPipeline } from '../../../../pipeline' +import { ActorService } from '../../../../services/actor' export default function (server: Server, ctx: AppContext) { + const getPosts = createPipeline(skeleton, hydration, noBlocks, presentation) server.app.bsky.feed.getPosts({ auth: ctx.authOptionalVerifier, handler: async ({ params, auth }) => { - const requester = auth.credentials.did - - const uris = common.dedupeStrs(params.uris) - const db = ctx.db.getReplica() - const postViews = await ctx.services - .feed(db) - .getPostViews(uris, requester) - - const posts: PostView[] = [] - for (const uri of uris) { - const post = postViews[uri] - const isBlocked = - post?.author.viewer?.blockedBy === true || - typeof post?.author.viewer?.blocking === 'string' - if (post && !isBlocked) { - posts.push(post) - } - } + const feedService = ctx.services.feed(db) + const actorService = ctx.services.actor(db) + const viewer = auth.credentials.did + + const results = await getPosts( + { ...params, viewer }, + { db, feedService, actorService }, + ) return { encoding: 'application/json', - body: { posts }, + body: results, } }, }) } + +const skeleton = async (params: Params) => { + return { params, postUris: dedupeStrs(params.uris) } +} + +const hydration = async (state: SkeletonState, ctx: Context) => { + const { feedService } = ctx + const { params, postUris } = state + const uris = new Set(postUris) + const dids = new Set(postUris.map((uri) => new AtUri(uri).hostname)) + const hydrated = await feedService.feedHydration({ + uris, + dids, + viewer: params.viewer, + }) + return { ...state, ...hydrated } +} + +const noBlocks = (state: HydrationState) => { + const { viewer } = state.params + state.postUris = state.postUris.filter((uri) => { + const post = state.posts[uri] + if (!viewer || !post) return true + return !state.bam.block([viewer, post.creator]) + }) + return state +} + +const presentation = (state: HydrationState, ctx: Context) => { + const { feedService, actorService } = ctx + const { postUris, profiles, params } = state + const SKIP = [] + const actors = actorService.views.profileBasicPresentation( + Object.keys(profiles), + state, + { viewer: params.viewer }, + ) + const postViews = postUris.flatMap((uri) => { + const postView = feedService.views.formatPostView( + uri, + actors, + state.posts, + state.embeds, + state.labels, + ) + return postView ?? SKIP + }) + return { posts: postViews } +} + +type Context = { + db: Database + feedService: FeedService + actorService: ActorService +} + +type Params = QueryParams & { viewer: string | null } + +type SkeletonState = { + params: Params + postUris: string[] +} + +type HydrationState = SkeletonState & FeedHydrationState diff --git a/packages/bsky/src/api/app/bsky/feed/getRepostedBy.ts b/packages/bsky/src/api/app/bsky/feed/getRepostedBy.ts index 69ad1e5c79e..5ca5c452b63 100644 --- a/packages/bsky/src/api/app/bsky/feed/getRepostedBy.ts +++ b/packages/bsky/src/api/app/bsky/feed/getRepostedBy.ts @@ -1,54 +1,118 @@ +import { mapDefined } from '@atproto/common' import { Server } from '../../../../lexicon' +import { QueryParams } from '../../../../lexicon/types/app/bsky/feed/getRepostedBy' import { paginate, TimeCidKeyset } from '../../../../db/pagination' import AppContext from '../../../../context' import { notSoftDeletedClause } from '../../../../db/util' +import { Database } from '../../../../db' +import { ActorInfoMap, ActorService } from '../../../../services/actor' +import { BlockAndMuteState, GraphService } from '../../../../services/graph' +import { Actor } from '../../../../db/tables/actor' +import { createPipeline } from '../../../../pipeline' export default function (server: Server, ctx: AppContext) { + const getRepostedBy = createPipeline( + skeleton, + hydration, + noBlocks, + presentation, + ) server.app.bsky.feed.getRepostedBy({ auth: ctx.authOptionalVerifier, handler: async ({ params, auth }) => { - const { uri, limit, cursor, cid } = params - const requester = auth.credentials.did const db = ctx.db.getReplica() + const actorService = ctx.services.actor(db) const graphService = ctx.services.graph(db) - const { ref } = db.db.dynamic - - let builder = db.db - .selectFrom('repost') - .where('repost.subject', '=', uri) - .innerJoin('actor as creator', 'creator.did', 'repost.creator') - .where(notSoftDeletedClause(ref('creator'))) - .whereNotExists( - graphService.blockQb(requester, [ref('repost.creator')]), - ) - .selectAll('creator') - .select(['repost.cid as cid', 'repost.sortAt as sortAt']) - - if (cid) { - builder = builder.where('repost.subjectCid', '=', cid) - } - - const keyset = new TimeCidKeyset(ref('repost.sortAt'), ref('repost.cid')) - builder = paginate(builder, { - limit, - cursor, - keyset, - }) + const viewer = auth.credentials.did - const repostedByRes = await builder.execute() - const repostedBy = await ctx.services - .actor(db) - .views.hydrateProfiles(repostedByRes, requester) + const result = await getRepostedBy( + { ...params, viewer }, + { db, actorService, graphService }, + ) return { encoding: 'application/json', - body: { - uri, - cid, - repostedBy, - cursor: keyset.packFromResult(repostedByRes), - }, + body: result, } }, }) } + +const skeleton = async ( + params: Params, + ctx: Context, +): Promise => { + const { db } = ctx + const { limit, cursor, uri, cid } = params + const { ref } = db.db.dynamic + + let builder = db.db + .selectFrom('repost') + .where('repost.subject', '=', uri) + .innerJoin('actor as creator', 'creator.did', 'repost.creator') + .where(notSoftDeletedClause(ref('creator'))) + .selectAll('creator') + .select(['repost.cid as cid', 'repost.sortAt as sortAt']) + + if (cid) { + builder = builder.where('repost.subjectCid', '=', cid) + } + + const keyset = new TimeCidKeyset(ref('repost.sortAt'), ref('repost.cid')) + builder = paginate(builder, { + limit, + cursor, + keyset, + }) + + const repostedBy = await builder.execute() + return { params, repostedBy, cursor: keyset.packFromResult(repostedBy) } +} + +const hydration = async (state: SkeletonState, ctx: Context) => { + const { graphService, actorService } = ctx + const { params, repostedBy } = state + const { viewer } = params + const [actors, bam] = await Promise.all([ + actorService.views.profiles(repostedBy, viewer), + graphService.getBlockAndMuteState( + viewer ? repostedBy.map((item) => [viewer, item.did]) : [], + ), + ]) + return { ...state, bam, actors } +} + +const noBlocks = (state: HydrationState) => { + const { viewer } = state.params + if (!viewer) return state + state.repostedBy = state.repostedBy.filter( + (item) => !state.bam.block([viewer, item.did]), + ) + return state +} + +const presentation = (state: HydrationState) => { + const { params, repostedBy, actors, cursor } = state + const { uri, cid } = params + const repostedByView = mapDefined(repostedBy, (item) => actors[item.did]) + return { repostedBy: repostedByView, cursor, uri, cid } +} + +type Context = { + db: Database + actorService: ActorService + graphService: GraphService +} + +type Params = QueryParams & { viewer: string | null } + +type SkeletonState = { + params: Params + repostedBy: Actor[] + cursor?: string +} + +type HydrationState = SkeletonState & { + bam: BlockAndMuteState + actors: ActorInfoMap +} diff --git a/packages/bsky/src/api/app/bsky/feed/getSuggestedFeeds.ts b/packages/bsky/src/api/app/bsky/feed/getSuggestedFeeds.ts index 25ff4dc8f8d..1ad65cdf756 100644 --- a/packages/bsky/src/api/app/bsky/feed/getSuggestedFeeds.ts +++ b/packages/bsky/src/api/app/bsky/feed/getSuggestedFeeds.ts @@ -9,6 +9,7 @@ export default function (server: Server, ctx: AppContext) { const db = ctx.db.getReplica() const feedService = ctx.services.feed(db) + const actorService = ctx.services.actor(db) const feedsRes = await db.db .selectFrom('suggested_feed') .orderBy('suggested_feed.order', 'asc') @@ -20,7 +21,8 @@ export default function (server: Server, ctx: AppContext) { ) const genList = feedsRes.map((r) => genInfos[r.uri]).filter(Boolean) const creators = genList.map((gen) => gen.creator) - const profiles = await feedService.getActorInfos(creators, viewer) + const profiles = await actorService.views.profilesBasic(creators, viewer) + const feedViews = genList.map((gen) => feedService.views.formatFeedGeneratorView(gen, profiles), ) diff --git a/packages/bsky/src/api/app/bsky/feed/getTimeline.ts b/packages/bsky/src/api/app/bsky/feed/getTimeline.ts index 315ef0c9eb5..9609ed6db42 100644 --- a/packages/bsky/src/api/app/bsky/feed/getTimeline.ts +++ b/packages/bsky/src/api/app/bsky/feed/getTimeline.ts @@ -4,54 +4,57 @@ import { FeedAlgorithm, FeedKeyset, getFeedDateThreshold } from '../util/feed' import { paginate } from '../../../../db/pagination' import AppContext from '../../../../context' import { Database } from '../../../../db' -import { SkeletonFeedPost } from '../../../../lexicon/types/app/bsky/feed/defs' +import { QueryParams } from '../../../../lexicon/types/app/bsky/feed/getTimeline' import { setRepoRev } from '../../../util' +import { + FeedHydrationState, + FeedRow, + FeedService, +} from '../../../../services/feed' +import { createPipeline } from '../../../../pipeline' export default function (server: Server, ctx: AppContext) { + const getTimeline = createPipeline( + skeleton, + hydration, + noBlocksOrMutes, + presentation, + ) server.app.bsky.feed.getTimeline({ auth: ctx.authVerifier, handler: async ({ params, auth, res }) => { - const { algorithm, limit, cursor } = params const viewer = auth.credentials.did - - if (algorithm && algorithm !== FeedAlgorithm.ReverseChronological) { - throw new InvalidRequestError(`Unsupported algorithm: ${algorithm}`) - } const db = ctx.db.getReplica('timeline') + const feedService = ctx.services.feed(db) + const actorService = ctx.services.actor(db) - const [skeleton, repoRev] = await Promise.all([ - getTimelineSkeleton(db, viewer, limit, cursor), - ctx.services.actor(db).getRepoRev(viewer), + const [result, repoRev] = await Promise.all([ + getTimeline({ ...params, viewer }, { db, feedService }), + actorService.getRepoRev(viewer), ]) - setRepoRev(res, repoRev) - const feedService = ctx.services.feed(db) - const feedItems = await feedService.cleanFeedSkeleton( - skeleton.feed, - limit, - viewer, - ) - const feed = await feedService.hydrateFeed(feedItems, viewer) + setRepoRev(res, repoRev) return { encoding: 'application/json', - body: { - feed, - cursor: skeleton.cursor, - }, + body: result, } }, }) } -export const getTimelineSkeleton = async ( - db: Database, - viewer: string, - limit: number, - cursor?: string, -): Promise<{ feed: SkeletonFeedPost[]; cursor?: string }> => { +export const skeleton = async ( + params: Params, + ctx: Context, +): Promise => { + const { cursor, limit, algorithm, viewer } = params + const { db } = ctx const { ref } = db.db.dynamic + if (algorithm && algorithm !== FeedAlgorithm.ReverseChronological) { + throw new InvalidRequestError(`Unsupported algorithm: ${algorithm}`) + } + const keyset = new FeedKeyset(ref('feed_item.sortAt'), ref('feed_item.cid')) const sortFrom = keyset.unpack(cursor)?.primary @@ -99,26 +102,67 @@ export const getTimelineSkeleton = async ( selfQb.execute(), ]) - const feedItems = [...followRes, ...selfRes] + const feedItems: FeedRow[] = [...followRes, ...selfRes] .sort((a, b) => { if (a.sortAt > b.sortAt) return -1 if (a.sortAt < b.sortAt) return 1 return a.cid > b.cid ? -1 : 1 }) .slice(0, limit) - const feed = feedItems.map((item) => ({ - post: item.postUri, - reason: - item.uri === item.postUri - ? undefined - : { - $type: 'app.bsky.feed.defs#skeletonReasonRepost', - repost: item.uri, - }, - })) return { + params, + feedItems, cursor: keyset.packFromResult(feedItems), - feed, } } + +const hydration = async ( + state: SkeletonState, + ctx: Context, +): Promise => { + const { feedService } = ctx + const { params, feedItems } = state + const refs = feedService.feedItemRefs(feedItems) + const hydrated = await feedService.feedHydration({ + ...refs, + viewer: params.viewer, + }) + return { ...state, ...hydrated } +} + +const noBlocksOrMutes = (state: HydrationState): HydrationState => { + const { viewer } = state.params + state.feedItems = state.feedItems.filter( + (item) => + !state.bam.block([viewer, item.postAuthorDid]) && + !state.bam.block([viewer, item.originatorDid]) && + !state.bam.mute([viewer, item.postAuthorDid]) && + !state.bam.mute([viewer, item.originatorDid]), + ) + return state +} + +const presentation = (state: HydrationState, ctx: Context) => { + const { feedService } = ctx + const { feedItems, cursor, params } = state + const feed = feedService.views.formatFeed(feedItems, state, { + viewer: params.viewer, + }) + return { feed, cursor } +} + +type Context = { + db: Database + feedService: FeedService +} + +type Params = QueryParams & { viewer: string } + +type SkeletonState = { + params: Params + feedItems: FeedRow[] + cursor?: string +} + +type HydrationState = SkeletonState & FeedHydrationState diff --git a/packages/bsky/src/api/app/bsky/graph/getBlocks.ts b/packages/bsky/src/api/app/bsky/graph/getBlocks.ts index bb9c0fd2356..66b809d70ce 100644 --- a/packages/bsky/src/api/app/bsky/graph/getBlocks.ts +++ b/packages/bsky/src/api/app/bsky/graph/getBlocks.ts @@ -33,10 +33,7 @@ export default function (server: Server, ctx: AppContext) { const blocksRes = await blocksReq.execute() const actorService = ctx.services.actor(db) - const blocks = await actorService.views.hydrateProfiles( - blocksRes, - requester, - ) + const blocks = await actorService.views.profilesList(blocksRes, requester) return { encoding: 'application/json', diff --git a/packages/bsky/src/api/app/bsky/graph/getFollowers.ts b/packages/bsky/src/api/app/bsky/graph/getFollowers.ts index 98f0d336551..1382c1f87c7 100644 --- a/packages/bsky/src/api/app/bsky/graph/getFollowers.ts +++ b/packages/bsky/src/api/app/bsky/graph/getFollowers.ts @@ -1,78 +1,146 @@ +import { mapDefined } from '@atproto/common' import { InvalidRequestError } from '@atproto/xrpc-server' import { Server } from '../../../../lexicon' -import { paginate, TimeCidKeyset } from '../../../../db/pagination' +import { QueryParams } from '../../../../lexicon/types/app/bsky/graph/getFollowers' import AppContext from '../../../../context' +import { Database } from '../../../../db' import { notSoftDeletedClause } from '../../../../db/util' +import { paginate, TimeCidKeyset } from '../../../../db/pagination' +import { Actor } from '../../../../db/tables/actor' +import { ActorInfoMap, ActorService } from '../../../../services/actor' +import { BlockAndMuteState, GraphService } from '../../../../services/graph' +import { createPipeline } from '../../../../pipeline' export default function (server: Server, ctx: AppContext) { + const getFollowers = createPipeline( + skeleton, + hydration, + noBlocksInclInvalid, + presentation, + ) server.app.bsky.graph.getFollowers({ auth: ctx.authOptionalAccessOrRoleVerifier, handler: async ({ params, auth }) => { - const { actor, limit, cursor } = params - const requester = 'did' in auth.credentials ? auth.credentials.did : null - const canViewTakendownProfile = - auth.credentials.type === 'role' && auth.credentials.triage const db = ctx.db.getReplica() - const { ref } = db.db.dynamic - const actorService = ctx.services.actor(db) const graphService = ctx.services.graph(db) + const viewer = 'did' in auth.credentials ? auth.credentials.did : null + const canViewTakendownProfile = + auth.credentials.type === 'role' && auth.credentials.triage - const subjectRes = await actorService.getActor( - actor, - canViewTakendownProfile, + const result = await getFollowers( + { ...params, viewer, canViewTakendownProfile }, + { db, actorService, graphService }, ) - if (!subjectRes) { - throw new InvalidRequestError(`Actor not found: ${actor}`) - } - - let followersReq = db.db - .selectFrom('follow') - .where('follow.subjectDid', '=', subjectRes.did) - .innerJoin('actor as creator', 'creator.did', 'follow.creator') - .if(!canViewTakendownProfile, (qb) => - qb.where(notSoftDeletedClause(ref('creator'))), - ) - .whereNotExists( - graphService.blockQb(requester, [ref('follow.creator')]), - ) - .whereNotExists( - graphService.blockRefQb( - ref('follow.subjectDid'), - ref('follow.creator'), - ), - ) - .selectAll('creator') - .select(['follow.cid as cid', 'follow.sortAt as sortAt']) - - const keyset = new TimeCidKeyset(ref('follow.sortAt'), ref('follow.cid')) - followersReq = paginate(followersReq, { - limit, - cursor, - keyset, - }) - - const followersRes = await followersReq.execute() - const [followers, subject] = await Promise.all([ - actorService.views.hydrateProfiles(followersRes, requester, { - includeSoftDeleted: canViewTakendownProfile, - }), - actorService.views.profile(subjectRes, requester, { - includeSoftDeleted: canViewTakendownProfile, - }), - ]) - if (!subject) { - throw new InvalidRequestError(`Actor not found: ${actor}`) - } return { encoding: 'application/json', - body: { - subject, - followers, - cursor: keyset.packFromResult(followersRes), - }, + body: result, } }, }) } + +const skeleton = async ( + params: Params, + ctx: Context, +): Promise => { + const { db, actorService } = ctx + const { limit, cursor, actor, canViewTakendownProfile } = params + const { ref } = db.db.dynamic + + const subject = await actorService.getActor(actor, canViewTakendownProfile) + if (!subject) { + throw new InvalidRequestError(`Actor not found: ${actor}`) + } + + let followersReq = db.db + .selectFrom('follow') + .where('follow.subjectDid', '=', subject.did) + .innerJoin('actor as creator', 'creator.did', 'follow.creator') + .if(!canViewTakendownProfile, (qb) => + qb.where(notSoftDeletedClause(ref('creator'))), + ) + .selectAll('creator') + .select(['follow.cid as cid', 'follow.sortAt as sortAt']) + + const keyset = new TimeCidKeyset(ref('follow.sortAt'), ref('follow.cid')) + followersReq = paginate(followersReq, { + limit, + cursor, + keyset, + }) + + const followers = await followersReq.execute() + return { + params, + followers, + subject, + cursor: keyset.packFromResult(followers), + } +} + +const hydration = async (state: SkeletonState, ctx: Context) => { + const { graphService, actorService } = ctx + const { params, followers, subject } = state + const { viewer } = params + const [actors, bam] = await Promise.all([ + actorService.views.profiles([subject, ...followers], viewer), + graphService.getBlockAndMuteState( + followers.flatMap((item) => { + if (viewer) { + return [ + [viewer, item.did], + [subject.did, item.did], + ] + } + return [[subject.did, item.did]] + }), + ), + ]) + return { ...state, bam, actors } +} + +const noBlocksInclInvalid = (state: HydrationState) => { + const { subject } = state + const { viewer } = state.params + state.followers = state.followers.filter( + (item) => + !state.bam.block([subject.did, item.did]) && + (!viewer || !state.bam.block([viewer, item.did])), + ) + return state +} + +const presentation = (state: HydrationState) => { + const { params, followers, subject, actors, cursor } = state + const subjectView = actors[subject.did] + const followersView = mapDefined(followers, (item) => actors[item.did]) + if (!subjectView) { + throw new InvalidRequestError(`Actor not found: ${params.actor}`) + } + return { followers: followersView, subject: subjectView, cursor } +} + +type Context = { + db: Database + actorService: ActorService + graphService: GraphService +} + +type Params = QueryParams & { + viewer: string | null + canViewTakendownProfile: boolean +} + +type SkeletonState = { + params: Params + followers: Actor[] + subject: Actor + cursor?: string +} + +type HydrationState = SkeletonState & { + bam: BlockAndMuteState + actors: ActorInfoMap +} diff --git a/packages/bsky/src/api/app/bsky/graph/getFollows.ts b/packages/bsky/src/api/app/bsky/graph/getFollows.ts index 344b17b0158..34b5d72a605 100644 --- a/packages/bsky/src/api/app/bsky/graph/getFollows.ts +++ b/packages/bsky/src/api/app/bsky/graph/getFollows.ts @@ -1,78 +1,147 @@ +import { mapDefined } from '@atproto/common' import { InvalidRequestError } from '@atproto/xrpc-server' import { Server } from '../../../../lexicon' -import { paginate, TimeCidKeyset } from '../../../../db/pagination' +import { QueryParams } from '../../../../lexicon/types/app/bsky/graph/getFollows' import AppContext from '../../../../context' +import { Database } from '../../../../db' import { notSoftDeletedClause } from '../../../../db/util' +import { paginate, TimeCidKeyset } from '../../../../db/pagination' +import { Actor } from '../../../../db/tables/actor' +import { ActorInfoMap, ActorService } from '../../../../services/actor' +import { BlockAndMuteState, GraphService } from '../../../../services/graph' +import { createPipeline } from '../../../../pipeline' export default function (server: Server, ctx: AppContext) { + const getFollows = createPipeline( + skeleton, + hydration, + noBlocksInclInvalid, + presentation, + ) server.app.bsky.graph.getFollows({ auth: ctx.authOptionalAccessOrRoleVerifier, handler: async ({ params, auth }) => { - const { actor, limit, cursor } = params - const requester = 'did' in auth.credentials ? auth.credentials.did : null - const canViewTakendownProfile = - auth.credentials.type === 'role' && auth.credentials.triage const db = ctx.db.getReplica() - const { ref } = db.db.dynamic - const actorService = ctx.services.actor(db) const graphService = ctx.services.graph(db) + const viewer = 'did' in auth.credentials ? auth.credentials.did : null + const canViewTakendownProfile = + auth.credentials.type === 'role' && auth.credentials.triage - const creatorRes = await actorService.getActor( - actor, - canViewTakendownProfile, + const result = await getFollows( + { ...params, viewer, canViewTakendownProfile }, + { db, actorService, graphService }, ) - if (!creatorRes) { - throw new InvalidRequestError(`Actor not found: ${actor}`) - } - - let followsReq = db.db - .selectFrom('follow') - .where('follow.creator', '=', creatorRes.did) - .innerJoin('actor as subject', 'subject.did', 'follow.subjectDid') - .if(!canViewTakendownProfile, (qb) => - qb.where(notSoftDeletedClause(ref('subject'))), - ) - .whereNotExists( - graphService.blockQb(requester, [ref('follow.subjectDid')]), - ) - .whereNotExists( - graphService.blockRefQb( - ref('follow.subjectDid'), - ref('follow.creator'), - ), - ) - .selectAll('subject') - .select(['follow.cid as cid', 'follow.sortAt as sortAt']) - - const keyset = new TimeCidKeyset(ref('follow.sortAt'), ref('follow.cid')) - followsReq = paginate(followsReq, { - limit, - cursor, - keyset, - }) - - const followsRes = await followsReq.execute() - const [follows, subject] = await Promise.all([ - actorService.views.hydrateProfiles(followsRes, requester, { - includeSoftDeleted: canViewTakendownProfile, - }), - actorService.views.profile(creatorRes, requester, { - includeSoftDeleted: canViewTakendownProfile, - }), - ]) - if (!subject) { - throw new InvalidRequestError(`Actor not found: ${actor}`) - } return { encoding: 'application/json', - body: { - subject, - follows, - cursor: keyset.packFromResult(followsRes), - }, + body: result, } }, }) } + +const skeleton = async ( + params: Params, + ctx: Context, +): Promise => { + const { db, actorService } = ctx + const { limit, cursor, actor, canViewTakendownProfile } = params + const { ref } = db.db.dynamic + + const creator = await actorService.getActor(actor, canViewTakendownProfile) + if (!creator) { + throw new InvalidRequestError(`Actor not found: ${actor}`) + } + + let followsReq = db.db + .selectFrom('follow') + .where('follow.creator', '=', creator.did) + .innerJoin('actor as subject', 'subject.did', 'follow.subjectDid') + .if(!canViewTakendownProfile, (qb) => + qb.where(notSoftDeletedClause(ref('subject'))), + ) + .selectAll('subject') + .select(['follow.cid as cid', 'follow.sortAt as sortAt']) + + const keyset = new TimeCidKeyset(ref('follow.sortAt'), ref('follow.cid')) + followsReq = paginate(followsReq, { + limit, + cursor, + keyset, + }) + + const follows = await followsReq.execute() + + return { + params, + follows, + creator, + cursor: keyset.packFromResult(follows), + } +} + +const hydration = async (state: SkeletonState, ctx: Context) => { + const { graphService, actorService } = ctx + const { params, follows, creator } = state + const { viewer } = params + const [actors, bam] = await Promise.all([ + actorService.views.profiles([creator, ...follows], viewer), + graphService.getBlockAndMuteState( + follows.flatMap((item) => { + if (viewer) { + return [ + [viewer, item.did], + [creator.did, item.did], + ] + } + return [[creator.did, item.did]] + }), + ), + ]) + return { ...state, bam, actors } +} + +const noBlocksInclInvalid = (state: HydrationState) => { + const { creator } = state + const { viewer } = state.params + state.follows = state.follows.filter( + (item) => + !state.bam.block([creator.did, item.did]) && + (!viewer || !state.bam.block([viewer, item.did])), + ) + return state +} + +const presentation = (state: HydrationState) => { + const { params, follows, creator, actors, cursor } = state + const creatorView = actors[creator.did] + const followsView = mapDefined(follows, (item) => actors[item.did]) + if (!creatorView) { + throw new InvalidRequestError(`Actor not found: ${params.actor}`) + } + return { follows: followsView, subject: creatorView, cursor } +} + +type Context = { + db: Database + actorService: ActorService + graphService: GraphService +} + +type Params = QueryParams & { + viewer: string | null + canViewTakendownProfile: boolean +} + +type SkeletonState = { + params: Params + follows: Actor[] + creator: Actor + cursor?: string +} + +type HydrationState = SkeletonState & { + bam: BlockAndMuteState + actors: ActorInfoMap +} diff --git a/packages/bsky/src/api/app/bsky/graph/getList.ts b/packages/bsky/src/api/app/bsky/graph/getList.ts index 068b35fb6df..1e6775d01cb 100644 --- a/packages/bsky/src/api/app/bsky/graph/getList.ts +++ b/packages/bsky/src/api/app/bsky/graph/getList.ts @@ -1,87 +1,126 @@ +import { mapDefined } from '@atproto/common' import { InvalidRequestError } from '@atproto/xrpc-server' import { Server } from '../../../../lexicon' -import { paginate, TimeCidKeyset } from '../../../../db/pagination' +import { QueryParams } from '../../../../lexicon/types/app/bsky/graph/getList' import AppContext from '../../../../context' +import { Database } from '../../../../db' +import { paginate, TimeCidKeyset } from '../../../../db/pagination' +import { Actor } from '../../../../db/tables/actor' +import { GraphService, ListInfo } from '../../../../services/graph' +import { ActorService, ProfileHydrationState } from '../../../../services/actor' +import { createPipeline, noRules } from '../../../../pipeline' export default function (server: Server, ctx: AppContext) { + const getList = createPipeline(skeleton, hydration, noRules, presentation) server.app.bsky.graph.getList({ auth: ctx.authOptionalVerifier, handler: async ({ params, auth }) => { - const { list, limit, cursor } = params - const requester = auth.credentials.did const db = ctx.db.getReplica() - const { ref } = db.db.dynamic - const graphService = ctx.services.graph(db) + const actorService = ctx.services.actor(db) + const viewer = auth.credentials.did - const listRes = await graphService - .getListsQb(requester) - .where('list.uri', '=', list) - .executeTakeFirst() - if (!listRes) { - throw new InvalidRequestError(`List not found: ${list}`) + const result = await getList( + { ...params, viewer }, + { db, graphService, actorService }, + ) + + return { + encoding: 'application/json', + body: result, } + }, + }) +} - let itemsReq = graphService - .getListItemsQb() - .where('list_item.listUri', '=', list) - .where('list_item.creator', '=', listRes.creator) +const skeleton = async ( + params: Params, + ctx: Context, +): Promise => { + const { db, graphService } = ctx + const { list, limit, cursor, viewer } = params + const { ref } = db.db.dynamic - const keyset = new TimeCidKeyset( - ref('list_item.sortAt'), - ref('list_item.cid'), - ) - itemsReq = paginate(itemsReq, { - limit, - cursor, - keyset, - }) - const itemsRes = await itemsReq.execute() + const listRes = await graphService + .getListsQb(viewer) + .where('list.uri', '=', list) + .executeTakeFirst() + if (!listRes) { + throw new InvalidRequestError(`List not found: ${list}`) + } - const actorService = ctx.services.actor(db) - const profiles = await actorService.views.hydrateProfiles( - itemsRes, - requester, - ) + let itemsReq = graphService + .getListItemsQb() + .where('list_item.listUri', '=', list) + .where('list_item.creator', '=', listRes.creator) - const items = profiles.map((subject) => ({ subject })) + const keyset = new TimeCidKeyset( + ref('list_item.sortAt'), + ref('list_item.cid'), + ) - const creator = await actorService.views.profile(listRes, requester) - if (!creator) { - throw new InvalidRequestError(`Actor not found: ${listRes.handle}`) - } + itemsReq = paginate(itemsReq, { + limit, + cursor, + keyset, + }) - const subject = { - uri: listRes.uri, - cid: listRes.cid, - creator, - name: listRes.name, - purpose: listRes.purpose, - description: listRes.description ?? undefined, - descriptionFacets: listRes.descriptionFacets - ? JSON.parse(listRes.descriptionFacets) - : undefined, - avatar: listRes.avatarCid - ? ctx.imgUriBuilder.getPresetUri( - 'avatar', - listRes.creator, - listRes.avatarCid, - ) - : undefined, - indexedAt: listRes.indexedAt, - viewer: { - muted: !!listRes.viewerMuted, - }, - } + const listItems = await itemsReq.execute() - return { - encoding: 'application/json', - body: { - items, - list: subject, - cursor: keyset.packFromResult(itemsRes), - }, - } - }, + return { + params, + list: listRes, + listItems, + cursor: keyset.packFromResult(listItems), + } +} + +const hydration = async (state: SkeletonState, ctx: Context) => { + const { actorService } = ctx + const { params, list, listItems } = state + const profileState = await actorService.views.profileHydration( + [list, ...listItems].map((x) => x.did), + { viewer: params.viewer }, + ) + return { ...state, ...profileState } +} + +const presentation = (state: HydrationState, ctx: Context) => { + const { actorService, graphService } = ctx + const { params, list, listItems, cursor, ...profileState } = state + const actors = actorService.views.profilePresentation( + Object.keys(profileState.profiles), + profileState, + { viewer: params.viewer }, + ) + const creator = actors[list.creator] + if (!creator) { + throw new InvalidRequestError(`Actor not found: ${list.handle}`) + } + const listView = graphService.formatListView(list, actors) + const items = mapDefined(listItems, (item) => { + const subject = actors[item.did] + if (!subject) return + return { subject } }) + return { list: listView, items, cursor } +} + +type Context = { + db: Database + actorService: ActorService + graphService: GraphService +} + +type Params = QueryParams & { + viewer: string | null +} + +type SkeletonState = { + params: Params + list: Actor & ListInfo + listItems: (Actor & { cid: string; sortAt: string })[] + cursor?: string } + +type HydrationState = SkeletonState & ProfileHydrationState diff --git a/packages/bsky/src/api/app/bsky/graph/getListBlocks.ts b/packages/bsky/src/api/app/bsky/graph/getListBlocks.ts new file mode 100644 index 00000000000..0884005b244 --- /dev/null +++ b/packages/bsky/src/api/app/bsky/graph/getListBlocks.ts @@ -0,0 +1,114 @@ +import { Server } from '../../../../lexicon' +import { QueryParams } from '../../../../lexicon/types/app/bsky/graph/getListBlocks' +import { paginate, TimeCidKeyset } from '../../../../db/pagination' +import AppContext from '../../../../context' +import { Database } from '../../../../db' +import { Actor } from '../../../../db/tables/actor' +import { GraphService, ListInfo } from '../../../../services/graph' +import { ActorService, ProfileHydrationState } from '../../../../services/actor' +import { createPipeline, noRules } from '../../../../pipeline' + +export default function (server: Server, ctx: AppContext) { + const getListBlocks = createPipeline( + skeleton, + hydration, + noRules, + presentation, + ) + server.app.bsky.graph.getListBlocks({ + auth: ctx.authVerifier, + handler: async ({ params, auth }) => { + const db = ctx.db.getReplica() + const graphService = ctx.services.graph(db) + const actorService = ctx.services.actor(db) + const viewer = auth.credentials.did + + const result = await getListBlocks( + { ...params, viewer }, + { db, actorService, graphService }, + ) + + return { + encoding: 'application/json', + body: result, + } + }, + }) +} + +const skeleton = async ( + params: Params, + ctx: Context, +): Promise => { + const { db, graphService } = ctx + const { limit, cursor, viewer } = params + const { ref } = db.db.dynamic + + let listsReq = graphService + .getListsQb(viewer) + .whereExists( + db.db + .selectFrom('list_block') + .where('list_block.creator', '=', viewer) + .whereRef('list_block.subjectUri', '=', ref('list.uri')) + .selectAll(), + ) + + const keyset = new TimeCidKeyset(ref('list.createdAt'), ref('list.cid')) + + listsReq = paginate(listsReq, { + limit, + cursor, + keyset, + }) + + const listInfos = await listsReq.execute() + + return { + params, + listInfos, + cursor: keyset.packFromResult(listInfos), + } +} + +const hydration = async (state: SkeletonState, ctx: Context) => { + const { actorService } = ctx + const { params, listInfos } = state + const profileState = await actorService.views.profileHydration( + listInfos.map((list) => list.creator), + { viewer: params.viewer }, + ) + return { ...state, ...profileState } +} + +const presentation = (state: HydrationState, ctx: Context) => { + const { actorService, graphService } = ctx + const { params, listInfos, cursor, ...profileState } = state + const actors = actorService.views.profilePresentation( + Object.keys(profileState.profiles), + profileState, + { viewer: params.viewer }, + ) + const lists = listInfos.map((list) => + graphService.formatListView(list, actors), + ) + return { lists, cursor } +} + +type Context = { + db: Database + actorService: ActorService + graphService: GraphService +} + +type Params = QueryParams & { + viewer: string +} + +type SkeletonState = { + params: Params + listInfos: (Actor & ListInfo)[] + cursor?: string +} + +type HydrationState = SkeletonState & ProfileHydrationState diff --git a/packages/bsky/src/api/app/bsky/graph/getLists.ts b/packages/bsky/src/api/app/bsky/graph/getLists.ts index 966bf0a594b..e6ca61fa9c7 100644 --- a/packages/bsky/src/api/app/bsky/graph/getLists.ts +++ b/packages/bsky/src/api/app/bsky/graph/getLists.ts @@ -31,19 +31,16 @@ export default function (server: Server, ctx: AppContext) { keyset, }) - const [listsRes, creator] = await Promise.all([ + const [listsRes, profiles] = await Promise.all([ listsReq.execute(), - actorService.views.profile(creatorRes, requester), + actorService.views.profiles([creatorRes], requester), ]) - if (!creator) { + if (!profiles[creatorRes.did]) { throw new InvalidRequestError(`Actor not found: ${actor}`) } - const profileMap = { - [creator.did]: creator, - } const lists = listsRes.map((row) => - graphService.formatListView(row, profileMap), + graphService.formatListView(row, profiles), ) return { diff --git a/packages/bsky/src/api/app/bsky/graph/getMutes.ts b/packages/bsky/src/api/app/bsky/graph/getMutes.ts index 0bac37edfdd..e69803d144a 100644 --- a/packages/bsky/src/api/app/bsky/graph/getMutes.ts +++ b/packages/bsky/src/api/app/bsky/graph/getMutes.ts @@ -38,7 +38,7 @@ export default function (server: Server, ctx: AppContext) { encoding: 'application/json', body: { cursor: keyset.packFromResult(mutesRes), - mutes: await actorService.views.hydrateProfiles(mutesRes, requester), + mutes: await actorService.views.profilesList(mutesRes, requester), }, } }, diff --git a/packages/bsky/src/api/app/bsky/notification/listNotifications.ts b/packages/bsky/src/api/app/bsky/notification/listNotifications.ts index 1f36207820e..7bcc88f12d3 100644 --- a/packages/bsky/src/api/app/bsky/notification/listNotifications.ts +++ b/packages/bsky/src/api/app/bsky/notification/listNotifications.ts @@ -2,135 +2,198 @@ import { InvalidRequestError } from '@atproto/xrpc-server' import { jsonStringToLex } from '@atproto/lexicon' import { mapDefined } from '@atproto/common' import { Server } from '../../../../lexicon' -import { paginate, TimeCidKeyset } from '../../../../db/pagination' +import { QueryParams } from '../../../../lexicon/types/app/bsky/notification/listNotifications' import AppContext from '../../../../context' +import { Database } from '../../../../db' import { notSoftDeletedClause } from '../../../../db/util' -import { getSelfLabels } from '../../../../services/label' +import { paginate, TimeCidKeyset } from '../../../../db/pagination' +import { BlockAndMuteState, GraphService } from '../../../../services/graph' +import { ActorInfoMap, ActorService } from '../../../../services/actor' +import { getSelfLabels, Labels, LabelService } from '../../../../services/label' +import { createPipeline } from '../../../../pipeline' export default function (server: Server, ctx: AppContext) { + const listNotifications = createPipeline( + skeleton, + hydration, + noBlockOrMutes, + presentation, + ) server.app.bsky.notification.listNotifications({ auth: ctx.authVerifier, handler: async ({ params, auth }) => { - const { limit, cursor } = params - const requester = auth.credentials.did - if (params.seenAt) { - throw new InvalidRequestError('The seenAt parameter is unsupported') - } - const db = ctx.db.getReplica() + const actorService = ctx.services.actor(db) const graphService = ctx.services.graph(db) + const labelService = ctx.services.label(db) + const viewer = auth.credentials.did - const { ref } = db.db.dynamic - let notifBuilder = db.db - .selectFrom('notification as notif') - .innerJoin('record', 'record.uri', 'notif.recordUri') - .innerJoin('actor as author', 'author.did', 'notif.author') - .where(notSoftDeletedClause(ref('record'))) - .where(notSoftDeletedClause(ref('author'))) - .where('notif.did', '=', requester) - .where((qb) => - graphService.whereNotMuted(qb, requester, [ref('notif.author')]), - ) - .whereNotExists(graphService.blockQb(requester, [ref('notif.author')])) - .where((clause) => - clause - .where('reasonSubject', 'is', null) - .orWhereExists( - db.db - .selectFrom('record as subject') - .selectAll() - .whereRef('subject.uri', '=', ref('notif.reasonSubject')), - ), - ) - .select([ - 'notif.recordUri as uri', - 'notif.recordCid as cid', - 'author.did as authorDid', - 'author.handle as authorHandle', - 'author.indexedAt as authorIndexedAt', - 'author.takedownId as authorTakedownId', - 'notif.reason as reason', - 'notif.reasonSubject as reasonSubject', - 'notif.sortAt as indexedAt', - 'record.json as recordJson', - ]) - - const keyset = new NotifsKeyset( - ref('notif.sortAt'), - ref('notif.recordCid'), + const result = await listNotifications( + { ...params, viewer }, + { db, actorService, graphService, labelService }, ) - notifBuilder = paginate(notifBuilder, { - cursor, - limit, - keyset, - }) - - const actorStateQuery = db.db - .selectFrom('actor_state') - .selectAll() - .where('did', '=', requester) - - const [actorState, notifs] = await Promise.all([ - actorStateQuery.executeTakeFirst(), - notifBuilder.execute(), - ]) - - const seenAt = actorState?.lastSeenNotifs - - const actorService = ctx.services.actor(db) - const labelService = ctx.services.label(db) - const recordUris = notifs.map((notif) => notif.uri) - const [authors, labels] = await Promise.all([ - actorService.views.profiles( - notifs.map((notif) => ({ - did: notif.authorDid, - handle: notif.authorHandle, - indexedAt: notif.authorIndexedAt, - takedownId: notif.authorTakedownId, - })), - requester, - ), - labelService.getLabelsForUris(recordUris), - ]) - - const notifications = mapDefined(notifs, (notif) => { - const author = authors[notif.authorDid] - if (!author) return undefined - const record = jsonStringToLex(notif.recordJson) as Record< - string, - unknown - > - const recordLabels = labels[notif.uri] ?? [] - const recordSelfLabels = getSelfLabels({ - uri: notif.uri, - cid: notif.cid, - record, - }) - return { - uri: notif.uri, - cid: notif.cid, - author, - reason: notif.reason, - reasonSubject: notif.reasonSubject || undefined, - record, - isRead: seenAt ? notif.indexedAt <= seenAt : false, - indexedAt: notif.indexedAt, - labels: [...recordLabels, ...recordSelfLabels], - } - }) return { encoding: 'application/json', - body: { - notifications, - cursor: keyset.packFromResult(notifs), - }, + body: result, } }, }) } -type NotifRow = { indexedAt: string; cid: string } +const skeleton = async ( + params: Params, + ctx: Context, +): Promise => { + const { db } = ctx + const { limit, cursor, viewer } = params + const { ref } = db.db.dynamic + if (params.seenAt) { + throw new InvalidRequestError('The seenAt parameter is unsupported') + } + let notifBuilder = db.db + .selectFrom('notification as notif') + .innerJoin('record', 'record.uri', 'notif.recordUri') + .innerJoin('actor as author', 'author.did', 'notif.author') + .where(notSoftDeletedClause(ref('record'))) + .where(notSoftDeletedClause(ref('author'))) + .where('notif.did', '=', viewer) + .where((clause) => + clause + .where('reasonSubject', 'is', null) + .orWhereExists( + db.db + .selectFrom('record as subject') + .selectAll() + .whereRef('subject.uri', '=', ref('notif.reasonSubject')), + ), + ) + .select([ + 'notif.recordUri as uri', + 'notif.recordCid as cid', + 'author.did as authorDid', + 'author.handle as authorHandle', + 'author.indexedAt as authorIndexedAt', + 'author.takedownId as authorTakedownId', + 'notif.reason as reason', + 'notif.reasonSubject as reasonSubject', + 'notif.sortAt as indexedAt', + 'record.json as recordJson', + ]) + + const keyset = new NotifsKeyset(ref('notif.sortAt'), ref('notif.recordCid')) + notifBuilder = paginate(notifBuilder, { + cursor, + limit, + keyset, + }) + + const actorStateQuery = db.db + .selectFrom('actor_state') + .selectAll() + .where('did', '=', viewer) + + const [notifs, actorState] = await Promise.all([ + notifBuilder.execute(), + actorStateQuery.executeTakeFirst(), + ]) + + return { + params, + notifs, + cursor: keyset.packFromResult(notifs), + lastSeenNotifs: actorState?.lastSeenNotifs, + } +} + +const hydration = async (state: SkeletonState, ctx: Context) => { + const { graphService, actorService, labelService } = ctx + const { params, notifs } = state + const { viewer } = params + const dids = notifs.map((notif) => notif.authorDid) + const uris = notifs.map((notif) => notif.uri) + const [actors, labels, bam] = await Promise.all([ + actorService.views.profiles(dids, viewer), + labelService.getLabelsForUris(uris), + graphService.getBlockAndMuteState(dids.map((did) => [viewer, did])), + ]) + return { ...state, actors, labels, bam } +} + +const noBlockOrMutes = (state: HydrationState) => { + const { viewer } = state.params + state.notifs = state.notifs.filter( + (item) => + !state.bam.block([viewer, item.authorDid]) && + !state.bam.mute([viewer, item.authorDid]), + ) + return state +} + +const presentation = (state: HydrationState) => { + const { notifs, cursor, actors, labels, lastSeenNotifs } = state + const notifications = mapDefined(notifs, (notif) => { + const author = actors[notif.authorDid] + if (!author) return undefined + const record = jsonStringToLex(notif.recordJson) as Record + const recordLabels = labels[notif.uri] ?? [] + const recordSelfLabels = getSelfLabels({ + uri: notif.uri, + cid: notif.cid, + record, + }) + return { + uri: notif.uri, + cid: notif.cid, + author, + reason: notif.reason, + reasonSubject: notif.reasonSubject || undefined, + record, + isRead: lastSeenNotifs ? notif.indexedAt <= lastSeenNotifs : false, + indexedAt: notif.indexedAt, + labels: [...recordLabels, ...recordSelfLabels], + } + }) + return { notifications, cursor } +} + +type Context = { + db: Database + actorService: ActorService + graphService: GraphService + labelService: LabelService +} + +type Params = QueryParams & { + viewer: string +} + +type SkeletonState = { + params: Params + notifs: NotifRow[] + lastSeenNotifs?: string + cursor?: string +} + +type HydrationState = SkeletonState & { + bam: BlockAndMuteState + actors: ActorInfoMap + labels: Labels +} + +type NotifRow = { + indexedAt: string + cid: string + uri: string + authorDid: string + authorHandle: string | null + authorIndexedAt: string + authorTakedownId: number | null + reason: string + reasonSubject: string | null + recordJson: string +} + class NotifsKeyset extends TimeCidKeyset { labelResult(result: NotifRow) { return { primary: result.indexedAt, secondary: result.cid } diff --git a/packages/bsky/src/api/app/bsky/unspecced/getPopularFeedGenerators.ts b/packages/bsky/src/api/app/bsky/unspecced/getPopularFeedGenerators.ts index 7c96522b6da..2971beba381 100644 --- a/packages/bsky/src/api/app/bsky/unspecced/getPopularFeedGenerators.ts +++ b/packages/bsky/src/api/app/bsky/unspecced/getPopularFeedGenerators.ts @@ -15,6 +15,7 @@ export default function (server: Server, ctx: AppContext) { const db = ctx.db.getReplica() const { ref } = db.db.dynamic const feedService = ctx.services.feed(db) + const actorService = ctx.services.actor(db) let inner = db.db .selectFrom('feed_generator') @@ -49,7 +50,7 @@ export default function (server: Server, ctx: AppContext) { ) const creators = Object.values(genInfos).map((gen) => gen.creator) - const profiles = await feedService.getActorInfos(creators, requester) + const profiles = await actorService.views.profiles(creators, requester) const genViews: GeneratorView[] = [] for (const row of res) { diff --git a/packages/bsky/src/api/app/bsky/unspecced/getTimelineSkeleton.ts b/packages/bsky/src/api/app/bsky/unspecced/getTimelineSkeleton.ts index fa0e8626ca9..821eeda655f 100644 --- a/packages/bsky/src/api/app/bsky/unspecced/getTimelineSkeleton.ts +++ b/packages/bsky/src/api/app/bsky/unspecced/getTimelineSkeleton.ts @@ -1,21 +1,25 @@ import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { getTimelineSkeleton } from '../feed/getTimeline' +import { skeleton } from '../feed/getTimeline' +import { toSkeletonItem } from '../../../../feed-gen/types' // THIS IS A TEMPORARY UNSPECCED ROUTE export default function (server: Server, ctx: AppContext) { server.app.bsky.unspecced.getTimelineSkeleton({ auth: ctx.authVerifier, handler: async ({ auth, params }) => { - const { limit, cursor } = params + const db = ctx.db.getReplica('timeline') + const feedService = ctx.services.feed(db) const viewer = auth.credentials.did - const db = ctx.db.getReplica('timeline') - const skeleton = await getTimelineSkeleton(db, viewer, limit, cursor) + const result = await skeleton({ ...params, viewer }, { db, feedService }) return { encoding: 'application/json', - body: skeleton, + body: { + feed: result.feedItems.map(toSkeletonItem), + cursor: result.cursor, + }, } }, }) diff --git a/packages/bsky/src/api/index.ts b/packages/bsky/src/api/index.ts index e2b1515e412..ec64c2236bf 100644 --- a/packages/bsky/src/api/index.ts +++ b/packages/bsky/src/api/index.ts @@ -17,6 +17,7 @@ import getProfile from './app/bsky/actor/getProfile' import getProfiles from './app/bsky/actor/getProfiles' import getRepostedBy from './app/bsky/feed/getRepostedBy' import getBlocks from './app/bsky/graph/getBlocks' +import getListBlocks from './app/bsky/graph/getListBlocks' import getFollowers from './app/bsky/graph/getFollowers' import getFollows from './app/bsky/graph/getFollows' import getList from './app/bsky/graph/getList' @@ -75,6 +76,7 @@ export default function (server: Server, ctx: AppContext) { getProfiles(server, ctx) getRepostedBy(server, ctx) getBlocks(server, ctx) + getListBlocks(server, ctx) getFollowers(server, ctx) getFollows(server, ctx) getList(server, ctx) diff --git a/packages/bsky/src/db/database-schema.ts b/packages/bsky/src/db/database-schema.ts index 4c896ef5375..adb8c088207 100644 --- a/packages/bsky/src/db/database-schema.ts +++ b/packages/bsky/src/db/database-schema.ts @@ -12,6 +12,7 @@ import * as like from './tables/like' import * as list from './tables/list' import * as listItem from './tables/list-item' import * as listMute from './tables/list-mute' +import * as listBlock from './tables/list-block' import * as mute from './tables/mute' import * as actorBlock from './tables/actor-block' import * as feedGenerator from './tables/feed-generator' @@ -43,6 +44,7 @@ export type DatabaseSchemaType = duplicateRecord.PartialDB & list.PartialDB & listItem.PartialDB & listMute.PartialDB & + listBlock.PartialDB & mute.PartialDB & actorBlock.PartialDB & feedGenerator.PartialDB & diff --git a/packages/bsky/src/db/migrations/20230904T211011773Z-block-lists.ts b/packages/bsky/src/db/migrations/20230904T211011773Z-block-lists.ts new file mode 100644 index 00000000000..e61996c573f --- /dev/null +++ b/packages/bsky/src/db/migrations/20230904T211011773Z-block-lists.ts @@ -0,0 +1,24 @@ +import { Kysely, sql } from 'kysely' + +export async function up(db: Kysely): Promise { + await db.schema + .createTable('list_block') + .addColumn('uri', 'varchar', (col) => col.primaryKey()) + .addColumn('cid', 'varchar', (col) => col.notNull()) + .addColumn('creator', 'varchar', (col) => col.notNull()) + .addColumn('subjectUri', 'varchar', (col) => col.notNull()) + .addColumn('createdAt', 'varchar', (col) => col.notNull()) + .addColumn('indexedAt', 'varchar', (col) => col.notNull()) + .addColumn('sortAt', 'varchar', (col) => + col + .generatedAlwaysAs(sql`least("createdAt", "indexedAt")`) + .stored() + .notNull(), + ) + .addUniqueConstraint('list_block_unique_subject', ['creator', 'subjectUri']) + .execute() +} + +export async function down(db: Kysely): Promise { + await db.schema.dropTable('list_block').execute() +} diff --git a/packages/bsky/src/db/migrations/index.ts b/packages/bsky/src/db/migrations/index.ts index 61463fd5c4e..505f7c84909 100644 --- a/packages/bsky/src/db/migrations/index.ts +++ b/packages/bsky/src/db/migrations/index.ts @@ -26,3 +26,4 @@ export * as _20230808T172902639Z from './20230808T172902639Z-repo-rev' export * as _20230810T203349843Z from './20230810T203349843Z-action-duration' export * as _20230817T195936007Z from './20230817T195936007Z-native-notifications' export * as _20230830T205507322Z from './20230830T205507322Z-suggested-feeds' +export * as _20230904T211011773Z from './20230904T211011773Z-block-lists' diff --git a/packages/bsky/src/db/tables/list-block.ts b/packages/bsky/src/db/tables/list-block.ts new file mode 100644 index 00000000000..69936f4d7fd --- /dev/null +++ b/packages/bsky/src/db/tables/list-block.ts @@ -0,0 +1,15 @@ +import { GeneratedAlways } from 'kysely' + +export const tableName = 'list_block' + +export interface ListBlock { + uri: string + cid: string + creator: string + subjectUri: string + createdAt: string + indexedAt: string + sortAt: GeneratedAlways +} + +export type PartialDB = { [tableName]: ListBlock } diff --git a/packages/bsky/src/feed-gen/best-of-follows.ts b/packages/bsky/src/feed-gen/best-of-follows.ts index c154ad9aa0e..c1d4ee4d21b 100644 --- a/packages/bsky/src/feed-gen/best-of-follows.ts +++ b/packages/bsky/src/feed-gen/best-of-follows.ts @@ -12,7 +12,6 @@ const handler: AlgoHandler = async ( const { limit, cursor } = params const db = ctx.db.getReplica('feed') const feedService = ctx.services.feed(db) - const graphService = ctx.services.graph(db) const { ref } = db.db.dynamic @@ -31,10 +30,6 @@ const handler: AlgoHandler = async ( .whereRef('follow.subjectDid', '=', 'post.creator'), ), ) - .where((qb) => - graphService.whereNotMuted(qb, viewer, [ref('post.creator')]), - ) - .whereNotExists(graphService.blockQb(viewer, [ref('post.creator')])) .select('candidate.score') .select('candidate.cid') diff --git a/packages/bsky/src/feed-gen/bsky-team.ts b/packages/bsky/src/feed-gen/bsky-team.ts index 40e9cc63fe5..3592dd42e26 100644 --- a/packages/bsky/src/feed-gen/bsky-team.ts +++ b/packages/bsky/src/feed-gen/bsky-team.ts @@ -14,22 +14,17 @@ const BSKY_TEAM: NotEmptyArray = [ const handler: AlgoHandler = async ( ctx: AppContext, params: SkeletonParams, - viewer: string, + _viewer: string, ): Promise => { const { limit = 50, cursor } = params const db = ctx.db.getReplica('feed') const feedService = ctx.services.feed(db) - const graphService = ctx.services.graph(db) const { ref } = db.db.dynamic const postsQb = feedService .selectPostQb() .where('post.creator', 'in', BSKY_TEAM) - .where((qb) => - graphService.whereNotMuted(qb, viewer, [ref('post.creator')]), - ) - .whereNotExists(graphService.blockQb(viewer, [ref('post.creator')])) const keyset = new FeedKeyset(ref('sortAt'), ref('cid')) @@ -37,6 +32,7 @@ const handler: AlgoHandler = async ( feedQb = paginate(feedQb, { limit, cursor, keyset }) const feedItems = await feedQb.execute() + return { feedItems, cursor: keyset.packFromResult(feedItems), diff --git a/packages/bsky/src/feed-gen/hot-classic.ts b/packages/bsky/src/feed-gen/hot-classic.ts index fb191328002..c042cea7116 100644 --- a/packages/bsky/src/feed-gen/hot-classic.ts +++ b/packages/bsky/src/feed-gen/hot-classic.ts @@ -11,12 +11,11 @@ const NO_WHATS_HOT_LABELS: NotEmptyArray = ['!no-promote'] const handler: AlgoHandler = async ( ctx: AppContext, params: SkeletonParams, - viewer: string, + _viewer: string, ): Promise => { const { limit = 50, cursor } = params const db = ctx.db.getReplica('feed') const feedService = ctx.services.feed(db) - const graphService = ctx.services.graph(db) const { ref } = db.db.dynamic @@ -39,10 +38,6 @@ const handler: AlgoHandler = async ( .orWhereRef('label.uri', '=', ref('post_embed_record.embedUri')), ), ) - .where((qb) => - graphService.whereNotMuted(qb, viewer, [ref('post.creator')]), - ) - .whereNotExists(graphService.blockQb(viewer, [ref('post.creator')])) const keyset = new FeedKeyset(ref('sortAt'), ref('cid')) @@ -50,6 +45,7 @@ const handler: AlgoHandler = async ( feedQb = paginate(feedQb, { limit, cursor, keyset }) const feedItems = await feedQb.execute() + return { feedItems, cursor: keyset.packFromResult(feedItems), diff --git a/packages/bsky/src/feed-gen/mutuals.ts b/packages/bsky/src/feed-gen/mutuals.ts index c12ef713ada..65a3311a524 100644 --- a/packages/bsky/src/feed-gen/mutuals.ts +++ b/packages/bsky/src/feed-gen/mutuals.ts @@ -12,7 +12,6 @@ const handler: AlgoHandler = async ( const { limit = 50, cursor } = params const db = ctx.db.getReplica('feed') const feedService = ctx.services.feed(db) - const graphService = ctx.services.graph(db) const { ref } = db.db.dynamic @@ -40,14 +39,11 @@ const handler: AlgoHandler = async ( .orWhere('originatorDid', 'in', mutualsSubquery), ) .where('feed_item.sortAt', '>', getFeedDateThreshold(sortFrom)) - .where((qb) => - graphService.whereNotMuted(qb, viewer, [ref('originatorDid')]), - ) - .whereNotExists(graphService.blockQb(viewer, [ref('originatorDid')])) feedQb = paginate(feedQb, { limit, cursor, keyset }) const feedItems = await feedQb.execute() + return { feedItems, cursor: keyset.packFromResult(feedItems), diff --git a/packages/bsky/src/feed-gen/types.ts b/packages/bsky/src/feed-gen/types.ts index 9670dff6018..11ebf53fb39 100644 --- a/packages/bsky/src/feed-gen/types.ts +++ b/packages/bsky/src/feed-gen/types.ts @@ -1,6 +1,7 @@ import AppContext from '../context' +import { SkeletonFeedPost } from '../lexicon/types/app/bsky/feed/defs' import { QueryParams as SkeletonParams } from '../lexicon/types/app/bsky/feed/getFeedSkeleton' -import { FeedRow } from '../services/feed/types' +import { FeedRow } from '../services/feed' export type AlgoResponse = { feedItems: FeedRow[] @@ -14,3 +15,17 @@ export type AlgoHandler = ( ) => Promise export type MountedAlgos = Record + +export const toSkeletonItem = (feedItem: { + uri: string + postUri: string +}): SkeletonFeedPost => ({ + post: feedItem.postUri, + reason: + feedItem.uri === feedItem.postUri + ? undefined + : { + $type: 'app.bsky.feed.defs#skeletonReasonRepost', + repost: feedItem.uri, + }, +}) diff --git a/packages/bsky/src/feed-gen/whats-hot.ts b/packages/bsky/src/feed-gen/whats-hot.ts index 1d74f72fcab..511c767804e 100644 --- a/packages/bsky/src/feed-gen/whats-hot.ts +++ b/packages/bsky/src/feed-gen/whats-hot.ts @@ -21,11 +21,10 @@ const NO_WHATS_HOT_LABELS: NotEmptyArray = [ const handler: AlgoHandler = async ( ctx: AppContext, params: SkeletonParams, - viewer: string, + _viewer: string, ): Promise => { const { limit, cursor } = params const db = ctx.db.getReplica('feed') - const graphService = ctx.services.graph(db) const { ref } = db.db.dynamic @@ -48,14 +47,9 @@ const handler: AlgoHandler = async ( .orWhereRef('label.uri', '=', ref('post_embed_record.embedUri')), ), ) - .where((qb) => - graphService.whereNotMuted(qb, viewer, [ref('post.creator')]), - ) - .whereNotExists(graphService.blockQb(viewer, [ref('post.creator')])) .select([ sql`${'post'}`.as('type'), 'post.uri as uri', - 'post.cid as cid', 'post.uri as postUri', 'post.creator as originatorDid', 'post.creator as postAuthorDid', diff --git a/packages/bsky/src/feed-gen/with-friends.ts b/packages/bsky/src/feed-gen/with-friends.ts index efd4b961e9d..98f784102a5 100644 --- a/packages/bsky/src/feed-gen/with-friends.ts +++ b/packages/bsky/src/feed-gen/with-friends.ts @@ -29,6 +29,7 @@ const handler: AlgoHandler = async ( postsQb = paginate(postsQb, { limit, cursor, keyset, tryIndex: true }) const feedItems = await postsQb.execute() + return { feedItems, cursor: keyset.packFromResult(feedItems), diff --git a/packages/bsky/src/lexicon/index.ts b/packages/bsky/src/lexicon/index.ts index a99d4d6e51b..df15a497c63 100644 --- a/packages/bsky/src/lexicon/index.ts +++ b/packages/bsky/src/lexicon/index.ts @@ -93,6 +93,7 @@ import * as AppBskyGraphGetBlocks from './types/app/bsky/graph/getBlocks' import * as AppBskyGraphGetFollowers from './types/app/bsky/graph/getFollowers' import * as AppBskyGraphGetFollows from './types/app/bsky/graph/getFollows' import * as AppBskyGraphGetList from './types/app/bsky/graph/getList' +import * as AppBskyGraphGetListBlocks from './types/app/bsky/graph/getListBlocks' import * as AppBskyGraphGetListMutes from './types/app/bsky/graph/getListMutes' import * as AppBskyGraphGetLists from './types/app/bsky/graph/getLists' import * as AppBskyGraphGetMutes from './types/app/bsky/graph/getMutes' @@ -1228,6 +1229,17 @@ export class GraphNS { return this._server.xrpc.method(nsid, cfg) } + getListBlocks( + cfg: ConfigOf< + AV, + AppBskyGraphGetListBlocks.Handler>, + AppBskyGraphGetListBlocks.HandlerReqCtx> + >, + ) { + const nsid = 'app.bsky.graph.getListBlocks' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } + getListMutes( cfg: ConfigOf< AV, diff --git a/packages/bsky/src/lexicon/lexicons.ts b/packages/bsky/src/lexicon/lexicons.ts index 193b9c39d37..c49b098002b 100644 --- a/packages/bsky/src/lexicon/lexicons.ts +++ b/packages/bsky/src/lexicon/lexicons.ts @@ -5728,6 +5728,10 @@ export const schemaDict = { muted: { type: 'boolean', }, + blocked: { + type: 'string', + format: 'at-uri', + }, }, }, }, @@ -5956,6 +5960,49 @@ export const schemaDict = { }, }, }, + AppBskyGraphGetListBlocks: { + lexicon: 1, + id: 'app.bsky.graph.getListBlocks', + defs: { + main: { + type: 'query', + description: "Which lists is the requester's account blocking?", + parameters: { + type: 'params', + properties: { + limit: { + type: 'integer', + minimum: 1, + maximum: 100, + default: 50, + }, + cursor: { + type: 'string', + }, + }, + }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['lists'], + properties: { + cursor: { + type: 'string', + }, + lists: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:app.bsky.graph.defs#listView', + }, + }, + }, + }, + }, + }, + }, + }, AppBskyGraphGetListMutes: { lexicon: 1, id: 'app.bsky.graph.getListMutes', @@ -6141,6 +6188,31 @@ export const schemaDict = { }, }, }, + AppBskyGraphListblock: { + lexicon: 1, + id: 'app.bsky.graph.listblock', + defs: { + main: { + type: 'record', + description: 'A block of an entire list of actors.', + key: 'tid', + record: { + type: 'object', + required: ['subject', 'createdAt'], + properties: { + subject: { + type: 'string', + format: 'at-uri', + }, + createdAt: { + type: 'string', + format: 'datetime', + }, + }, + }, + }, + }, + }, AppBskyGraphListitem: { lexicon: 1, id: 'app.bsky.graph.listitem', @@ -6798,10 +6870,12 @@ export const ids = { AppBskyGraphGetFollowers: 'app.bsky.graph.getFollowers', AppBskyGraphGetFollows: 'app.bsky.graph.getFollows', AppBskyGraphGetList: 'app.bsky.graph.getList', + AppBskyGraphGetListBlocks: 'app.bsky.graph.getListBlocks', AppBskyGraphGetListMutes: 'app.bsky.graph.getListMutes', AppBskyGraphGetLists: 'app.bsky.graph.getLists', AppBskyGraphGetMutes: 'app.bsky.graph.getMutes', AppBskyGraphList: 'app.bsky.graph.list', + AppBskyGraphListblock: 'app.bsky.graph.listblock', AppBskyGraphListitem: 'app.bsky.graph.listitem', AppBskyGraphMuteActor: 'app.bsky.graph.muteActor', AppBskyGraphMuteActorList: 'app.bsky.graph.muteActorList', diff --git a/packages/bsky/src/lexicon/types/app/bsky/graph/defs.ts b/packages/bsky/src/lexicon/types/app/bsky/graph/defs.ts index e50338d488d..63c05b5faa3 100644 --- a/packages/bsky/src/lexicon/types/app/bsky/graph/defs.ts +++ b/packages/bsky/src/lexicon/types/app/bsky/graph/defs.ts @@ -81,6 +81,7 @@ export const MODLIST = 'app.bsky.graph.defs#modlist' export interface ListViewerState { muted?: boolean + blocked?: string [k: string]: unknown } diff --git a/packages/bsky/src/lexicon/types/app/bsky/graph/getListBlocks.ts b/packages/bsky/src/lexicon/types/app/bsky/graph/getListBlocks.ts new file mode 100644 index 00000000000..04cca70b44d --- /dev/null +++ b/packages/bsky/src/lexicon/types/app/bsky/graph/getListBlocks.ts @@ -0,0 +1,48 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import express from 'express' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +import { CID } from 'multiformats/cid' +import { HandlerAuth } from '@atproto/xrpc-server' +import * as AppBskyGraphDefs from './defs' + +export interface QueryParams { + limit: number + cursor?: string +} + +export type InputSchema = undefined + +export interface OutputSchema { + cursor?: string + lists: AppBskyGraphDefs.ListView[] + [k: string]: unknown +} + +export type HandlerInput = undefined + +export interface HandlerSuccess { + encoding: 'application/json' + body: OutputSchema + headers?: { [key: string]: string } +} + +export interface HandlerError { + status: number + message?: string +} + +export type HandlerOutput = HandlerError | HandlerSuccess +export type HandlerReqCtx = { + auth: HA + params: QueryParams + input: HandlerInput + req: express.Request + res: express.Response +} +export type Handler = ( + ctx: HandlerReqCtx, +) => Promise | HandlerOutput diff --git a/packages/bsky/src/lexicon/types/app/bsky/graph/listblock.ts b/packages/bsky/src/lexicon/types/app/bsky/graph/listblock.ts new file mode 100644 index 00000000000..59f2e057eb5 --- /dev/null +++ b/packages/bsky/src/lexicon/types/app/bsky/graph/listblock.ts @@ -0,0 +1,26 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +import { CID } from 'multiformats/cid' + +export interface Record { + subject: string + createdAt: string + [k: string]: unknown +} + +export function isRecord(v: unknown): v is Record { + return ( + isObj(v) && + hasProp(v, '$type') && + (v.$type === 'app.bsky.graph.listblock#main' || + v.$type === 'app.bsky.graph.listblock') + ) +} + +export function validateRecord(v: unknown): ValidationResult { + return lexicons.validate('app.bsky.graph.listblock#main', v) +} diff --git a/packages/bsky/src/pipeline.ts b/packages/bsky/src/pipeline.ts new file mode 100644 index 00000000000..7798519bfa2 --- /dev/null +++ b/packages/bsky/src/pipeline.ts @@ -0,0 +1,22 @@ +export function createPipeline< + Params, + SkeletonState, + HydrationState extends SkeletonState, + View, + Context, +>( + skeleton: (params: Params, ctx: Context) => Promise, + hydration: (state: SkeletonState, ctx: Context) => Promise, + rules: (state: HydrationState, ctx: Context) => HydrationState, + presentation: (state: HydrationState, ctx: Context) => View, +) { + return async (params: Params, ctx: Context) => { + const skeletonState = await skeleton(params, ctx) + const hydrationState = await hydration(skeletonState, ctx) + return presentation(rules(hydrationState, ctx), ctx) + } +} + +export function noRules(state: T) { + return state +} diff --git a/packages/bsky/src/services/actor/index.ts b/packages/bsky/src/services/actor/index.ts index 5012f27142f..54bfb714146 100644 --- a/packages/bsky/src/services/actor/index.ts +++ b/packages/bsky/src/services/actor/index.ts @@ -8,6 +8,8 @@ import { LabelCache } from '../../label-cache' import { TimeCidKeyset, paginate } from '../../db/pagination' import { SearchKeyset, getUserSearchQuery } from '../util/search' +export * from './types' + export class ActorService { constructor( public db: Database, diff --git a/packages/bsky/src/services/actor/types.ts b/packages/bsky/src/services/actor/types.ts new file mode 100644 index 00000000000..e853406e22e --- /dev/null +++ b/packages/bsky/src/services/actor/types.ts @@ -0,0 +1,74 @@ +import { ListViewBasic } from '../../lexicon/types/app/bsky/graph/defs' +import { Label } from '../../lexicon/types/com/atproto/label/defs' +import { BlockAndMuteState } from '../graph' +import { ListInfoMap } from '../graph/types' +import { Labels } from '../label' + +export type ActorInfo = { + did: string + handle: string + displayName?: string + description?: string // omitted from basic profile view + avatar?: string + indexedAt?: string // omitted from basic profile view + viewer?: { + muted?: boolean + mutedByList?: ListViewBasic + blockedBy?: boolean + blocking?: string + following?: string + followedBy?: string + } + labels?: Label[] +} +export type ActorInfoMap = { [did: string]: ActorInfo } + +export type ProfileViewMap = ActorInfoMap + +export type ProfileInfo = { + did: string + handle: string | null + profileUri: string | null + profileCid: string | null + displayName: string | null + description: string | null + avatarCid: string | null + indexedAt: string | null + profileJson: string | null + viewerFollowing: string | null + viewerFollowedBy: string | null +} + +export type ProfileInfoMap = { [did: string]: ProfileInfo } + +export type ProfileHydrationState = { + profiles: ProfileInfoMap + labels: Labels + lists: ListInfoMap + bam: BlockAndMuteState +} + +export type ProfileDetailInfo = ProfileInfo & { + bannerCid: string | null + followsCount: number | null + followersCount: number | null + postsCount: number | null +} + +export type ProfileDetailInfoMap = { [did: string]: ProfileDetailInfo } + +export type ProfileDetailHydrationState = { + profilesDetailed: ProfileDetailInfoMap + labels: Labels + lists: ListInfoMap + bam: BlockAndMuteState +} + +export const toMapByDid = ( + items: T[], +): Record => { + return items.reduce((cur, item) => { + cur[item.did] = item + return cur + }, {} as Record) +} diff --git a/packages/bsky/src/services/actor/views.ts b/packages/bsky/src/services/actor/views.ts index ff90a6be2c5..80652599f80 100644 --- a/packages/bsky/src/services/actor/views.ts +++ b/packages/bsky/src/services/actor/views.ts @@ -4,15 +4,23 @@ import { jsonStringToLex } from '@atproto/lexicon' import { ProfileViewDetailed, ProfileView, - ProfileViewBasic, } from '../../lexicon/types/app/bsky/actor/defs' import { Database } from '../../db' import { noMatch, notSoftDeletedClause } from '../../db/util' import { Actor } from '../../db/tables/actor' import { ImageUriBuilder } from '../../image/uri' -import { LabelService, getSelfLabels } from '../label' -import { GraphService } from '../graph' +import { LabelService, Labels, getSelfLabels } from '../label' +import { BlockAndMuteState, GraphService } from '../graph' import { LabelCache } from '../../label-cache' +import { + ActorInfoMap, + ProfileDetailHydrationState, + ProfileHydrationState, + ProfileInfoMap, + ProfileViewMap, + toMapByDid, +} from './types' +import { ListInfoMap } from '../graph/types' export class ActorViews { constructor( @@ -26,20 +34,65 @@ export class ActorViews { graph: GraphService.creator(this.imgUriBuilder)(this.db), } - async profilesDetailed( - results: ActorResult[], + async profiles( + results: (ActorResult | string)[], // @TODO simplify down to just string[] viewer: string | null, - opts?: { skipLabels?: boolean; includeSoftDeleted?: boolean }, - ): Promise> { + opts?: { includeSoftDeleted?: boolean }, + ): Promise { if (results.length === 0) return {} + const dids = results.map((res) => (typeof res === 'string' ? res : res.did)) + const hydrated = await this.profileHydration(dids, { + viewer, + ...opts, + }) + return this.profilePresentation(dids, hydrated, { + viewer, + ...opts, + }) + } - const { ref } = this.db.db.dynamic - const { skipLabels = false, includeSoftDeleted = false } = opts ?? {} - const dids = results.map((r) => r.did) + async profilesBasic( + results: (ActorResult | string)[], + viewer: string | null, + opts?: { omitLabels?: boolean; includeSoftDeleted?: boolean }, + ): Promise { + if (results.length === 0) return {} + const dids = results.map((res) => (typeof res === 'string' ? res : res.did)) + const hydrated = await this.profileHydration(dids, { + viewer, + includeSoftDeleted: opts?.includeSoftDeleted, + }) + return this.profileBasicPresentation(dids, hydrated, { + viewer, + omitLabels: opts?.omitLabels, + }) + } + async profilesList( + results: ActorResult[], + viewer: string | null, + opts?: { includeSoftDeleted?: boolean }, + ): Promise { + const profiles = await this.profiles(results, viewer, opts) + return mapDefined(results, (result) => profiles[result.did]) + } + + async profileDetailHydration( + dids: string[], + opts: { + viewer?: string | null + includeSoftDeleted?: boolean + }, + state?: { + bam: BlockAndMuteState + labels: Labels + }, + ): Promise { + const { viewer = null, includeSoftDeleted } = opts + const { ref } = this.db.db.dynamic const profileInfosQb = this.db.db .selectFrom('actor') - .where('actor.did', 'in', dids) + .where('actor.did', 'in', dids.length ? dids : ['']) .leftJoin('profile', 'profile.creator', 'actor.did') .leftJoin('profile_agg', 'profile_agg.did', 'actor.did') .leftJoin('record', 'record.uri', 'profile.uri') @@ -66,138 +119,107 @@ export class ActorViews { .where('creator', '=', viewer ?? '') .whereRef('subjectDid', '=', ref('actor.did')) .select('uri') - .as('requesterFollowing'), + .as('viewerFollowing'), this.db.db .selectFrom('follow') .if(!viewer, (q) => q.where(noMatch)) .whereRef('creator', '=', ref('actor.did')) .where('subjectDid', '=', viewer ?? '') .select('uri') - .as('requesterFollowedBy'), - this.db.db - .selectFrom('actor_block') - .if(!viewer, (q) => q.where(noMatch)) - .where('creator', '=', viewer ?? '') - .whereRef('subjectDid', '=', ref('actor.did')) - .select('uri') - .as('requesterBlocking'), - this.db.db - .selectFrom('actor_block') - .if(!viewer, (q) => q.where(noMatch)) - .whereRef('creator', '=', ref('actor.did')) - .where('subjectDid', '=', viewer ?? '') - .select('uri') - .as('requesterBlockedBy'), - this.db.db - .selectFrom('mute') - .if(!viewer, (q) => q.where(noMatch)) - .whereRef('subjectDid', '=', ref('actor.did')) - .where('mutedByDid', '=', viewer ?? '') - .select('subjectDid') - .as('requesterMuted'), - this.db.db - .selectFrom('list_item') - .if(!viewer, (q) => q.where(noMatch)) - .innerJoin('list_mute', 'list_mute.listUri', 'list_item.listUri') - .where('list_mute.mutedByDid', '=', viewer ?? '') - .whereRef('list_item.subjectDid', '=', ref('actor.did')) - .select('list_item.listUri') - .limit(1) - .as('requesterMutedByList'), + .as('viewerFollowedBy'), ]) - - const [profileInfos, labels] = await Promise.all([ + const [profiles, labels, bam] = await Promise.all([ profileInfosQb.execute(), - this.services.label.getLabelsForSubjects(skipLabels ? [] : dids), + this.services.label.getLabelsForSubjects(dids, state?.labels), + this.services.graph.getBlockAndMuteState( + viewer ? dids.map((did) => [viewer, did]) : [], + state?.bam, + ), ]) + const listUris = mapDefined(profiles, ({ did }) => { + const list = viewer && bam.muteList([viewer, did]) + if (!list) return + return list + }) + const lists = await this.services.graph.getListViews(listUris, viewer) + return { profilesDetailed: toMapByDid(profiles), labels, bam, lists } + } - const listUris: string[] = profileInfos - .map((a) => a.requesterMutedByList) - .filter((list) => !!list) - const listViews = await this.services.graph.getListViews(listUris, viewer) - - return profileInfos.reduce((acc, cur) => { - const avatar = cur?.avatarCid - ? this.imgUriBuilder.getPresetUri('avatar', cur.did, cur.avatarCid) + profileDetailPresentation( + dids: string[], + state: ProfileDetailHydrationState, + opts: { + viewer?: string | null + }, + ): Record { + const { viewer } = opts + const { profilesDetailed, lists, labels, bam } = state + return dids.reduce((acc, did) => { + const prof = profilesDetailed[did] + if (!prof) return acc + const avatar = prof?.avatarCid + ? this.imgUriBuilder.getPresetUri('avatar', prof.did, prof.avatarCid) : undefined - const banner = cur?.bannerCid - ? this.imgUriBuilder.getPresetUri('banner', cur.did, cur.bannerCid) + const banner = prof?.bannerCid + ? this.imgUriBuilder.getPresetUri('banner', prof.did, prof.bannerCid) : undefined + const mutedByListUri = viewer && bam.muteList([viewer, did]) const mutedByList = - cur.requesterMutedByList && listViews[cur.requesterMutedByList] - ? this.services.graph.formatListViewBasic( - listViews[cur.requesterMutedByList], - ) + mutedByListUri && lists[mutedByListUri] + ? this.services.graph.formatListViewBasic(lists[mutedByListUri]) : undefined - const actorLabels = labels[cur.did] ?? [] + const actorLabels = labels[did] ?? [] const selfLabels = getSelfLabels({ - uri: cur.profileUri, - cid: cur.profileCid, + uri: prof.profileUri, + cid: prof.profileCid, record: - cur.profileJson !== null - ? (jsonStringToLex(cur.profileJson) as Record) + prof.profileJson !== null + ? (jsonStringToLex(prof.profileJson) as Record) : null, }) - const profile = { - did: cur.did, - handle: cur.handle ?? INVALID_HANDLE, - displayName: cur?.displayName || undefined, - description: cur?.description || undefined, + acc[did] = { + did: prof.did, + handle: prof.handle ?? INVALID_HANDLE, + displayName: prof?.displayName || undefined, + description: prof?.description || undefined, avatar, banner, - followsCount: cur?.followsCount ?? 0, - followersCount: cur?.followersCount ?? 0, - postsCount: cur?.postsCount ?? 0, - indexedAt: cur?.indexedAt || undefined, + followsCount: prof?.followsCount ?? 0, + followersCount: prof?.followersCount ?? 0, + postsCount: prof?.postsCount ?? 0, + indexedAt: prof?.indexedAt || undefined, viewer: viewer ? { - following: cur?.requesterFollowing || undefined, - followedBy: cur?.requesterFollowedBy || undefined, - muted: !!cur?.requesterMuted || !!cur.requesterMutedByList, + muted: bam.mute([viewer, did]), mutedByList, - blockedBy: !!cur.requesterBlockedBy, - blocking: cur.requesterBlocking || undefined, + blockedBy: !!bam.blockedBy([viewer, did]), + blocking: bam.blocking([viewer, did]) ?? undefined, + following: prof?.viewerFollowing || undefined, + followedBy: prof?.viewerFollowedBy || undefined, } : undefined, - labels: skipLabels ? undefined : [...actorLabels, ...selfLabels], + labels: [...actorLabels, ...selfLabels], } - acc[cur.did] = profile return acc }, {} as Record) } - async hydrateProfilesDetailed( - results: ActorResult[], - viewer: string | null, - opts?: { skipLabels?: boolean; includeSoftDeleted?: boolean }, - ): Promise { - const profiles = await this.profilesDetailed(results, viewer, opts) - return mapDefined(results, (result) => profiles[result.did]) - } - - async profileDetailed( - result: ActorResult, - viewer: string | null, - opts?: { skipLabels?: boolean; includeSoftDeleted?: boolean }, - ): Promise { - const profiles = await this.profilesDetailed([result], viewer, opts) - return profiles[result.did] ?? null - } - - async profiles( - results: ActorResult[], - viewer: string | null, - opts?: { skipLabels?: boolean; includeSoftDeleted?: boolean }, - ): Promise> { - if (results.length === 0) return {} - + async profileHydration( + dids: string[], + opts: { + viewer?: string | null + includeSoftDeleted?: boolean + }, + state?: { + bam: BlockAndMuteState + labels: Labels + }, + ): Promise { + const { viewer = null, includeSoftDeleted } = opts const { ref } = this.db.db.dynamic - const { skipLabels = false, includeSoftDeleted = false } = opts ?? {} - const dids = results.map((r) => r.did) - const profileInfosQb = this.db.db .selectFrom('actor') - .where('actor.did', 'in', dids) + .where('actor.did', 'in', dids.length ? dids : ['']) .leftJoin('profile', 'profile.creator', 'actor.did') .leftJoin('record', 'record.uri', 'profile.uri') .if(!includeSoftDeleted, (qb) => @@ -219,154 +241,110 @@ export class ActorViews { .where('creator', '=', viewer ?? '') .whereRef('subjectDid', '=', ref('actor.did')) .select('uri') - .as('requesterFollowing'), + .as('viewerFollowing'), this.db.db .selectFrom('follow') .if(!viewer, (q) => q.where(noMatch)) .whereRef('creator', '=', ref('actor.did')) .where('subjectDid', '=', viewer ?? '') .select('uri') - .as('requesterFollowedBy'), - this.db.db - .selectFrom('actor_block') - .if(!viewer, (q) => q.where(noMatch)) - .where('creator', '=', viewer ?? '') - .whereRef('subjectDid', '=', ref('actor.did')) - .select('uri') - .as('requesterBlocking'), - this.db.db - .selectFrom('actor_block') - .if(!viewer, (q) => q.where(noMatch)) - .whereRef('creator', '=', ref('actor.did')) - .where('subjectDid', '=', viewer ?? '') - .select('uri') - .as('requesterBlockedBy'), - this.db.db - .selectFrom('mute') - .if(!viewer, (q) => q.where(noMatch)) - .whereRef('subjectDid', '=', ref('actor.did')) - .where('mutedByDid', '=', viewer ?? '') - .select('subjectDid') - .as('requesterMuted'), - this.db.db - .selectFrom('list_item') - .if(!viewer, (q) => q.where(noMatch)) - .innerJoin('list_mute', 'list_mute.listUri', 'list_item.listUri') - .where('list_mute.mutedByDid', '=', viewer ?? '') - .whereRef('list_item.subjectDid', '=', ref('actor.did')) - .select('list_item.listUri') - .limit(1) - .as('requesterMutedByList'), + .as('viewerFollowedBy'), ]) - - const [profileInfos, labels] = await Promise.all([ + const [profiles, labels, bam] = await Promise.all([ profileInfosQb.execute(), - this.services.label.getLabelsForSubjects(skipLabels ? [] : dids), + this.services.label.getLabelsForSubjects(dids, state?.labels), + this.services.graph.getBlockAndMuteState( + viewer ? dids.map((did) => [viewer, did]) : [], + state?.bam, + ), ]) + const listUris = mapDefined(profiles, ({ did }) => { + const list = viewer && bam.muteList([viewer, did]) + if (!list) return + return list + }) + const lists = await this.services.graph.getListViews(listUris, viewer) + return { profiles: toMapByDid(profiles), labels, bam, lists } + } - const listUris: string[] = profileInfos - .map((a) => a.requesterMutedByList) - .filter((list) => !!list) - const listViews = await this.services.graph.getListViews(listUris, viewer) - - return profileInfos.reduce((acc, cur) => { - const avatar = cur?.avatarCid - ? this.imgUriBuilder.getPresetUri('avatar', cur.did, cur.avatarCid) + profilePresentation( + dids: string[], + state: { + profiles: ProfileInfoMap + lists: ListInfoMap + labels: Labels + bam: BlockAndMuteState + }, + opts?: { + viewer?: string | null + }, + ): ProfileViewMap { + const { viewer } = opts ?? {} + const { profiles, lists, labels, bam } = state + return dids.reduce((acc, did) => { + const prof = profiles[did] + if (!prof) return acc + const avatar = prof?.avatarCid + ? this.imgUriBuilder.getPresetUri('avatar', prof.did, prof.avatarCid) : undefined + const mutedByListUri = viewer && bam.muteList([viewer, did]) const mutedByList = - cur.requesterMutedByList && listViews[cur.requesterMutedByList] - ? this.services.graph.formatListViewBasic( - listViews[cur.requesterMutedByList], - ) + mutedByListUri && lists[mutedByListUri] + ? this.services.graph.formatListViewBasic(lists[mutedByListUri]) : undefined - const actorLabels = labels[cur.did] ?? [] + const actorLabels = labels[did] ?? [] const selfLabels = getSelfLabels({ - uri: cur.profileUri, - cid: cur.profileCid, + uri: prof.profileUri, + cid: prof.profileCid, record: - cur.profileJson !== null - ? (jsonStringToLex(cur.profileJson) as Record) + prof.profileJson !== null + ? (jsonStringToLex(prof.profileJson) as Record) : null, }) - const profile = { - did: cur.did, - handle: cur.handle ?? INVALID_HANDLE, - displayName: cur?.displayName || undefined, - description: cur?.description || undefined, + acc[did] = { + did: prof.did, + handle: prof.handle ?? INVALID_HANDLE, + displayName: prof?.displayName || undefined, + description: prof?.description || undefined, avatar, - indexedAt: cur?.indexedAt || undefined, + indexedAt: prof?.indexedAt || undefined, viewer: viewer ? { - muted: !!cur?.requesterMuted || !!cur.requesterMutedByList, + muted: bam.mute([viewer, did]), mutedByList, - blockedBy: !!cur.requesterBlockedBy, - blocking: cur.requesterBlocking || undefined, - following: cur?.requesterFollowing || undefined, - followedBy: cur?.requesterFollowedBy || undefined, + blockedBy: !!bam.blockedBy([viewer, did]), + blocking: bam.blocking([viewer, did]) ?? undefined, + following: prof?.viewerFollowing || undefined, + followedBy: prof?.viewerFollowedBy || undefined, } : undefined, - labels: skipLabels ? undefined : [...actorLabels, ...selfLabels], + labels: [...actorLabels, ...selfLabels], } - acc[cur.did] = profile return acc - }, {} as Record) + }, {} as ProfileViewMap) } - async hydrateProfiles( - results: ActorResult[], - viewer: string | null, - opts?: { skipLabels?: boolean; includeSoftDeleted?: boolean }, - ): Promise { - const profiles = await this.profiles(results, viewer, opts) - return mapDefined(results, (result) => profiles[result.did]) - } - - async profile( - result: ActorResult, - viewer: string | null, - opts?: { skipLabels?: boolean; includeSoftDeleted?: boolean }, - ): Promise { - const profiles = await this.profiles([result], viewer, opts) - return profiles[result.did] ?? null - } - - // @NOTE keep in sync with feedService.getActorViews() - async profilesBasic( - results: ActorResult[], - viewer: string | null, - opts?: { skipLabels?: boolean; includeSoftDeleted?: boolean }, - ): Promise> { - if (results.length === 0) return {} - const profiles = await this.profiles(results, viewer, opts) - return Object.values(profiles).reduce((acc, cur) => { - const profile = { - did: cur.did, - handle: cur.handle, - displayName: cur.displayName, - avatar: cur.avatar, - viewer: cur.viewer, + profileBasicPresentation( + dids: string[], + state: ProfileHydrationState, + opts?: { + viewer?: string | null + omitLabels?: boolean + }, + ): ProfileViewMap { + const result = this.profilePresentation(dids, state, opts) + return Object.values(result).reduce((acc, prof) => { + const profileBasic = { + did: prof.did, + handle: prof.handle, + displayName: prof.displayName, + avatar: prof.avatar, + viewer: prof.viewer, + labels: opts?.omitLabels ? undefined : prof.labels, } - acc[cur.did] = profile + acc[prof.did] = profileBasic return acc - }, {} as Record) - } - - async hydrateProfilesBasic( - results: ActorResult[], - viewer: string | null, - opts?: { skipLabels?: boolean; includeSoftDeleted?: boolean }, - ): Promise { - const profiles = await this.profilesBasic(results, viewer, opts) - return mapDefined(results, (result) => profiles[result.did]) - } - - async profileBasic( - result: ActorResult, - viewer: string | null, - opts?: { skipLabels?: boolean; includeSoftDeleted?: boolean }, - ): Promise { - const profiles = await this.profilesBasic([result], viewer, opts) - return profiles[result.did] ?? null + }, {} as ProfileViewMap) } } diff --git a/packages/bsky/src/services/feed/index.ts b/packages/bsky/src/services/feed/index.ts index 895cec08c7b..db32f1971bc 100644 --- a/packages/bsky/src/services/feed/index.ts +++ b/packages/bsky/src/services/feed/index.ts @@ -1,15 +1,8 @@ import { sql } from 'kysely' import { AtUri } from '@atproto/syntax' -import { dedupeStrs } from '@atproto/common' -import { INVALID_HANDLE } from '@atproto/syntax' import { jsonStringToLex } from '@atproto/lexicon' import { Database } from '../../db' -import { - countAll, - noMatch, - notSoftDeletedClause, - valuesList, -} from '../../db/util' +import { countAll, noMatch, notSoftDeletedClause } from '../../db/util' import { ImageUriBuilder } from '../../image/uri' import { ids } from '../../lexicon/lexicons' import { @@ -24,26 +17,20 @@ import { } from '../../lexicon/types/app/bsky/embed/record' import { isMain as isEmbedRecordWithMedia } from '../../lexicon/types/app/bsky/embed/recordWithMedia' import { - FeedViewPost, - SkeletonFeedPost, -} from '../../lexicon/types/app/bsky/feed/defs' -import { - ActorInfoMap, PostInfoMap, FeedItemType, FeedRow, FeedGenInfoMap, - PostViews, PostEmbedViews, RecordEmbedViewRecordMap, PostInfo, RecordEmbedViewRecord, PostBlocksMap, - kSelfLabels, + FeedHydrationState, } from './types' -import { LabelService, Labels, getSelfLabels } from '../label' +import { LabelService } from '../label' import { ActorService } from '../actor' -import { GraphService } from '../graph' +import { BlockAndMuteState, GraphService, RelationshipPair } from '../graph' import { FeedViews } from './views' import { LabelCache } from '../../label-cache' @@ -56,7 +43,7 @@ export class FeedService { public labelCache: LabelCache, ) {} - views = new FeedViews(this.db, this.imgUriBuilder) + views = new FeedViews(this.db, this.imgUriBuilder, this.labelCache) services = { label: LabelService.creator(this.labelCache)(this.db), @@ -123,127 +110,6 @@ export class FeedService { ) } - // @TODO just use actor service?? - // @NOTE keep in sync with actorService.views.profile() - async getActorInfos( - dids: string[], - viewer: string | null, - opts?: { skipLabels?: boolean }, // @NOTE used by hydrateFeed() to batch label hydration - ): Promise { - if (dids.length < 1) return {} - const { ref } = this.db.db.dynamic - const { skipLabels } = opts ?? {} - const [actors, labels] = await Promise.all([ - this.db.db - .selectFrom('actor') - .leftJoin('profile', 'profile.creator', 'actor.did') - .leftJoin('record', 'record.uri', 'profile.uri') - .where('actor.did', 'in', dids) - .where(notSoftDeletedClause(ref('actor'))) - .selectAll('actor') - .select([ - 'profile.uri as profileUri', - 'profile.cid as profileCid', - 'profile.displayName as displayName', - 'profile.description as description', - 'profile.avatarCid as avatarCid', - 'profile.indexedAt as indexedAt', - 'record.json as profileJson', - this.db.db - .selectFrom('follow') - .if(!viewer, (q) => q.where(noMatch)) - .where('creator', '=', viewer ?? '') - .whereRef('subjectDid', '=', ref('actor.did')) - .select('uri') - .as('requesterFollowing'), - this.db.db - .selectFrom('follow') - .if(!viewer, (q) => q.where(noMatch)) - .whereRef('creator', '=', ref('actor.did')) - .where('subjectDid', '=', viewer ?? '') - .select('uri') - .as('requesterFollowedBy'), - this.db.db - .selectFrom('actor_block') - .if(!viewer, (q) => q.where(noMatch)) - .where('creator', '=', viewer ?? '') - .whereRef('subjectDid', '=', ref('actor.did')) - .select('uri') - .as('requesterBlocking'), - this.db.db - .selectFrom('actor_block') - .if(!viewer, (q) => q.where(noMatch)) - .whereRef('creator', '=', ref('actor.did')) - .where('subjectDid', '=', viewer ?? '') - .select('uri') - .as('requesterBlockedBy'), - this.db.db - .selectFrom('mute') - .if(!viewer, (q) => q.where(noMatch)) - .whereRef('subjectDid', '=', ref('actor.did')) - .where('mutedByDid', '=', viewer ?? '') - .select('subjectDid') - .as('requesterMuted'), - this.db.db - .selectFrom('list_item') - .if(!viewer, (q) => q.where(noMatch)) - .innerJoin('list_mute', 'list_mute.listUri', 'list_item.listUri') - .where('list_mute.mutedByDid', '=', viewer ?? '') - .whereRef('list_item.subjectDid', '=', ref('actor.did')) - .select('list_item.listUri') - .limit(1) - .as('requesterMutedByList'), - ]) - .execute(), - this.services.label.getLabelsForSubjects(skipLabels ? [] : dids), - ]) - const listUris: string[] = actors - .map((a) => a.requesterMutedByList) - .filter((list) => !!list) - const listViews = await this.services.graph.getListViews(listUris, viewer) - return actors.reduce((acc, cur) => { - const avatar = cur.avatarCid - ? this.imgUriBuilder.getPresetUri('avatar', cur.did, cur.avatarCid) - : undefined - const mutedByList = - cur.requesterMutedByList && listViews[cur.requesterMutedByList] - ? this.services.graph.formatListViewBasic( - listViews[cur.requesterMutedByList], - ) - : undefined - const actorLabels = labels[cur.did] ?? [] - const selfLabels = getSelfLabels({ - uri: cur.profileUri, - cid: cur.profileCid, - record: - cur.profileJson !== null - ? (jsonStringToLex(cur.profileJson) as Record) - : null, - }) - return { - ...acc, - [cur.did]: { - did: cur.did, - handle: cur.handle ?? INVALID_HANDLE, - displayName: cur.displayName ?? undefined, - avatar, - viewer: viewer - ? { - muted: !!cur?.requesterMuted || !!cur?.requesterMutedByList, - mutedByList, - blockedBy: !!cur?.requesterBlockedBy, - blocking: cur?.requesterBlocking || undefined, - following: cur?.requesterFollowing || undefined, - followedBy: cur?.requesterFollowedBy || undefined, - } - : undefined, - labels: skipLabels ? undefined : [...actorLabels, ...selfLabels], - [kSelfLabels]: selfLabels, - }, - } - }, {} as ActorInfoMap) - } - async getPostInfos( postUris: string[], viewer: string | null, @@ -312,102 +178,23 @@ export class FeedService { ) } - async getPostViews( - postUris: string[], - requester: string | null, - precomputed?: { - actors?: ActorInfoMap - posts?: PostInfoMap - embeds?: PostEmbedViews - blocks?: PostBlocksMap - labels?: Labels - }, - ): Promise { - const uris = dedupeStrs(postUris) - const dids = dedupeStrs(postUris.map((uri) => new AtUri(uri).hostname)) - - const [actors, posts, labels] = await Promise.all([ - precomputed?.actors ?? - this.getActorInfos(dids, requester, { skipLabels: true }), - precomputed?.posts ?? this.getPostInfos(uris, requester), - precomputed?.labels ?? - this.services.label.getLabelsForSubjects([...uris, ...dids]), - ]) - const blocks = precomputed?.blocks ?? (await this.blocksForPosts(posts)) - const embeds = - precomputed?.embeds ?? - (await this.embedsForPosts(posts, blocks, requester)) - - return uris.reduce((acc, cur) => { - const view = this.views.formatPostView(cur, actors, posts, embeds, labels) - if (view) { - acc[cur] = view - } - return acc - }, {} as PostViews) - } - - async filterAndGetFeedItems( - uris: string[], - requester: string, - ): Promise> { + async getFeedItems(uris: string[]): Promise> { if (uris.length < 1) return {} - const { ref } = this.db.db.dynamic const feedItems = await this.selectFeedItemQb() .where('feed_item.uri', 'in', uris) - .where((qb) => - // Hide posts and reposts of or by muted actors - this.services.graph.whereNotMuted(qb, requester, [ - ref('post.creator'), - ref('originatorDid'), - ]), - ) - .whereNotExists( - this.services.graph.blockQb(requester, [ - ref('post.creator'), - ref('originatorDid'), - ]), - ) .execute() return feedItems.reduce((acc, item) => { return Object.assign(acc, { [item.uri]: item }) }, {} as Record) } - // @TODO enforce limit elsewhere - async cleanFeedSkeleton( - skeleton: SkeletonFeedPost[], - limit: number, - requester: string, - ): Promise { - skeleton = skeleton.slice(0, limit) - const feedItemUris = skeleton.map(getSkeleFeedItemUri) - const feedItems = await this.filterAndGetFeedItems(feedItemUris, requester) - - const cleaned: FeedRow[] = [] - for (const skeleItem of skeleton) { - const feedItem = feedItems[getSkeleFeedItemUri(skeleItem)] - if (feedItem && feedItem.postUri === skeleItem.post) { - cleaned.push(feedItem) - } - } - return cleaned - } - - async hydrateFeed( - items: FeedRow[], - viewer: string | null, - // @TODO (deprecated) remove this once all clients support the blocked/not-found union on post views - usePostViewUnion?: boolean, - ): Promise { + feedItemRefs(items: FeedRow[]) { const actorDids = new Set() const postUris = new Set() for (const item of items) { - actorDids.add(item.postAuthorDid) postUris.add(item.postUri) - if (item.postAuthorDid !== item.originatorDid) { - actorDids.add(item.originatorDid) - } + actorDids.add(item.postAuthorDid) + actorDids.add(item.originatorDid) if (item.replyParent) { postUris.add(item.replyParent) actorDids.add(new AtUri(item.replyParent).hostname) @@ -417,29 +204,51 @@ export class FeedService { actorDids.add(new AtUri(item.replyRoot).hostname) } } - const [actors, posts, labels] = await Promise.all([ - this.getActorInfos(Array.from(actorDids), viewer, { - skipLabels: true, - }), - this.getPostInfos(Array.from(postUris), viewer), - this.services.label.getLabelsForSubjects([...postUris, ...actorDids]), - ]) - const blocks = await this.blocksForPosts(posts) - const embeds = await this.embedsForPosts(posts, blocks, viewer) + return { dids: actorDids, uris: postUris } + } - return this.views.formatFeed( - items, - actors, + async feedHydration( + refs: { + dids: Set + uris: Set + viewer: string | null + }, + depth = 0, + ): Promise { + const { viewer, dids, uris } = refs + const [posts, labels, bam] = await Promise.all([ + this.getPostInfos(Array.from(uris), viewer), + this.services.label.getLabelsForSubjects([...uris, ...dids]), + this.services.graph.getBlockAndMuteState( + viewer ? [...dids].map((did) => [viewer, did]) : [], + ), + ]) + // profileState for labels and bam handled above, profileHydration() shouldn't fetch additional + const [profileState, blocks] = await Promise.all([ + this.services.actor.views.profileHydration( + Array.from(dids), + { viewer }, + { bam, labels }, + ), + this.blocksForPosts(posts, bam), + ]) + const embeds = await this.embedsForPosts(posts, blocks, viewer, depth) + return { posts, - embeds, - labels, blocks, - usePostViewUnion, - ) + embeds, + labels, // includes info for profiles + bam, // includes info for profiles + profiles: profileState.profiles, + lists: profileState.lists, + } } // applies blocks for visibility to third-parties (i.e. based on post content) - async blocksForPosts(posts: PostInfoMap): Promise { + async blocksForPosts( + posts: PostInfoMap, + bam?: BlockAndMuteState, + ): Promise { const relationships: RelationshipPair[] = [] const byPost: Record = {} const didFromUri = (uri) => new AtUri(uri).host @@ -466,15 +275,17 @@ export class FeedService { } } // compute block state from all actor relationships among posts - const blockSet = await this.getBlockSet(relationships) - if (blockSet.empty()) return {} + const blockState = await this.services.graph.getBlockState( + relationships, + bam, + ) const result: PostBlocksMap = {} Object.entries(byPost).forEach(([uri, block]) => { - if (block.embed && blockSet.has(block.embed)) { + if (block.embed && blockState.block(block.embed)) { result[uri] ??= {} result[uri].embed = true } - if (block.reply && blockSet.has(block.reply)) { + if (block.reply && blockState.block(block.reply)) { result[uri] ??= {} result[uri].reply = true } @@ -482,33 +293,11 @@ export class FeedService { return result } - private async getBlockSet(relationships: RelationshipPair[]) { - const { ref } = this.db.db.dynamic - const blockSet = new RelationshipSet() - if (!relationships.length) return blockSet - const relationshipSet = new RelationshipSet() - relationships.forEach((pair) => relationshipSet.add(pair)) - // compute actual block set from all actor relationships - const blockRows = await this.db.db - .selectFrom('actor_block') - .select(['creator', 'subjectDid']) // index-only columns - .where( - sql`(${ref('creator')}, ${ref('subjectDid')})`, - 'in', - valuesList( - relationshipSet.listAllPairs().map(([a, b]) => sql`${a}, ${b}`), - ), - ) - .execute() - blockRows.forEach((r) => blockSet.add([r.creator, r.subjectDid])) - return blockSet - } - async embedsForPosts( postInfos: PostInfoMap, blocks: PostBlocksMap, viewer: string | null, - depth = 0, + depth: number, ) { const postMap = postRecordsFromInfos(postInfos) const posts = Object.values(postMap) @@ -559,39 +348,37 @@ export class FeedService { ): Promise { const nestedUris = nestedRecordUris(posts) if (nestedUris.length < 1) return {} - const nestedPostUris: string[] = [] - const nestedFeedGenUris: string[] = [] - const nestedListUris: string[] = [] - const nestedDidsSet = new Set() + const nestedDids = new Set() + const nestedPostUris = new Set() + const nestedFeedGenUris = new Set() + const nestedListUris = new Set() for (const uri of nestedUris) { const parsed = new AtUri(uri) - nestedDidsSet.add(parsed.hostname) + nestedDids.add(parsed.hostname) if (parsed.collection === ids.AppBskyFeedPost) { - nestedPostUris.push(uri) + nestedPostUris.add(uri) } else if (parsed.collection === ids.AppBskyFeedGenerator) { - nestedFeedGenUris.push(uri) + nestedFeedGenUris.add(uri) } else if (parsed.collection === ids.AppBskyGraphList) { - nestedListUris.push(uri) + nestedListUris.add(uri) } } - const nestedDids = [...nestedDidsSet] - const [postInfos, actorInfos, labelViews, feedGenInfos, listViews] = - await Promise.all([ - this.getPostInfos(nestedPostUris, viewer), - this.getActorInfos(nestedDids, viewer, { skipLabels: true }), - this.services.label.getLabelsForSubjects([ - ...nestedPostUris, - ...nestedDids, - ]), - this.getFeedGeneratorInfos(nestedFeedGenUris, viewer), - this.services.graph.getListViews(nestedListUris, viewer), - ]) - const deepBlocks = await this.blocksForPosts(postInfos) - const deepEmbedViews = await this.embedsForPosts( - postInfos, - deepBlocks, - viewer, - depth + 1, + const [feedState, feedGenInfos, listViews] = await Promise.all([ + this.feedHydration( + { + dids: nestedDids, + uris: nestedPostUris, + viewer, + }, + depth + 1, + ), + this.getFeedGeneratorInfos([...nestedFeedGenUris], viewer), + this.services.graph.getListViews([...nestedListUris], viewer), + ]) + const actorInfos = this.services.actor.views.profileBasicPresentation( + [...nestedDids], + feedState, + { viewer }, ) const recordEmbedViews: RecordEmbedViewRecordMap = {} for (const uri of nestedUris) { @@ -599,24 +386,20 @@ export class FeedService { if (collection === ids.AppBskyFeedGenerator && feedGenInfos[uri]) { recordEmbedViews[uri] = { $type: 'app.bsky.feed.defs#generatorView', - ...this.views.formatFeedGeneratorView( - feedGenInfos[uri], - actorInfos, - labelViews, - ), + ...this.views.formatFeedGeneratorView(feedGenInfos[uri], actorInfos), } } else if (collection === ids.AppBskyGraphList && listViews[uri]) { recordEmbedViews[uri] = { $type: 'app.bsky.graph.defs#listView', ...this.services.graph.formatListView(listViews[uri], actorInfos), } - } else if (collection === ids.AppBskyFeedPost && postInfos[uri]) { + } else if (collection === ids.AppBskyFeedPost && feedState.posts[uri]) { const formatted = this.views.formatPostView( uri, actorInfos, - postInfos, - deepEmbedViews, - labelViews, + feedState.posts, + feedState.embeds, + feedState.labels, ) recordEmbedViews[uri] = this.views.getRecordEmbedView( uri, @@ -664,35 +447,6 @@ const nestedRecordUris = (posts: PostRecord[]): string[] => { type PostRelationships = { reply?: RelationshipPair; embed?: RelationshipPair } -type RelationshipPair = [didA: string, didB: string] - -class RelationshipSet { - index = new Map>() - add([didA, didB]: RelationshipPair) { - const didAIdx = this.index.get(didA) ?? new Set() - const didBIdx = this.index.get(didB) ?? new Set() - if (!this.index.has(didA)) this.index.set(didA, didAIdx) - if (!this.index.has(didB)) this.index.set(didB, didBIdx) - didAIdx.add(didB) - didBIdx.add(didA) - } - has([didA, didB]: RelationshipPair) { - return !!this.index.get(didA)?.has(didB) - } - listAllPairs() { - const pairs: RelationshipPair[] = [] - for (const [didA, didBIdx] of this.index.entries()) { - for (const didB of didBIdx) { - pairs.push([didA, didB]) - } - } - return pairs - } - empty() { - return this.index.size === 0 - } -} - function applyEmbedBlock( uri: string, blocks: PostBlocksMap, @@ -716,9 +470,3 @@ function applyEmbedBlock( } return view } - -function getSkeleFeedItemUri(item: SkeletonFeedPost) { - return typeof item.reason?.repost === 'string' - ? item.reason.repost - : item.post -} diff --git a/packages/bsky/src/services/feed/types.ts b/packages/bsky/src/services/feed/types.ts index f1b5500c62e..894ee0a564f 100644 --- a/packages/bsky/src/services/feed/types.ts +++ b/packages/bsky/src/services/feed/types.ts @@ -14,9 +14,11 @@ import { NotFoundPost, PostView, } from '../../lexicon/types/app/bsky/feed/defs' -import { Label } from '../../lexicon/types/com/atproto/label/defs' import { FeedGenerator } from '../../db/tables/feed-generator' import { ListView } from '../../lexicon/types/app/bsky/graph/defs' +import { ProfileHydrationState } from '../actor' +import { Labels } from '../label' +import { BlockAndMuteState } from '../graph' export type PostEmbedViews = { [uri: string]: PostEmbedView @@ -28,8 +30,6 @@ export type PostEmbedView = | RecordEmbedView | RecordWithMediaEmbedView -export type PostViews = { [uri: string]: PostView } - export type PostInfo = { uri: string cid: string @@ -50,26 +50,6 @@ export type PostBlocksMap = { [uri: string]: { reply?: boolean; embed?: boolean } } -export const kSelfLabels = Symbol('selfLabels') - -export type ActorInfo = { - did: string - handle: string - displayName?: string - avatar?: string - viewer?: { - muted?: boolean - blockedBy?: boolean - blocking?: string - following?: string - followedBy?: string - } - labels?: Label[] - // allows threading self-labels through if they are going to be applied later, i.e. when using skipLabels option. - [kSelfLabels]?: Label[] -} -export type ActorInfoMap = { [did: string]: ActorInfo } - export type FeedGenInfo = Selectable & { likeCount: number viewer?: { @@ -103,3 +83,11 @@ export type RecordEmbedViewRecord = | ListView export type RecordEmbedViewRecordMap = { [uri: string]: RecordEmbedViewRecord } + +export type FeedHydrationState = ProfileHydrationState & { + posts: PostInfoMap + embeds: PostEmbedViews + labels: Labels + blocks: PostBlocksMap + bam: BlockAndMuteState +} diff --git a/packages/bsky/src/services/feed/views.ts b/packages/bsky/src/services/feed/views.ts index dc19ddf637e..439e68f3d1f 100644 --- a/packages/bsky/src/services/feed/views.ts +++ b/packages/bsky/src/services/feed/views.ts @@ -21,7 +21,6 @@ import { ViewRecord, } from '../../lexicon/types/app/bsky/embed/record' import { - ActorInfoMap, PostEmbedViews, FeedGenInfo, FeedRow, @@ -29,31 +28,33 @@ import { PostInfoMap, RecordEmbedViewRecord, PostBlocksMap, - kSelfLabels, + FeedHydrationState, } from './types' import { Labels, getSelfLabels } from '../label' import { ImageUriBuilder } from '../../image/uri' +import { LabelCache } from '../../label-cache' +import { ActorInfoMap, ActorService } from '../actor' export class FeedViews { - constructor(public db: Database, public imgUriBuilder: ImageUriBuilder) {} + constructor( + public db: Database, + public imgUriBuilder: ImageUriBuilder, + public labelCache: LabelCache, + ) {} - static creator(imgUriBuilder: ImageUriBuilder) { - return (db: Database) => new FeedViews(db, imgUriBuilder) + static creator(imgUriBuilder: ImageUriBuilder, labelCache: LabelCache) { + return (db: Database) => new FeedViews(db, imgUriBuilder, labelCache) + } + + services = { + actor: ActorService.creator(this.imgUriBuilder, this.labelCache)(this.db), } formatFeedGeneratorView( info: FeedGenInfo, profiles: ActorInfoMap, - labels?: Labels, ): GeneratorView { const profile = profiles[info.creator] - if (profile && !profile.labels) { - // If the creator labels are not hydrated yet, attempt to pull them - // from labels: e.g. compatible with embedsForPosts() batching label hydration. - const profileLabels = labels?.[info.creator] ?? [] - const profileSelfLabels = profile[kSelfLabels] ?? [] - profile.labels = [...profileLabels, ...profileSelfLabels] - } return { uri: info.uri, cid: info.cid, @@ -83,13 +84,18 @@ export class FeedViews { formatFeed( items: FeedRow[], - actors: ActorInfoMap, - posts: PostInfoMap, - embeds: PostEmbedViews, - labels: Labels, - blocks: PostBlocksMap, - usePostViewUnion?: boolean, + state: FeedHydrationState, + opts?: { + viewer?: string | null + usePostViewUnion?: boolean + }, ): FeedViewPost[] { + const { posts, profiles, blocks, embeds, labels } = state + const actors = this.services.actor.views.profileBasicPresentation( + Object.keys(profiles), + state, + opts, + ) const feed: FeedViewPost[] = [] for (const item of items) { const post = this.formatPostView( @@ -110,14 +116,9 @@ export class FeedViews { if (!originator) { continue } else { - const originatorLabels = labels[item.originatorDid] ?? [] - const originatorSelfLabels = originator[kSelfLabels] ?? [] feedPost['reason'] = { $type: 'app.bsky.feed.defs#reasonRepost', - by: { - ...originator, - labels: [...originatorLabels, ...originatorSelfLabels], - }, + by: originator, indexedAt: item.sortAt, } } @@ -130,7 +131,7 @@ export class FeedViews { embeds, labels, blocks, - usePostViewUnion, + opts, ) const replyRoot = this.formatMaybePostView( item.replyRoot, @@ -139,7 +140,7 @@ export class FeedViews { embeds, labels, blocks, - usePostViewUnion, + opts, ) if (replyRoot && replyParent) { feedPost['reply'] = { @@ -163,11 +164,6 @@ export class FeedViews { const post = posts[uri] const author = actors[post?.creator] if (!post || !author) return undefined - // If the author labels are not hydrated yet, attempt to pull them - // from labels: e.g. compatible with hydrateFeed() batching label hydration. - const authorLabels = labels[author.did] ?? [] - const authorSelfLabels = author[kSelfLabels] ?? [] - author.labels ??= [...authorLabels, ...authorSelfLabels] const postLabels = labels[uri] ?? [] const postSelfLabels = getSelfLabels({ uri: post.uri, @@ -201,11 +197,13 @@ export class FeedViews { embeds: PostEmbedViews, labels: Labels, blocks: PostBlocksMap, - usePostViewUnion?: boolean, + opts?: { + usePostViewUnion?: boolean + }, ): MaybePostView | undefined { const post = this.formatPostView(uri, actors, posts, embeds, labels) if (!post) { - if (!usePostViewUnion) return + if (!opts?.usePostViewUnion) return return this.notFoundPost(uri) } if ( @@ -213,7 +211,7 @@ export class FeedViews { post.author.viewer?.blocking || blocks[uri]?.reply ) { - if (!usePostViewUnion) return + if (!opts?.usePostViewUnion) return return this.blockedPost(post) } return { diff --git a/packages/bsky/src/services/graph/index.ts b/packages/bsky/src/services/graph/index.ts index 4c05dc7cecd..53592ac4021 100644 --- a/packages/bsky/src/services/graph/index.ts +++ b/packages/bsky/src/services/graph/index.ts @@ -1,10 +1,11 @@ +import { sql } from 'kysely' import { Database } from '../../db' import { ImageUriBuilder } from '../../image/uri' -import { ProfileView } from '../../lexicon/types/app/bsky/actor/defs' -import { List } from '../../db/tables/list' -import { Selectable, WhereInterface, sql } from 'kysely' -import { NotEmptyArray } from '@atproto/common' -import { DbRef, noMatch } from '../../db/util' +import { valuesList } from '../../db/util' +import { ListInfo } from './types' +import { ActorInfoMap } from '../actor' + +export * from './types' export class GraphService { constructor(public db: Database, public imgUriBuilder: ImageUriBuilder) {} @@ -69,27 +70,6 @@ export class GraphService { .execute() } - whereNotMuted>( - qb: W, - requester: string, - refs: NotEmptyArray, - ) { - const subjectRefs = sql.join(refs) - const actorMute = this.db.db - .selectFrom('mute') - .where('mutedByDid', '=', requester) - .where('subjectDid', 'in', sql`(${subjectRefs})`) - .select('subjectDid as muted') - const listMute = this.db.db - .selectFrom('list_item') - .innerJoin('list_mute', 'list_mute.listUri', 'list_item.listUri') - .where('list_mute.mutedByDid', '=', requester) - .whereRef('list_item.subjectDid', 'in', sql`(${subjectRefs})`) - .select('list_item.subjectDid as muted') - // Splitting the mute from list-mute checks seems to be more flexible for the query-planner and often quicker - return qb.whereNotExists(actorMute).whereNotExists(listMute) - } - getListsQb(viewer: string | null) { const { ref } = this.db.db.dynamic return this.db.db @@ -98,14 +78,20 @@ export class GraphService { .selectAll('list') .selectAll('actor') .select('list.sortAt as sortAt') - .select( + .select([ this.db.db .selectFrom('list_mute') .where('list_mute.mutedByDid', '=', viewer ?? '') .whereRef('list_mute.listUri', '=', ref('list.uri')) .select('list_mute.listUri') .as('viewerMuted'), - ) + this.db.db + .selectFrom('list_block') + .where('list_block.creator', '=', viewer ?? '') + .whereRef('list_block.subjectUri', '=', ref('list.uri')) + .select('list_block.uri') + .as('viewerListBlockUri'), + ]) } getListItemsQb() { @@ -116,84 +102,117 @@ export class GraphService { .select(['list_item.cid as cid', 'list_item.sortAt as sortAt']) } - blockQb(viewer: string | null, refs: NotEmptyArray) { - const subjectRefs = sql.join(refs) - return this.db.db - .selectFrom('actor_block') - .if(!viewer, (q) => q.where(noMatch)) - .where((outer) => - outer - .where((qb) => - qb - .where('actor_block.creator', '=', viewer ?? '') - .whereRef('actor_block.subjectDid', 'in', sql`(${subjectRefs})`), - ) - .orWhere((qb) => - qb - .where('actor_block.subjectDid', '=', viewer ?? '') - .whereRef('actor_block.creator', 'in', sql`(${subjectRefs})`), - ), - ) - .select(['creator', 'subjectDid']) - } - - blockRefQb(first: DbRef, second: DbRef) { - return this.db.db - .selectFrom('actor_block') - .where((outer) => - outer - .where((qb) => - qb - .whereRef('actor_block.creator', '=', first) - .whereRef('actor_block.subjectDid', '=', second), - ) - .orWhere((qb) => - qb - .whereRef('actor_block.subjectDid', '=', first) - .whereRef('actor_block.creator', '=', second), - ), - ) - .select(['creator', 'subjectDid']) + async getBlockAndMuteState( + pairs: RelationshipPair[], + bam?: BlockAndMuteState, + ) { + pairs = bam ? pairs.filter((pair) => !bam.has(pair)) : pairs + const result = bam ?? new BlockAndMuteState() + if (!pairs.length) return result + const { ref } = this.db.db.dynamic + const sourceRef = ref('pair.source') + const targetRef = ref('pair.target') + const values = valuesList(pairs.map((p) => sql`${p[0]}, ${p[1]}`)) + const items = await this.db.db + .selectFrom(values.as(sql`pair (source, target)`)) + .select([ + sql`${sourceRef}`.as('source'), + sql`${targetRef}`.as('target'), + this.db.db + .selectFrom('actor_block') + .whereRef('creator', '=', sourceRef) + .whereRef('subjectDid', '=', targetRef) + .select('uri') + .as('blocking'), + this.db.db + .selectFrom('list_item') + .innerJoin('list_block', 'list_block.subjectUri', 'list_item.listUri') + .whereRef('list_block.creator', '=', sourceRef) + .whereRef('list_item.subjectDid', '=', targetRef) + .select('list_item.listUri') + .limit(1) + .as('blockingViaList'), + this.db.db + .selectFrom('actor_block') + .whereRef('creator', '=', targetRef) + .whereRef('subjectDid', '=', sourceRef) + .select('uri') + .as('blockedBy'), + this.db.db + .selectFrom('list_item') + .innerJoin('list_block', 'list_block.subjectUri', 'list_item.listUri') + .whereRef('list_block.creator', '=', targetRef) + .whereRef('list_item.subjectDid', '=', sourceRef) + .select('list_item.listUri') + .limit(1) + .as('blockedByViaList'), + this.db.db + .selectFrom('mute') + .whereRef('mutedByDid', '=', sourceRef) + .whereRef('subjectDid', '=', targetRef) + .select(sql`${true}`.as('val')) + .as('muting'), + this.db.db + .selectFrom('list_item') + .innerJoin('list_mute', 'list_mute.listUri', 'list_item.listUri') + .whereRef('list_mute.mutedByDid', '=', sourceRef) + .whereRef('list_item.subjectDid', '=', targetRef) + .select('list_item.listUri') + .limit(1) + .as('mutingViaList'), + ]) + .selectAll() + .execute() + items.forEach((item) => result.add(item)) + return result } - async getBlocks( - requester: string, - subjectHandleOrDid: string, - ): Promise<{ blocking: boolean; blockedBy: boolean }> { - let subjectDid - if (subjectHandleOrDid.startsWith('did:')) { - subjectDid = subjectHandleOrDid - } else { - const res = await this.db.db - .selectFrom('actor') - .where('handle', '=', subjectHandleOrDid) - .select('did') - .executeTakeFirst() - if (!res) { - return { blocking: false, blockedBy: false } - } - subjectDid = res.did - } - - const accnts = [requester, subjectDid] - const blockRes = await this.db.db - .selectFrom('actor_block') - .where('creator', 'in', accnts) - .where('subjectDid', 'in', accnts) + async getBlockState(pairs: RelationshipPair[], bam?: BlockAndMuteState) { + pairs = bam ? pairs.filter((pair) => !bam.has(pair)) : pairs + const result = bam ?? new BlockAndMuteState() + if (!pairs.length) return result + const { ref } = this.db.db.dynamic + const sourceRef = ref('pair.source') + const targetRef = ref('pair.target') + const values = valuesList(pairs.map((p) => sql`${p[0]}, ${p[1]}`)) + const items = await this.db.db + .selectFrom(values.as(sql`pair (source, target)`)) + .select([ + sql`${sourceRef}`.as('source'), + sql`${targetRef}`.as('target'), + this.db.db + .selectFrom('actor_block') + .whereRef('creator', '=', sourceRef) + .whereRef('subjectDid', '=', targetRef) + .select('uri') + .as('blocking'), + this.db.db + .selectFrom('list_item') + .innerJoin('list_block', 'list_block.subjectUri', 'list_item.listUri') + .whereRef('list_block.creator', '=', sourceRef) + .whereRef('list_item.subjectDid', '=', targetRef) + .select('list_item.listUri') + .limit(1) + .as('blockingViaList'), + this.db.db + .selectFrom('actor_block') + .whereRef('creator', '=', targetRef) + .whereRef('subjectDid', '=', sourceRef) + .select('uri') + .as('blockedBy'), + this.db.db + .selectFrom('list_item') + .innerJoin('list_block', 'list_block.subjectUri', 'list_item.listUri') + .whereRef('list_block.creator', '=', targetRef) + .whereRef('list_item.subjectDid', '=', sourceRef) + .select('list_item.listUri') + .limit(1) + .as('blockedByViaList'), + ]) .selectAll() .execute() - - const blocking = blockRes.some( - (row) => row.creator === requester && row.subjectDid === subjectDid, - ) - const blockedBy = blockRes.some( - (row) => row.creator === subjectDid && row.subjectDid === requester, - ) - - return { - blocking, - blockedBy, - } + items.forEach((item) => result.add(item)) + return result } async getListViews(listUris: string[], requester: string | null) { @@ -210,28 +229,14 @@ export class GraphService { ) } - formatListView(list: ListInfo, profiles: Record) { + formatListView(list: ListInfo, profiles: ActorInfoMap) { return { - uri: list.uri, - cid: list.cid, + ...this.formatListViewBasic(list), creator: profiles[list.creator], - name: list.name, - purpose: list.purpose, description: list.description ?? undefined, descriptionFacets: list.descriptionFacets ? JSON.parse(list.descriptionFacets) : undefined, - avatar: list.avatarCid - ? this.imgUriBuilder.getPresetUri( - 'avatar', - list.creator, - list.avatarCid, - ) - : undefined, - indexedAt: list.sortAt, - viewer: { - muted: !!list.viewerMuted, - }, } } @@ -248,14 +253,92 @@ export class GraphService { list.avatarCid, ) : undefined, - indexedAt: list.indexedAt, + indexedAt: list.sortAt, viewer: { muted: !!list.viewerMuted, + blocked: list.viewerListBlockUri ?? undefined, }, } } } -type ListInfo = Selectable & { - viewerMuted: string | null +export type RelationshipPair = [didA: string, didB: string] + +export class BlockAndMuteState { + hasIdx = new Map>() // did -> did + blockIdx = new Map>() // did -> did -> block uri + muteIdx = new Map>() // did -> did + muteListIdx = new Map>() // did -> did -> list uri + constructor(items: BlockAndMuteInfo[] = []) { + items.forEach((item) => this.add(item)) + } + add(item: BlockAndMuteInfo) { + const blocking = item.blocking || item.blockingViaList // block or list uri + if (blocking) { + const map = this.blockIdx.get(item.source) ?? new Map() + map.set(item.target, blocking) + if (!this.blockIdx.has(item.source)) { + this.blockIdx.set(item.source, map) + } + } + const blockedBy = item.blockedBy || item.blockedByViaList // block or list uri + if (blockedBy) { + const map = this.blockIdx.get(item.target) ?? new Map() + map.set(item.source, blockedBy) + if (!this.blockIdx.has(item.target)) { + this.blockIdx.set(item.target, map) + } + } + if (item.muting) { + const set = this.muteIdx.get(item.source) ?? new Set() + set.add(item.target) + if (!this.muteIdx.has(item.source)) { + this.muteIdx.set(item.source, set) + } + } + if (item.mutingViaList) { + const map = this.muteListIdx.get(item.source) ?? new Map() + map.set(item.target, item.mutingViaList) + if (!this.muteListIdx.has(item.source)) { + this.muteListIdx.set(item.source, map) + } + } + const set = this.hasIdx.get(item.source) ?? new Set() + set.add(item.target) + if (!this.hasIdx.has(item.source)) { + this.hasIdx.set(item.source, set) + } + } + block(pair: RelationshipPair): boolean { + return !!this.blocking(pair) || !!this.blockedBy(pair) + } + // block or list uri + blocking(pair: RelationshipPair): string | null { + return this.blockIdx.get(pair[0])?.get(pair[1]) ?? null + } + // block or list uri + blockedBy(pair: RelationshipPair): string | null { + return this.blocking([pair[1], pair[0]]) + } + mute(pair: RelationshipPair): boolean { + return !!this.muteIdx.get(pair[0])?.has(pair[1]) || !!this.muteList(pair) + } + // list uri + muteList(pair: RelationshipPair): string | null { + return this.muteListIdx.get(pair[0])?.get(pair[1]) ?? null + } + has(pair: RelationshipPair) { + return !!this.hasIdx.get(pair[0])?.has(pair[1]) + } +} + +type BlockAndMuteInfo = { + source: string + target: string + blocking?: string | null + blockingViaList?: string | null + blockedBy?: string | null + blockedByViaList?: string | null + muting?: true | null + mutingViaList?: string | null } diff --git a/packages/bsky/src/services/graph/types.ts b/packages/bsky/src/services/graph/types.ts new file mode 100644 index 00000000000..f5ee0c13026 --- /dev/null +++ b/packages/bsky/src/services/graph/types.ts @@ -0,0 +1,9 @@ +import { Selectable } from 'kysely' +import { List } from '../../db/tables/list' + +export type ListInfo = Selectable & { + viewerMuted: string | null + viewerListBlockUri: string | null +} + +export type ListInfoMap = Record diff --git a/packages/bsky/src/services/indexing/index.ts b/packages/bsky/src/services/indexing/index.ts index 33f0a2577c2..05e591c92c4 100644 --- a/packages/bsky/src/services/indexing/index.ts +++ b/packages/bsky/src/services/indexing/index.ts @@ -20,6 +20,7 @@ import * as Follow from './plugins/follow' import * as Profile from './plugins/profile' import * as List from './plugins/list' import * as ListItem from './plugins/list-item' +import * as ListBlock from './plugins/list-block' import * as Block from './plugins/block' import * as FeedGenerator from './plugins/feed-generator' import RecordProcessor from './processor' @@ -39,6 +40,7 @@ export class IndexingService { profile: Profile.PluginType list: List.PluginType listItem: ListItem.PluginType + listBlock: ListBlock.PluginType block: Block.PluginType feedGenerator: FeedGenerator.PluginType } @@ -58,6 +60,7 @@ export class IndexingService { profile: Profile.makePlugin(this.db, backgroundQueue, notifServer), list: List.makePlugin(this.db, backgroundQueue, notifServer), listItem: ListItem.makePlugin(this.db, backgroundQueue, notifServer), + listBlock: ListBlock.makePlugin(this.db, backgroundQueue, notifServer), block: Block.makePlugin(this.db, backgroundQueue, notifServer), feedGenerator: FeedGenerator.makePlugin( this.db, @@ -334,6 +337,10 @@ export class IndexingService { .deleteFrom('actor_block') .where('creator', '=', did) .execute() + await this.db.db + .deleteFrom('list_block') + .where('creator', '=', did) + .execute() // posts const postByUser = (qb) => qb diff --git a/packages/bsky/src/services/indexing/plugins/list-block.ts b/packages/bsky/src/services/indexing/plugins/list-block.ts new file mode 100644 index 00000000000..4285ca8d4bc --- /dev/null +++ b/packages/bsky/src/services/indexing/plugins/list-block.ts @@ -0,0 +1,90 @@ +import { Selectable } from 'kysely' +import { AtUri } from '@atproto/syntax' +import { CID } from 'multiformats/cid' +import * as ListBlock from '../../../lexicon/types/app/bsky/graph/listblock' +import * as lex from '../../../lexicon/lexicons' +import { PrimaryDatabase } from '../../../db' +import { DatabaseSchema, DatabaseSchemaType } from '../../../db/database-schema' +import RecordProcessor from '../processor' +import { BackgroundQueue } from '../../../background' +import { NotificationServer } from '../../../notifications' +import { toSimplifiedISOSafe } from '../util' + +const lexId = lex.ids.AppBskyGraphListblock +type IndexedListBlock = Selectable + +const insertFn = async ( + db: DatabaseSchema, + uri: AtUri, + cid: CID, + obj: ListBlock.Record, + timestamp: string, +): Promise => { + const inserted = await db + .insertInto('list_block') + .values({ + uri: uri.toString(), + cid: cid.toString(), + creator: uri.host, + subjectUri: obj.subject, + createdAt: toSimplifiedISOSafe(obj.createdAt), + indexedAt: timestamp, + }) + .onConflict((oc) => oc.doNothing()) + .returningAll() + .executeTakeFirst() + return inserted || null +} + +const findDuplicate = async ( + db: DatabaseSchema, + uri: AtUri, + obj: ListBlock.Record, +): Promise => { + const found = await db + .selectFrom('list_block') + .where('creator', '=', uri.host) + .where('subjectUri', '=', obj.subject) + .selectAll() + .executeTakeFirst() + return found ? new AtUri(found.uri) : null +} + +const notifsForInsert = () => { + return [] +} + +const deleteFn = async ( + db: DatabaseSchema, + uri: AtUri, +): Promise => { + const deleted = await db + .deleteFrom('list_block') + .where('uri', '=', uri.toString()) + .returningAll() + .executeTakeFirst() + return deleted || null +} + +const notifsForDelete = () => { + return { notifs: [], toDelete: [] } +} + +export type PluginType = RecordProcessor + +export const makePlugin = ( + db: PrimaryDatabase, + backgroundQueue: BackgroundQueue, + notifServer?: NotificationServer, +): PluginType => { + return new RecordProcessor(db, backgroundQueue, notifServer, { + lexId, + insertFn, + findDuplicate, + deleteFn, + notifsForInsert, + notifsForDelete, + }) +} + +export default makePlugin diff --git a/packages/bsky/src/services/label/index.ts b/packages/bsky/src/services/label/index.ts index bfd037e8fb8..855038ab14c 100644 --- a/packages/bsky/src/services/label/index.ts +++ b/packages/bsky/src/services/label/index.ts @@ -100,9 +100,11 @@ export class LabelService { includeNeg?: boolean skipCache?: boolean }, + labels: Labels = {}, ): Promise { - if (subjects.length < 1) return {} + if (subjects.length < 1) return labels const expandedSubjects = subjects.flatMap((subject) => { + if (labels[subject]) return [] // skip over labels we already have fetched if (subject.startsWith('did:')) { return [ subject, @@ -111,8 +113,8 @@ export class LabelService { } return subject }) - const labels = await this.getLabelsForUris(expandedSubjects, opts) - return Object.keys(labels).reduce((acc, cur) => { + const labelsByUri = await this.getLabelsForUris(expandedSubjects, opts) + return Object.keys(labelsByUri).reduce((acc, cur) => { const uri = cur.startsWith('at://') ? new AtUri(cur) : null if ( uri && @@ -122,12 +124,12 @@ export class LabelService { // combine labels for profile + did const did = uri.hostname acc[did] ??= [] - acc[did].push(...labels[cur]) + acc[did].push(...labelsByUri[cur]) } acc[cur] ??= [] - acc[cur].push(...labels[cur]) + acc[cur].push(...labelsByUri[cur]) return acc - }, {} as Labels) + }, labels) } async getLabels( diff --git a/packages/bsky/tests/views/__snapshots__/block-lists.test.ts.snap b/packages/bsky/tests/views/__snapshots__/block-lists.test.ts.snap new file mode 100644 index 00000000000..fae6e7f4fa9 --- /dev/null +++ b/packages/bsky/tests/views/__snapshots__/block-lists.test.ts.snap @@ -0,0 +1,557 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`pds views with blocking from block lists blocks record embeds 1`] = ` +Object { + "thread": Object { + "$type": "app.bsky.feed.defs#threadViewPost", + "post": Object { + "author": Object { + "avatar": "https://bsky.public.url/img/avatar/plain/user(1)/cids(1)@jpeg", + "did": "user(0)", + "displayName": "ali", + "handle": "alice.test", + "labels": Array [ + Object { + "cid": "cids(2)", + "cts": "1970-01-01T00:00:00.000Z", + "neg": false, + "src": "user(0)", + "uri": "record(2)", + "val": "self-label-a", + }, + Object { + "cid": "cids(2)", + "cts": "1970-01-01T00:00:00.000Z", + "neg": false, + "src": "user(0)", + "uri": "record(2)", + "val": "self-label-b", + }, + ], + "viewer": Object { + "blockedBy": false, + "followedBy": "record(1)", + "muted": false, + }, + }, + "cid": "cids(0)", + "embed": Object { + "$type": "app.bsky.embed.record#view", + "record": Object { + "$type": "app.bsky.embed.record#viewRecord", + "author": Object { + "did": "user(2)", + "handle": "dan.test", + "labels": Array [], + "viewer": Object { + "blockedBy": false, + "muted": false, + }, + }, + "cid": "cids(3)", + "embeds": Array [ + Object { + "$type": "app.bsky.embed.record#view", + "record": Object { + "$type": "app.bsky.embed.record#viewBlocked", + "author": Object { + "did": "user(3)", + "viewer": Object { + "blockedBy": false, + "blocking": "record(5)", + }, + }, + "blocked": true, + "uri": "record(4)", + }, + }, + ], + "indexedAt": "1970-01-01T00:00:00.000Z", + "labels": Array [], + "uri": "record(3)", + "value": Object { + "$type": "app.bsky.feed.post", + "createdAt": "1970-01-01T00:00:00.000Z", + "embed": Object { + "$type": "app.bsky.embed.record", + "record": Object { + "cid": "cids(4)", + "uri": "record(4)", + }, + }, + "facets": Array [ + Object { + "features": Array [ + Object { + "$type": "app.bsky.richtext.facet#mention", + "did": "user(0)", + }, + ], + "index": Object { + "byteEnd": 18, + "byteStart": 0, + }, + }, + ], + "text": "@alice.bluesky.xyz is the best", + }, + }, + }, + "indexedAt": "1970-01-01T00:00:00.000Z", + "labels": Array [ + Object { + "cid": "cids(0)", + "cts": "1970-01-01T00:00:00.000Z", + "neg": false, + "src": "did:example:labeler", + "uri": "record(0)", + "val": "test-label", + }, + ], + "likeCount": 2, + "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(3)", + "uri": "record(3)", + }, + }, + "text": "yoohoo label_me", + }, + "replyCount": 0, + "repostCount": 0, + "uri": "record(0)", + "viewer": Object {}, + }, + }, +} +`; + +exports[`pds views with blocking from block lists blocks thread parent 1`] = ` +Object { + "thread": Object { + "$type": "app.bsky.feed.defs#threadViewPost", + "parent": Object { + "$type": "app.bsky.feed.defs#blockedPost", + "author": Object { + "did": "user(2)", + "viewer": Object { + "blockedBy": true, + }, + }, + "blocked": true, + "uri": "record(4)", + }, + "post": Object { + "author": Object { + "avatar": "https://bsky.public.url/img/avatar/plain/user(1)/cids(1)@jpeg", + "did": "user(0)", + "displayName": "ali", + "handle": "alice.test", + "labels": Array [ + Object { + "cid": "cids(2)", + "cts": "1970-01-01T00:00:00.000Z", + "neg": false, + "src": "user(0)", + "uri": "record(3)", + "val": "self-label-a", + }, + Object { + "cid": "cids(2)", + "cts": "1970-01-01T00:00:00.000Z", + "neg": false, + "src": "user(0)", + "uri": "record(3)", + "val": "self-label-b", + }, + ], + "viewer": Object { + "blockedBy": false, + "followedBy": "record(2)", + "following": "record(1)", + "muted": false, + }, + }, + "cid": "cids(0)", + "indexedAt": "1970-01-01T00:00:00.000Z", + "labels": Array [], + "likeCount": 0, + "record": Object { + "$type": "app.bsky.feed.post", + "createdAt": "1970-01-01T00:00:00.000Z", + "reply": Object { + "parent": Object { + "cid": "cids(3)", + "uri": "record(4)", + }, + "root": Object { + "cid": "cids(3)", + "uri": "record(4)", + }, + }, + "text": "alice replies to dan", + }, + "replyCount": 0, + "repostCount": 0, + "uri": "record(0)", + "viewer": Object {}, + }, + "replies": Array [], + }, +} +`; + +exports[`pds views with blocking from block lists blocks thread reply 1`] = ` +Object { + "thread": Object { + "$type": "app.bsky.feed.defs#threadViewPost", + "post": Object { + "author": Object { + "avatar": "https://bsky.public.url/img/avatar/plain/user(1)/cids(1)@jpeg", + "did": "user(0)", + "displayName": "ali", + "handle": "alice.test", + "labels": Array [ + Object { + "cid": "cids(2)", + "cts": "1970-01-01T00:00:00.000Z", + "neg": false, + "src": "user(0)", + "uri": "record(2)", + "val": "self-label-a", + }, + Object { + "cid": "cids(2)", + "cts": "1970-01-01T00:00:00.000Z", + "neg": false, + "src": "user(0)", + "uri": "record(2)", + "val": "self-label-b", + }, + ], + "viewer": Object { + "blockedBy": false, + "followedBy": "record(1)", + "muted": false, + }, + }, + "cid": "cids(0)", + "indexedAt": "1970-01-01T00:00:00.000Z", + "labels": Array [], + "likeCount": 3, + "record": Object { + "$type": "app.bsky.feed.post", + "createdAt": "1970-01-01T00:00:00.000000Z", + "text": "again", + }, + "replyCount": 2, + "repostCount": 1, + "uri": "record(0)", + "viewer": Object { + "like": "record(4)", + "repost": "record(3)", + }, + }, + "replies": Array [ + Object { + "$type": "app.bsky.feed.defs#blockedPost", + "author": Object { + "did": "user(2)", + "viewer": Object { + "blockedBy": false, + "blocking": "record(6)", + }, + }, + "blocked": true, + "uri": "record(5)", + }, + Object { + "$type": "app.bsky.feed.defs#blockedPost", + "author": Object { + "did": "user(3)", + "viewer": Object { + "blockedBy": false, + "blocking": "record(6)", + }, + }, + "blocked": true, + "uri": "record(7)", + }, + ], + }, +} +`; + +exports[`pds views with blocking from block lists returns a users own list blocks 1`] = ` +Object { + "cursor": "0000000000000::bafycid", + "lists": Array [ + Object { + "cid": "cids(0)", + "creator": Object { + "avatar": "https://bsky.public.url/img/avatar/plain/user(1)/cids(1)@jpeg", + "description": "its me!", + "did": "user(0)", + "displayName": "ali", + "handle": "alice.test", + "indexedAt": "1970-01-01T00:00:00.000Z", + "labels": Array [ + Object { + "cid": "cids(2)", + "cts": "1970-01-01T00:00:00.000Z", + "neg": false, + "src": "user(0)", + "uri": "record(3)", + "val": "self-label-a", + }, + Object { + "cid": "cids(2)", + "cts": "1970-01-01T00:00:00.000Z", + "neg": false, + "src": "user(0)", + "uri": "record(3)", + "val": "self-label-b", + }, + ], + "viewer": Object { + "blockedBy": false, + "followedBy": "record(2)", + "muted": false, + }, + }, + "description": "blah blah", + "indexedAt": "1970-01-01T00:00:00.000Z", + "name": "new list", + "purpose": "app.bsky.graph.defs#blocklist", + "uri": "record(0)", + "viewer": Object { + "blocked": "record(1)", + "muted": false, + }, + }, + Object { + "avatar": "https://bsky.public.url/img/avatar/plain/user(1)/cids(1)@jpeg", + "cid": "cids(3)", + "creator": Object { + "avatar": "https://bsky.public.url/img/avatar/plain/user(1)/cids(1)@jpeg", + "description": "its me!", + "did": "user(0)", + "displayName": "ali", + "handle": "alice.test", + "indexedAt": "1970-01-01T00:00:00.000Z", + "labels": Array [ + Object { + "cid": "cids(2)", + "cts": "1970-01-01T00:00:00.000Z", + "neg": false, + "src": "user(0)", + "uri": "record(3)", + "val": "self-label-a", + }, + Object { + "cid": "cids(2)", + "cts": "1970-01-01T00:00:00.000Z", + "neg": false, + "src": "user(0)", + "uri": "record(3)", + "val": "self-label-b", + }, + ], + "viewer": Object { + "blockedBy": false, + "followedBy": "record(2)", + "muted": false, + }, + }, + "description": "big list of blocks", + "indexedAt": "1970-01-01T00:00:00.000Z", + "name": "alice blocks", + "purpose": "app.bsky.graph.defs#blocklist", + "uri": "record(4)", + "viewer": Object { + "blocked": "record(5)", + "muted": false, + }, + }, + ], +} +`; + +exports[`pds views with blocking from block lists returns lists associated with a user 1`] = ` +Object { + "cursor": "0000000000000::bafycid", + "lists": Array [ + Object { + "cid": "cids(0)", + "creator": Object { + "avatar": "https://bsky.public.url/img/avatar/plain/user(1)/cids(1)@jpeg", + "description": "its me!", + "did": "user(0)", + "displayName": "ali", + "handle": "alice.test", + "indexedAt": "1970-01-01T00:00:00.000Z", + "labels": Array [ + Object { + "cid": "cids(2)", + "cts": "1970-01-01T00:00:00.000Z", + "neg": false, + "src": "user(0)", + "uri": "record(2)", + "val": "self-label-a", + }, + Object { + "cid": "cids(2)", + "cts": "1970-01-01T00:00:00.000Z", + "neg": false, + "src": "user(0)", + "uri": "record(2)", + "val": "self-label-b", + }, + ], + "viewer": Object { + "blockedBy": false, + "followedBy": "record(1)", + "muted": false, + }, + }, + "description": "blah blah", + "indexedAt": "1970-01-01T00:00:00.000Z", + "name": "new list", + "purpose": "app.bsky.graph.defs#blocklist", + "uri": "record(0)", + "viewer": Object { + "muted": false, + }, + }, + Object { + "avatar": "https://bsky.public.url/img/avatar/plain/user(1)/cids(1)@jpeg", + "cid": "cids(3)", + "creator": Object { + "avatar": "https://bsky.public.url/img/avatar/plain/user(1)/cids(1)@jpeg", + "description": "its me!", + "did": "user(0)", + "displayName": "ali", + "handle": "alice.test", + "indexedAt": "1970-01-01T00:00:00.000Z", + "labels": Array [ + Object { + "cid": "cids(2)", + "cts": "1970-01-01T00:00:00.000Z", + "neg": false, + "src": "user(0)", + "uri": "record(2)", + "val": "self-label-a", + }, + Object { + "cid": "cids(2)", + "cts": "1970-01-01T00:00:00.000Z", + "neg": false, + "src": "user(0)", + "uri": "record(2)", + "val": "self-label-b", + }, + ], + "viewer": Object { + "blockedBy": false, + "followedBy": "record(1)", + "muted": false, + }, + }, + "description": "big list of blocks", + "indexedAt": "1970-01-01T00:00:00.000Z", + "name": "alice blocks", + "purpose": "app.bsky.graph.defs#blocklist", + "uri": "record(3)", + "viewer": Object { + "blocked": "record(4)", + "muted": false, + }, + }, + ], +} +`; + +exports[`pds views with blocking from block lists returns the contents of a list 1`] = ` +Object { + "cursor": "0000000000000::bafycid", + "items": Array [ + Object { + "subject": Object { + "did": "user(2)", + "handle": "carol.test", + "labels": Array [], + "viewer": Object { + "blockedBy": false, + "blocking": "record(0)", + "muted": false, + }, + }, + }, + Object { + "subject": Object { + "avatar": "https://bsky.public.url/img/avatar/plain/user(4)/cids(1)@jpeg", + "description": "hi im bob label_me", + "did": "user(3)", + "displayName": "bobby", + "handle": "bob.test", + "indexedAt": "1970-01-01T00:00:00.000Z", + "labels": Array [], + "viewer": Object { + "blockedBy": false, + "blocking": "record(0)", + "following": "record(4)", + "muted": false, + }, + }, + }, + ], + "list": Object { + "avatar": "https://bsky.public.url/img/avatar/plain/user(0)/cids(1)@jpeg", + "cid": "cids(0)", + "creator": Object { + "avatar": "https://bsky.public.url/img/avatar/plain/user(0)/cids(1)@jpeg", + "description": "its me!", + "did": "user(1)", + "displayName": "ali", + "handle": "alice.test", + "indexedAt": "1970-01-01T00:00:00.000Z", + "labels": Array [ + Object { + "cid": "cids(2)", + "cts": "1970-01-01T00:00:00.000Z", + "neg": false, + "src": "user(1)", + "uri": "record(3)", + "val": "self-label-a", + }, + Object { + "cid": "cids(2)", + "cts": "1970-01-01T00:00:00.000Z", + "neg": false, + "src": "user(1)", + "uri": "record(3)", + "val": "self-label-b", + }, + ], + "viewer": Object { + "blockedBy": false, + "followedBy": "record(2)", + "muted": false, + }, + }, + "description": "big list of blocks", + "indexedAt": "1970-01-01T00:00:00.000Z", + "name": "alice blocks", + "purpose": "app.bsky.graph.defs#blocklist", + "uri": "record(0)", + "viewer": Object { + "blocked": "record(1)", + "muted": false, + }, + }, +} +`; diff --git a/packages/bsky/tests/views/__snapshots__/follows.test.ts.snap b/packages/bsky/tests/views/__snapshots__/follows.test.ts.snap index 2061726d8e6..c85b0549de7 100644 --- a/packages/bsky/tests/views/__snapshots__/follows.test.ts.snap +++ b/packages/bsky/tests/views/__snapshots__/follows.test.ts.snap @@ -5,9 +5,9 @@ Object { "cursor": "0000000000000::bafycid", "followers": Array [ Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(3)/cids(0)@jpeg", + "avatar": "https://bsky.public.url/img/avatar/plain/user(1)/cids(0)@jpeg", "description": "descript-eve", - "did": "user(2)", + "did": "user(0)", "displayName": "display-eve", "handle": "eve.test", "indexedAt": "1970-01-01T00:00:00.000Z", @@ -20,9 +20,9 @@ Object { }, }, Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(5)/cids(0)@jpeg", + "avatar": "https://bsky.public.url/img/avatar/plain/user(3)/cids(0)@jpeg", "description": "descript-dan", - "did": "user(4)", + "did": "user(2)", "displayName": "display-dan", "handle": "dan.test", "indexedAt": "1970-01-01T00:00:00.000Z", @@ -35,9 +35,9 @@ Object { }, }, Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(7)/cids(0)@jpeg", + "avatar": "https://bsky.public.url/img/avatar/plain/user(5)/cids(0)@jpeg", "description": "descript-bob", - "did": "user(6)", + "did": "user(4)", "displayName": "display-bob", "handle": "bob.test", "indexedAt": "1970-01-01T00:00:00.000Z", @@ -50,9 +50,9 @@ Object { }, }, Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(9)/cids(0)@jpeg", + "avatar": "https://bsky.public.url/img/avatar/plain/user(7)/cids(0)@jpeg", "description": "descript-carol", - "did": "user(8)", + "did": "user(6)", "displayName": "display-carol", "handle": "carol.test", "indexedAt": "1970-01-01T00:00:00.000Z", @@ -66,9 +66,9 @@ Object { }, ], "subject": Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(1)/cids(0)@jpeg", + "avatar": "https://bsky.public.url/img/avatar/plain/user(9)/cids(0)@jpeg", "description": "descript-alice", - "did": "user(0)", + "did": "user(8)", "displayName": "display-alice", "handle": "alice.test", "indexedAt": "1970-01-01T00:00:00.000Z", @@ -86,24 +86,24 @@ Object { "cursor": "0000000000000::bafycid", "followers": Array [ Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(3)/cids(0)@jpeg", + "avatar": "https://bsky.public.url/img/avatar/plain/user(1)/cids(0)@jpeg", "description": "descript-dan", - "did": "user(2)", + "did": "user(0)", "displayName": "display-dan", "handle": "dan.test", "indexedAt": "1970-01-01T00:00:00.000Z", "labels": Array [], "viewer": Object { "blockedBy": false, - "followedBy": "record(3)", - "following": "record(2)", + "followedBy": "record(1)", + "following": "record(0)", "muted": false, }, }, Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(5)/cids(0)@jpeg", + "avatar": "https://bsky.public.url/img/avatar/plain/user(3)/cids(0)@jpeg", "description": "descript-alice", - "did": "user(4)", + "did": "user(2)", "displayName": "display-alice", "handle": "alice.test", "indexedAt": "1970-01-01T00:00:00.000Z", @@ -115,17 +115,17 @@ Object { }, ], "subject": Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(1)/cids(0)@jpeg", + "avatar": "https://bsky.public.url/img/avatar/plain/user(5)/cids(0)@jpeg", "description": "descript-bob", - "did": "user(0)", + "did": "user(4)", "displayName": "display-bob", "handle": "bob.test", "indexedAt": "1970-01-01T00:00:00.000Z", "labels": Array [], "viewer": Object { "blockedBy": false, - "followedBy": "record(1)", - "following": "record(0)", + "followedBy": "record(3)", + "following": "record(2)", "muted": false, }, }, @@ -137,39 +137,39 @@ Object { "cursor": "0000000000000::bafycid", "followers": Array [ Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(3)/cids(0)@jpeg", + "avatar": "https://bsky.public.url/img/avatar/plain/user(1)/cids(0)@jpeg", "description": "descript-eve", - "did": "user(2)", + "did": "user(0)", "displayName": "display-eve", "handle": "eve.test", "indexedAt": "1970-01-01T00:00:00.000Z", "labels": Array [], "viewer": Object { "blockedBy": false, - "followedBy": "record(3)", - "following": "record(2)", + "followedBy": "record(1)", + "following": "record(0)", "muted": false, }, }, Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(5)/cids(0)@jpeg", + "avatar": "https://bsky.public.url/img/avatar/plain/user(3)/cids(0)@jpeg", "description": "descript-bob", - "did": "user(4)", + "did": "user(2)", "displayName": "display-bob", "handle": "bob.test", "indexedAt": "1970-01-01T00:00:00.000Z", "labels": Array [], "viewer": Object { "blockedBy": false, - "followedBy": "record(5)", - "following": "record(4)", + "followedBy": "record(3)", + "following": "record(2)", "muted": false, }, }, Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(7)/cids(0)@jpeg", + "avatar": "https://bsky.public.url/img/avatar/plain/user(5)/cids(0)@jpeg", "description": "descript-alice", - "did": "user(6)", + "did": "user(4)", "displayName": "display-alice", "handle": "alice.test", "indexedAt": "1970-01-01T00:00:00.000Z", @@ -181,17 +181,17 @@ Object { }, ], "subject": Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(1)/cids(0)@jpeg", + "avatar": "https://bsky.public.url/img/avatar/plain/user(7)/cids(0)@jpeg", "description": "descript-carol", - "did": "user(0)", + "did": "user(6)", "displayName": "display-carol", "handle": "carol.test", "indexedAt": "1970-01-01T00:00:00.000Z", "labels": Array [], "viewer": Object { "blockedBy": false, - "followedBy": "record(1)", - "following": "record(0)", + "followedBy": "record(5)", + "following": "record(4)", "muted": false, }, }, @@ -203,9 +203,9 @@ Object { "cursor": "0000000000000::bafycid", "followers": Array [ Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(3)/cids(0)@jpeg", + "avatar": "https://bsky.public.url/img/avatar/plain/user(1)/cids(0)@jpeg", "description": "descript-alice", - "did": "user(2)", + "did": "user(0)", "displayName": "display-alice", "handle": "alice.test", "indexedAt": "1970-01-01T00:00:00.000Z", @@ -217,9 +217,9 @@ Object { }, ], "subject": Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(1)/cids(0)@jpeg", + "avatar": "https://bsky.public.url/img/avatar/plain/user(3)/cids(0)@jpeg", "description": "descript-dan", - "did": "user(0)", + "did": "user(2)", "displayName": "display-dan", "handle": "dan.test", "indexedAt": "1970-01-01T00:00:00.000Z", @@ -239,24 +239,24 @@ Object { "cursor": "0000000000000::bafycid", "followers": Array [ Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(3)/cids(0)@jpeg", + "avatar": "https://bsky.public.url/img/avatar/plain/user(1)/cids(0)@jpeg", "description": "descript-dan", - "did": "user(2)", + "did": "user(0)", "displayName": "display-dan", "handle": "dan.test", "indexedAt": "1970-01-01T00:00:00.000Z", "labels": Array [], "viewer": Object { "blockedBy": false, - "followedBy": "record(3)", - "following": "record(2)", + "followedBy": "record(1)", + "following": "record(0)", "muted": false, }, }, Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(5)/cids(0)@jpeg", + "avatar": "https://bsky.public.url/img/avatar/plain/user(3)/cids(0)@jpeg", "description": "descript-alice", - "did": "user(4)", + "did": "user(2)", "displayName": "display-alice", "handle": "alice.test", "indexedAt": "1970-01-01T00:00:00.000Z", @@ -268,17 +268,17 @@ Object { }, ], "subject": Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(1)/cids(0)@jpeg", + "avatar": "https://bsky.public.url/img/avatar/plain/user(5)/cids(0)@jpeg", "description": "descript-eve", - "did": "user(0)", + "did": "user(4)", "displayName": "display-eve", "handle": "eve.test", "indexedAt": "1970-01-01T00:00:00.000Z", "labels": Array [], "viewer": Object { "blockedBy": false, - "followedBy": "record(1)", - "following": "record(0)", + "followedBy": "record(3)", + "following": "record(2)", "muted": false, }, }, @@ -290,9 +290,9 @@ Object { "cursor": "0000000000000::bafycid", "follows": Array [ Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(3)/cids(0)@jpeg", + "avatar": "https://bsky.public.url/img/avatar/plain/user(1)/cids(0)@jpeg", "description": "descript-eve", - "did": "user(2)", + "did": "user(0)", "displayName": "display-eve", "handle": "eve.test", "indexedAt": "1970-01-01T00:00:00.000Z", @@ -305,9 +305,9 @@ Object { }, }, Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(5)/cids(0)@jpeg", + "avatar": "https://bsky.public.url/img/avatar/plain/user(3)/cids(0)@jpeg", "description": "descript-dan", - "did": "user(4)", + "did": "user(2)", "displayName": "display-dan", "handle": "dan.test", "indexedAt": "1970-01-01T00:00:00.000Z", @@ -320,9 +320,9 @@ Object { }, }, Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(7)/cids(0)@jpeg", + "avatar": "https://bsky.public.url/img/avatar/plain/user(5)/cids(0)@jpeg", "description": "descript-carol", - "did": "user(6)", + "did": "user(4)", "displayName": "display-carol", "handle": "carol.test", "indexedAt": "1970-01-01T00:00:00.000Z", @@ -335,9 +335,9 @@ Object { }, }, Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(9)/cids(0)@jpeg", + "avatar": "https://bsky.public.url/img/avatar/plain/user(7)/cids(0)@jpeg", "description": "descript-bob", - "did": "user(8)", + "did": "user(6)", "displayName": "display-bob", "handle": "bob.test", "indexedAt": "1970-01-01T00:00:00.000Z", @@ -351,9 +351,9 @@ Object { }, ], "subject": Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(1)/cids(0)@jpeg", + "avatar": "https://bsky.public.url/img/avatar/plain/user(9)/cids(0)@jpeg", "description": "descript-alice", - "did": "user(0)", + "did": "user(8)", "displayName": "display-alice", "handle": "alice.test", "indexedAt": "1970-01-01T00:00:00.000Z", @@ -371,24 +371,24 @@ Object { "cursor": "0000000000000::bafycid", "follows": Array [ Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(3)/cids(0)@jpeg", + "avatar": "https://bsky.public.url/img/avatar/plain/user(1)/cids(0)@jpeg", "description": "descript-carol", - "did": "user(2)", + "did": "user(0)", "displayName": "display-carol", "handle": "carol.test", "indexedAt": "1970-01-01T00:00:00.000Z", "labels": Array [], "viewer": Object { "blockedBy": false, - "followedBy": "record(3)", - "following": "record(2)", + "followedBy": "record(1)", + "following": "record(0)", "muted": false, }, }, Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(5)/cids(0)@jpeg", + "avatar": "https://bsky.public.url/img/avatar/plain/user(3)/cids(0)@jpeg", "description": "descript-alice", - "did": "user(4)", + "did": "user(2)", "displayName": "display-alice", "handle": "alice.test", "indexedAt": "1970-01-01T00:00:00.000Z", @@ -400,17 +400,17 @@ Object { }, ], "subject": Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(1)/cids(0)@jpeg", + "avatar": "https://bsky.public.url/img/avatar/plain/user(5)/cids(0)@jpeg", "description": "descript-bob", - "did": "user(0)", + "did": "user(4)", "displayName": "display-bob", "handle": "bob.test", "indexedAt": "1970-01-01T00:00:00.000Z", "labels": Array [], "viewer": Object { "blockedBy": false, - "followedBy": "record(1)", - "following": "record(0)", + "followedBy": "record(3)", + "following": "record(2)", "muted": false, }, }, @@ -422,9 +422,9 @@ Object { "cursor": "0000000000000::bafycid", "follows": Array [ Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(3)/cids(0)@jpeg", + "avatar": "https://bsky.public.url/img/avatar/plain/user(1)/cids(0)@jpeg", "description": "descript-alice", - "did": "user(2)", + "did": "user(0)", "displayName": "display-alice", "handle": "alice.test", "indexedAt": "1970-01-01T00:00:00.000Z", @@ -436,9 +436,9 @@ Object { }, ], "subject": Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(1)/cids(0)@jpeg", + "avatar": "https://bsky.public.url/img/avatar/plain/user(3)/cids(0)@jpeg", "description": "descript-carol", - "did": "user(0)", + "did": "user(2)", "displayName": "display-carol", "handle": "carol.test", "indexedAt": "1970-01-01T00:00:00.000Z", @@ -458,39 +458,39 @@ Object { "cursor": "0000000000000::bafycid", "follows": Array [ Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(3)/cids(0)@jpeg", + "avatar": "https://bsky.public.url/img/avatar/plain/user(1)/cids(0)@jpeg", "description": "descript-eve", - "did": "user(2)", + "did": "user(0)", "displayName": "display-eve", "handle": "eve.test", "indexedAt": "1970-01-01T00:00:00.000Z", "labels": Array [], "viewer": Object { "blockedBy": false, - "followedBy": "record(3)", - "following": "record(2)", + "followedBy": "record(1)", + "following": "record(0)", "muted": false, }, }, Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(5)/cids(0)@jpeg", + "avatar": "https://bsky.public.url/img/avatar/plain/user(3)/cids(0)@jpeg", "description": "descript-bob", - "did": "user(4)", + "did": "user(2)", "displayName": "display-bob", "handle": "bob.test", "indexedAt": "1970-01-01T00:00:00.000Z", "labels": Array [], "viewer": Object { "blockedBy": false, - "followedBy": "record(5)", - "following": "record(4)", + "followedBy": "record(3)", + "following": "record(2)", "muted": false, }, }, Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(7)/cids(0)@jpeg", + "avatar": "https://bsky.public.url/img/avatar/plain/user(5)/cids(0)@jpeg", "description": "descript-alice", - "did": "user(6)", + "did": "user(4)", "displayName": "display-alice", "handle": "alice.test", "indexedAt": "1970-01-01T00:00:00.000Z", @@ -502,17 +502,17 @@ Object { }, ], "subject": Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(1)/cids(0)@jpeg", + "avatar": "https://bsky.public.url/img/avatar/plain/user(7)/cids(0)@jpeg", "description": "descript-dan", - "did": "user(0)", + "did": "user(6)", "displayName": "display-dan", "handle": "dan.test", "indexedAt": "1970-01-01T00:00:00.000Z", "labels": Array [], "viewer": Object { "blockedBy": false, - "followedBy": "record(1)", - "following": "record(0)", + "followedBy": "record(5)", + "following": "record(4)", "muted": false, }, }, @@ -524,24 +524,24 @@ Object { "cursor": "0000000000000::bafycid", "follows": Array [ Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(3)/cids(0)@jpeg", + "avatar": "https://bsky.public.url/img/avatar/plain/user(1)/cids(0)@jpeg", "description": "descript-carol", - "did": "user(2)", + "did": "user(0)", "displayName": "display-carol", "handle": "carol.test", "indexedAt": "1970-01-01T00:00:00.000Z", "labels": Array [], "viewer": Object { "blockedBy": false, - "followedBy": "record(3)", - "following": "record(2)", + "followedBy": "record(1)", + "following": "record(0)", "muted": false, }, }, Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(5)/cids(0)@jpeg", + "avatar": "https://bsky.public.url/img/avatar/plain/user(3)/cids(0)@jpeg", "description": "descript-alice", - "did": "user(4)", + "did": "user(2)", "displayName": "display-alice", "handle": "alice.test", "indexedAt": "1970-01-01T00:00:00.000Z", @@ -553,17 +553,17 @@ Object { }, ], "subject": Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(1)/cids(0)@jpeg", + "avatar": "https://bsky.public.url/img/avatar/plain/user(5)/cids(0)@jpeg", "description": "descript-eve", - "did": "user(0)", + "did": "user(4)", "displayName": "display-eve", "handle": "eve.test", "indexedAt": "1970-01-01T00:00:00.000Z", "labels": Array [], "viewer": Object { "blockedBy": false, - "followedBy": "record(1)", - "following": "record(0)", + "followedBy": "record(3)", + "following": "record(2)", "muted": false, }, }, diff --git a/packages/bsky/tests/views/__snapshots__/likes.test.ts.snap b/packages/bsky/tests/views/__snapshots__/likes.test.ts.snap index 53f37486d1c..426467a3fa7 100644 --- a/packages/bsky/tests/views/__snapshots__/likes.test.ts.snap +++ b/packages/bsky/tests/views/__snapshots__/likes.test.ts.snap @@ -24,7 +24,7 @@ Object { "labels": Array [], "viewer": Object { "blockedBy": false, - "following": "record(1)", + "following": "record(0)", "muted": false, }, }, @@ -38,8 +38,8 @@ Object { "labels": Array [], "viewer": Object { "blockedBy": false, - "followedBy": "record(3)", - "following": "record(2)", + "followedBy": "record(2)", + "following": "record(1)", "muted": false, }, }, @@ -57,8 +57,8 @@ Object { "labels": Array [], "viewer": Object { "blockedBy": false, - "followedBy": "record(5)", - "following": "record(4)", + "followedBy": "record(4)", + "following": "record(3)", "muted": false, }, }, @@ -66,7 +66,7 @@ Object { "indexedAt": "1970-01-01T00:00:00.000Z", }, ], - "uri": "record(0)", + "uri": "record(5)", } `; @@ -81,8 +81,8 @@ Object { "labels": Array [], "viewer": Object { "blockedBy": false, - "followedBy": "record(2)", - "following": "record(1)", + "followedBy": "record(1)", + "following": "record(0)", "muted": false, }, }, @@ -90,6 +90,6 @@ Object { "indexedAt": "1970-01-01T00:00:00.000Z", }, ], - "uri": "record(0)", + "uri": "record(2)", } `; diff --git a/packages/bsky/tests/views/__snapshots__/mute-lists.test.ts.snap b/packages/bsky/tests/views/__snapshots__/mute-lists.test.ts.snap index 748180f4d57..0a081f91292 100644 --- a/packages/bsky/tests/views/__snapshots__/mute-lists.test.ts.snap +++ b/packages/bsky/tests/views/__snapshots__/mute-lists.test.ts.snap @@ -41,6 +41,24 @@ Object { "did": "user(0)", "displayName": "ali", "handle": "alice.test", + "labels": Array [ + Object { + "cid": "cids(2)", + "cts": "1970-01-01T00:00:00.000Z", + "neg": false, + "src": "user(0)", + "uri": "record(1)", + "val": "self-label-a", + }, + Object { + "cid": "cids(2)", + "cts": "1970-01-01T00:00:00.000Z", + "neg": false, + "src": "user(0)", + "uri": "record(1)", + "val": "self-label-b", + }, + ], "viewer": Object { "blockedBy": false, "muted": false, @@ -469,15 +487,15 @@ Object { "items": Array [ Object { "subject": Object { - "did": "user(0)", + "did": "user(2)", "handle": "carol.test", "labels": Array [], "viewer": Object { "blockedBy": false, - "followedBy": "record(1)", + "followedBy": "record(3)", "muted": true, "mutedByList": Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(1)/cids(1)@jpeg", + "avatar": "https://bsky.public.url/img/avatar/plain/user(0)/cids(1)@jpeg", "cid": "cids(0)", "indexedAt": "1970-01-01T00:00:00.000Z", "name": "alice mutes", @@ -492,19 +510,19 @@ Object { }, Object { "subject": Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(3)/cids(1)@jpeg", + "avatar": "https://bsky.public.url/img/avatar/plain/user(4)/cids(1)@jpeg", "description": "hi im bob label_me", - "did": "user(2)", + "did": "user(3)", "displayName": "bobby", "handle": "bob.test", "indexedAt": "1970-01-01T00:00:00.000Z", "labels": Array [], "viewer": Object { "blockedBy": false, - "following": "record(2)", + "following": "record(4)", "muted": true, "mutedByList": Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(1)/cids(1)@jpeg", + "avatar": "https://bsky.public.url/img/avatar/plain/user(0)/cids(1)@jpeg", "cid": "cids(0)", "indexedAt": "1970-01-01T00:00:00.000Z", "name": "alice mutes", @@ -519,12 +537,12 @@ Object { }, ], "list": Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(1)/cids(1)@jpeg", + "avatar": "https://bsky.public.url/img/avatar/plain/user(0)/cids(1)@jpeg", "cid": "cids(0)", "creator": Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(1)/cids(1)@jpeg", + "avatar": "https://bsky.public.url/img/avatar/plain/user(0)/cids(1)@jpeg", "description": "its me!", - "did": "user(4)", + "did": "user(1)", "displayName": "ali", "handle": "alice.test", "indexedAt": "1970-01-01T00:00:00.000Z", @@ -533,22 +551,22 @@ Object { "cid": "cids(2)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(4)", - "uri": "record(4)", + "src": "user(1)", + "uri": "record(2)", "val": "self-label-a", }, Object { "cid": "cids(2)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(4)", - "uri": "record(4)", + "src": "user(1)", + "uri": "record(2)", "val": "self-label-b", }, ], "viewer": Object { "blockedBy": false, - "followedBy": "record(3)", + "followedBy": "record(1)", "muted": false, }, }, diff --git a/packages/bsky/tests/views/block-lists.test.ts b/packages/bsky/tests/views/block-lists.test.ts new file mode 100644 index 00000000000..0a8a223e046 --- /dev/null +++ b/packages/bsky/tests/views/block-lists.test.ts @@ -0,0 +1,407 @@ +import AtpAgent from '@atproto/api' +import { TestNetwork } from '@atproto/dev-env' +import { forSnapshot } from '../_util' +import { SeedClient } from '../seeds/client' +import basicSeed from '../seeds/basic' +import { RecordRef } from '@atproto/bsky/tests/seeds/client' +import { BlockedActorError } from '@atproto/api/src/client/types/app/bsky/feed/getAuthorFeed' +import { BlockedByActorError } from '@atproto/api/src/client/types/app/bsky/feed/getAuthorFeed' + +describe('pds views with blocking from block lists', () => { + let network: TestNetwork + let agent: AtpAgent + let pdsAgent: AtpAgent + let sc: SeedClient + let aliceReplyToDan: { ref: RecordRef } + + let alice: string + let bob: string + let carol: string + let dan: string + + beforeAll(async () => { + network = await TestNetwork.create({ + dbPostgresSchema: 'views_block_lists', + }) + agent = network.bsky.getClient() + pdsAgent = network.pds.getClient() + sc = new SeedClient(pdsAgent) + await basicSeed(sc) + alice = sc.dids.alice + bob = sc.dids.bob + carol = sc.dids.carol + dan = sc.dids.dan + // add follows to ensure blocks work even w follows + await sc.follow(carol, dan) + await sc.follow(dan, carol) + aliceReplyToDan = await sc.reply( + alice, + sc.posts[dan][0].ref, + sc.posts[dan][0].ref, + 'alice replies to dan', + ) + await network.processAll() + }) + + afterAll(async () => { + await network.close() + }) + + let listUri: string + + it('creates a list with some items', async () => { + const avatar = await sc.uploadFile( + alice, + 'tests/image/fixtures/key-portrait-small.jpg', + 'image/jpeg', + ) + // alice creates block list with bob & carol that dan uses + const list = await pdsAgent.api.app.bsky.graph.list.create( + { repo: alice }, + { + name: 'alice blocks', + purpose: 'app.bsky.graph.defs#blocklist', + description: 'big list of blocks', + avatar: avatar.image, + createdAt: new Date().toISOString(), + }, + sc.getHeaders(alice), + ) + listUri = list.uri + await pdsAgent.api.app.bsky.graph.listitem.create( + { repo: alice }, + { + subject: sc.dids.bob, + list: list.uri, + createdAt: new Date().toISOString(), + }, + sc.getHeaders(alice), + ) + await pdsAgent.api.app.bsky.graph.listitem.create( + { repo: alice }, + { + subject: sc.dids.carol, + list: list.uri, + createdAt: new Date().toISOString(), + }, + sc.getHeaders(alice), + ) + await network.processAll() + }) + + it('uses a list for blocks', async () => { + await pdsAgent.api.app.bsky.graph.listblock.create( + { repo: dan }, + { + subject: listUri, + createdAt: new Date().toISOString(), + }, + sc.getHeaders(dan), + ) + await network.processAll() + }) + + it('blocks thread post', async () => { + const { carol, dan } = sc.dids + const { data: threadAlice } = await agent.api.app.bsky.feed.getPostThread( + { depth: 1, uri: sc.posts[carol][0].ref.uriStr }, + { headers: await network.serviceHeaders(dan) }, + ) + expect(threadAlice.thread).toEqual( + expect.objectContaining({ + $type: 'app.bsky.feed.defs#blockedPost', + uri: sc.posts[carol][0].ref.uriStr, + blocked: true, + }), + ) + const { data: threadCarol } = await agent.api.app.bsky.feed.getPostThread( + { depth: 1, uri: sc.posts[dan][0].ref.uriStr }, + { headers: await network.serviceHeaders(carol) }, + ) + expect(threadCarol.thread).toEqual( + expect.objectContaining({ + $type: 'app.bsky.feed.defs#blockedPost', + uri: sc.posts[dan][0].ref.uriStr, + blocked: true, + }), + ) + }) + + it('blocks thread reply', async () => { + // Contains reply by carol + const { data: thread } = await agent.api.app.bsky.feed.getPostThread( + { depth: 1, uri: sc.posts[alice][1].ref.uriStr }, + { headers: await network.serviceHeaders(dan) }, + ) + expect(forSnapshot(thread)).toMatchSnapshot() + }) + + it('blocks thread parent', async () => { + // Parent is a post by dan + const { data: thread } = await agent.api.app.bsky.feed.getPostThread( + { depth: 1, uri: aliceReplyToDan.ref.uriStr }, + { headers: await network.serviceHeaders(carol) }, + ) + expect(forSnapshot(thread)).toMatchSnapshot() + }) + + it('blocks record embeds', async () => { + // Contains a deep embed of carol's post, blocked by dan + const { data: thread } = await agent.api.app.bsky.feed.getPostThread( + { depth: 0, uri: sc.posts[alice][2].ref.uriStr }, + { headers: await network.serviceHeaders(dan) }, + ) + expect(forSnapshot(thread)).toMatchSnapshot() + }) + + it('errors on getting author feed', async () => { + const attempt1 = agent.api.app.bsky.feed.getAuthorFeed( + { actor: carol }, + { headers: await network.serviceHeaders(dan) }, + ) + await expect(attempt1).rejects.toThrow(BlockedActorError) + + const attempt2 = agent.api.app.bsky.feed.getAuthorFeed( + { actor: dan }, + { headers: await network.serviceHeaders(carol) }, + ) + await expect(attempt2).rejects.toThrow(BlockedByActorError) + }) + + it('strips blocked users out of getTimeline', async () => { + const resCarol = await agent.api.app.bsky.feed.getTimeline( + { limit: 100 }, + { headers: await network.serviceHeaders(carol) }, + ) + expect( + resCarol.data.feed.some((post) => post.post.author.did === dan), + ).toBeFalsy() + + const resDan = await agent.api.app.bsky.feed.getTimeline( + { limit: 100 }, + { headers: await network.serviceHeaders(dan) }, + ) + expect( + resDan.data.feed.some((post) => + [bob, carol].includes(post.post.author.did), + ), + ).toBeFalsy() + }) + + it('returns block status on getProfile', async () => { + const resCarol = await agent.api.app.bsky.actor.getProfile( + { actor: dan }, + { headers: await network.serviceHeaders(carol) }, + ) + expect(resCarol.data.viewer?.blocking).toBeUndefined() + expect(resCarol.data.viewer?.blockedBy).toBe(true) + + const resDan = await agent.api.app.bsky.actor.getProfile( + { actor: carol }, + { headers: await network.serviceHeaders(dan) }, + ) + expect(resDan.data.viewer?.blocking).toBeDefined() + expect(resDan.data.viewer?.blockedBy).toBe(false) + }) + + it('returns block status on getProfiles', async () => { + const resCarol = await agent.api.app.bsky.actor.getProfiles( + { actors: [alice, dan] }, + { headers: await network.serviceHeaders(carol) }, + ) + expect(resCarol.data.profiles[0].viewer?.blocking).toBeUndefined() + expect(resCarol.data.profiles[0].viewer?.blockedBy).toBe(false) + expect(resCarol.data.profiles[1].viewer?.blocking).toBeUndefined() + expect(resCarol.data.profiles[1].viewer?.blockedBy).toBe(true) + + const resDan = await agent.api.app.bsky.actor.getProfiles( + { actors: [alice, carol] }, + { headers: await network.serviceHeaders(dan) }, + ) + expect(resDan.data.profiles[0].viewer?.blocking).toBeUndefined() + expect(resDan.data.profiles[0].viewer?.blockedBy).toBe(false) + expect(resDan.data.profiles[1].viewer?.blocking).toBeDefined() + expect(resDan.data.profiles[1].viewer?.blockedBy).toBe(false) + }) + + it('does not return notifs for blocked accounts', async () => { + const resCarol = await agent.api.app.bsky.notification.listNotifications( + { + limit: 100, + }, + { headers: await network.serviceHeaders(carol) }, + ) + expect( + resCarol.data.notifications.some((notif) => notif.author.did === dan), + ).toBeFalsy() + + const resDan = await agent.api.app.bsky.notification.listNotifications( + { + limit: 100, + }, + { headers: await network.serviceHeaders(carol) }, + ) + expect( + resDan.data.notifications.some((notif) => notif.author.did === carol), + ).toBeFalsy() + }) + + it('does not return blocked accounts in actor search', async () => { + const resCarol = await agent.api.app.bsky.actor.searchActors( + { + term: 'dan.test', + }, + { headers: await network.serviceHeaders(carol) }, + ) + expect(resCarol.data.actors.some((actor) => actor.did === dan)).toBeFalsy() + + const resDan = await agent.api.app.bsky.actor.searchActors( + { + term: 'carol.test', + }, + { headers: await network.serviceHeaders(dan) }, + ) + expect(resDan.data.actors.some((actor) => actor.did === carol)).toBeFalsy() + }) + + it('does not return blocked accounts in actor search typeahead', async () => { + const resCarol = await agent.api.app.bsky.actor.searchActorsTypeahead( + { + term: 'dan.test', + }, + { headers: await network.serviceHeaders(carol) }, + ) + expect(resCarol.data.actors.some((actor) => actor.did === dan)).toBeFalsy() + + const resDan = await agent.api.app.bsky.actor.searchActorsTypeahead( + { + term: 'carol.test', + }, + { headers: await network.serviceHeaders(dan) }, + ) + expect(resDan.data.actors.some((actor) => actor.did === carol)).toBeFalsy() + }) + + it('does not return blocked accounts in get suggestions', async () => { + // unfollow so they _would_ show up in suggestions if not for block + await sc.unfollow(carol, dan) + await sc.unfollow(dan, carol) + await network.processAll() + + const resCarol = await agent.api.app.bsky.actor.getSuggestions( + { + limit: 100, + }, + { headers: await network.serviceHeaders(carol) }, + ) + expect(resCarol.data.actors.some((actor) => actor.did === dan)).toBeFalsy() + + const resDan = await agent.api.app.bsky.actor.getSuggestions( + { + limit: 100, + }, + { headers: await network.serviceHeaders(dan) }, + ) + expect(resDan.data.actors.some((actor) => actor.did === carol)).toBeFalsy() + }) + + it('returns the contents of a list', async () => { + const res = await agent.api.app.bsky.graph.getList( + { list: listUri }, + { headers: await network.serviceHeaders(dan) }, + ) + expect(forSnapshot(res.data)).toMatchSnapshot() + }) + + it('paginates getList', async () => { + const full = await agent.api.app.bsky.graph.getList( + { list: listUri }, + { headers: await network.serviceHeaders(dan) }, + ) + const first = await agent.api.app.bsky.graph.getList( + { list: listUri, limit: 1 }, + { headers: await network.serviceHeaders(dan) }, + ) + const second = await agent.api.app.bsky.graph.getList( + { list: listUri, cursor: first.data.cursor }, + { headers: await network.serviceHeaders(dan) }, + ) + const combined = [...first.data.items, ...second.data.items] + expect(combined).toEqual(full.data.items) + }) + + let otherListUri: string + + it('returns lists associated with a user', async () => { + const listRes = await pdsAgent.api.app.bsky.graph.list.create( + { repo: alice }, + { + name: 'new list', + purpose: 'app.bsky.graph.defs#blocklist', + description: 'blah blah', + createdAt: new Date().toISOString(), + }, + sc.getHeaders(alice), + ) + otherListUri = listRes.uri + await network.processAll() + + const res = await agent.api.app.bsky.graph.getLists( + { actor: alice }, + { headers: await network.serviceHeaders(dan) }, + ) + expect(forSnapshot(res.data)).toMatchSnapshot() + }) + + it('paginates getLists', async () => { + const full = await agent.api.app.bsky.graph.getLists( + { actor: alice }, + { headers: await network.serviceHeaders(dan) }, + ) + const first = await agent.api.app.bsky.graph.getLists( + { actor: alice, limit: 1 }, + { headers: await network.serviceHeaders(dan) }, + ) + const second = await agent.api.app.bsky.graph.getLists( + { actor: alice, cursor: first.data.cursor }, + { headers: await network.serviceHeaders(dan) }, + ) + const combined = [...first.data.lists, ...second.data.lists] + expect(combined).toEqual(full.data.lists) + }) + + it('returns a users own list blocks', async () => { + await pdsAgent.api.app.bsky.graph.listblock.create( + { repo: dan }, + { + subject: otherListUri, + createdAt: new Date().toISOString(), + }, + sc.getHeaders(dan), + ) + await network.processAll() + + const res = await agent.api.app.bsky.graph.getListBlocks( + {}, + { headers: await network.serviceHeaders(dan) }, + ) + expect(forSnapshot(res.data)).toMatchSnapshot() + }) + + it('paginates getListBlocks', async () => { + const full = await agent.api.app.bsky.graph.getListBlocks( + {}, + { headers: await network.serviceHeaders(dan) }, + ) + const first = await agent.api.app.bsky.graph.getListBlocks( + { limit: 1 }, + { headers: await network.serviceHeaders(dan) }, + ) + const second = await agent.api.app.bsky.graph.getListBlocks( + { cursor: first.data.cursor }, + { headers: await network.serviceHeaders(dan) }, + ) + const combined = [...first.data.lists, ...second.data.lists] + expect(combined).toEqual(full.data.lists) + }) +}) diff --git a/packages/pds/src/app-view/api/app/bsky/graph/getListBlocks.ts b/packages/pds/src/app-view/api/app/bsky/graph/getListBlocks.ts new file mode 100644 index 00000000000..98c55e14bc9 --- /dev/null +++ b/packages/pds/src/app-view/api/app/bsky/graph/getListBlocks.ts @@ -0,0 +1,19 @@ +import { Server } from '../../../../../lexicon' +import AppContext from '../../../../../context' + +export default function (server: Server, ctx: AppContext) { + server.app.bsky.graph.getListBlocks({ + auth: ctx.accessVerifier, + handler: async ({ auth, params }) => { + const requester = auth.credentials.did + const res = await ctx.appviewAgent.api.app.bsky.graph.getListBlocks( + params, + await ctx.serviceAuthHeaders(requester), + ) + return { + encoding: 'application/json', + body: res.data, + } + }, + }) +} diff --git a/packages/pds/src/app-view/api/app/bsky/index.ts b/packages/pds/src/app-view/api/app/bsky/index.ts index fc8c6baeace..7f9c458ae70 100644 --- a/packages/pds/src/app-view/api/app/bsky/index.ts +++ b/packages/pds/src/app-view/api/app/bsky/index.ts @@ -19,6 +19,7 @@ import getBlocks from './graph/getBlocks' import getFollowers from './graph/getFollowers' import getFollows from './graph/getFollows' import getList from './graph/getList' +import getListBlocks from './graph/getListBlocks' import getListMutes from './graph/getListMutes' import getLists from './graph/getLists' import getMutes from './graph/getMutes' @@ -55,6 +56,7 @@ export default function (server: Server, ctx: AppContext) { getFollowers(server, ctx) getFollows(server, ctx) getList(server, ctx) + getListBlocks(server, ctx) getListMutes(server, ctx) getLists(server, ctx) getMutes(server, ctx) diff --git a/packages/pds/src/app-view/services/indexing/index.ts b/packages/pds/src/app-view/services/indexing/index.ts index cdb27344251..346c31e83ea 100644 --- a/packages/pds/src/app-view/services/indexing/index.ts +++ b/packages/pds/src/app-view/services/indexing/index.ts @@ -1,7 +1,10 @@ import { CID } from 'multiformats/cid' import { WriteOpAction } from '@atproto/repo' import { AtUri } from '@atproto/syntax' +import { ids } from '../../../lexicon/lexicons' import Database from '../../../db' +import { BackgroundQueue } from '../../../event-stream/background-queue' +import { NoopProcessor } from './processor' import * as Post from './plugins/post' import * as Like from './plugins/like' import * as Repost from './plugins/repost' @@ -11,7 +14,6 @@ import * as List from './plugins/list' import * as ListItem from './plugins/list-item' import * as Profile from './plugins/profile' import * as FeedGenerator from './plugins/feed-generator' -import { BackgroundQueue } from '../../../event-stream/background-queue' export class IndexingService { records: { @@ -22,6 +24,7 @@ export class IndexingService { block: Block.PluginType list: List.PluginType listItem: ListItem.PluginType + listBlock: NoopProcessor profile: Profile.PluginType feedGenerator: FeedGenerator.PluginType } @@ -35,6 +38,11 @@ export class IndexingService { block: Block.makePlugin(this.db, backgroundQueue), list: List.makePlugin(this.db, backgroundQueue), listItem: ListItem.makePlugin(this.db, backgroundQueue), + listBlock: new NoopProcessor( + ids.AppBskyGraphListblock, + this.db, + backgroundQueue, + ), profile: Profile.makePlugin(this.db, backgroundQueue), feedGenerator: FeedGenerator.makePlugin(this.db, backgroundQueue), } diff --git a/packages/pds/src/app-view/services/indexing/processor.ts b/packages/pds/src/app-view/services/indexing/processor.ts index 8c93fccfb3f..6b6712889f2 100644 --- a/packages/pds/src/app-view/services/indexing/processor.ts +++ b/packages/pds/src/app-view/services/indexing/processor.ts @@ -240,3 +240,20 @@ export class RecordProcessor { } export default RecordProcessor + +export class NoopProcessor extends RecordProcessor { + constructor( + lexId: string, + appDb: Database, + backgroundQueue: BackgroundQueue, + ) { + super(appDb, backgroundQueue, { + lexId, + insertFn: async () => null, + deleteFn: async () => null, + findDuplicate: async () => null, + notifsForInsert: () => [], + notifsForDelete: () => ({ notifs: [], toDelete: [] }), + }) + } +} diff --git a/packages/pds/src/lexicon/index.ts b/packages/pds/src/lexicon/index.ts index a99d4d6e51b..df15a497c63 100644 --- a/packages/pds/src/lexicon/index.ts +++ b/packages/pds/src/lexicon/index.ts @@ -93,6 +93,7 @@ import * as AppBskyGraphGetBlocks from './types/app/bsky/graph/getBlocks' import * as AppBskyGraphGetFollowers from './types/app/bsky/graph/getFollowers' import * as AppBskyGraphGetFollows from './types/app/bsky/graph/getFollows' import * as AppBskyGraphGetList from './types/app/bsky/graph/getList' +import * as AppBskyGraphGetListBlocks from './types/app/bsky/graph/getListBlocks' import * as AppBskyGraphGetListMutes from './types/app/bsky/graph/getListMutes' import * as AppBskyGraphGetLists from './types/app/bsky/graph/getLists' import * as AppBskyGraphGetMutes from './types/app/bsky/graph/getMutes' @@ -1228,6 +1229,17 @@ export class GraphNS { return this._server.xrpc.method(nsid, cfg) } + getListBlocks( + cfg: ConfigOf< + AV, + AppBskyGraphGetListBlocks.Handler>, + AppBskyGraphGetListBlocks.HandlerReqCtx> + >, + ) { + const nsid = 'app.bsky.graph.getListBlocks' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } + getListMutes( cfg: ConfigOf< AV, diff --git a/packages/pds/src/lexicon/lexicons.ts b/packages/pds/src/lexicon/lexicons.ts index 193b9c39d37..c49b098002b 100644 --- a/packages/pds/src/lexicon/lexicons.ts +++ b/packages/pds/src/lexicon/lexicons.ts @@ -5728,6 +5728,10 @@ export const schemaDict = { muted: { type: 'boolean', }, + blocked: { + type: 'string', + format: 'at-uri', + }, }, }, }, @@ -5956,6 +5960,49 @@ export const schemaDict = { }, }, }, + AppBskyGraphGetListBlocks: { + lexicon: 1, + id: 'app.bsky.graph.getListBlocks', + defs: { + main: { + type: 'query', + description: "Which lists is the requester's account blocking?", + parameters: { + type: 'params', + properties: { + limit: { + type: 'integer', + minimum: 1, + maximum: 100, + default: 50, + }, + cursor: { + type: 'string', + }, + }, + }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['lists'], + properties: { + cursor: { + type: 'string', + }, + lists: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:app.bsky.graph.defs#listView', + }, + }, + }, + }, + }, + }, + }, + }, AppBskyGraphGetListMutes: { lexicon: 1, id: 'app.bsky.graph.getListMutes', @@ -6141,6 +6188,31 @@ export const schemaDict = { }, }, }, + AppBskyGraphListblock: { + lexicon: 1, + id: 'app.bsky.graph.listblock', + defs: { + main: { + type: 'record', + description: 'A block of an entire list of actors.', + key: 'tid', + record: { + type: 'object', + required: ['subject', 'createdAt'], + properties: { + subject: { + type: 'string', + format: 'at-uri', + }, + createdAt: { + type: 'string', + format: 'datetime', + }, + }, + }, + }, + }, + }, AppBskyGraphListitem: { lexicon: 1, id: 'app.bsky.graph.listitem', @@ -6798,10 +6870,12 @@ export const ids = { AppBskyGraphGetFollowers: 'app.bsky.graph.getFollowers', AppBskyGraphGetFollows: 'app.bsky.graph.getFollows', AppBskyGraphGetList: 'app.bsky.graph.getList', + AppBskyGraphGetListBlocks: 'app.bsky.graph.getListBlocks', AppBskyGraphGetListMutes: 'app.bsky.graph.getListMutes', AppBskyGraphGetLists: 'app.bsky.graph.getLists', AppBskyGraphGetMutes: 'app.bsky.graph.getMutes', AppBskyGraphList: 'app.bsky.graph.list', + AppBskyGraphListblock: 'app.bsky.graph.listblock', AppBskyGraphListitem: 'app.bsky.graph.listitem', AppBskyGraphMuteActor: 'app.bsky.graph.muteActor', AppBskyGraphMuteActorList: 'app.bsky.graph.muteActorList', diff --git a/packages/pds/src/lexicon/types/app/bsky/graph/defs.ts b/packages/pds/src/lexicon/types/app/bsky/graph/defs.ts index e50338d488d..63c05b5faa3 100644 --- a/packages/pds/src/lexicon/types/app/bsky/graph/defs.ts +++ b/packages/pds/src/lexicon/types/app/bsky/graph/defs.ts @@ -81,6 +81,7 @@ export const MODLIST = 'app.bsky.graph.defs#modlist' export interface ListViewerState { muted?: boolean + blocked?: string [k: string]: unknown } diff --git a/packages/pds/src/lexicon/types/app/bsky/graph/getListBlocks.ts b/packages/pds/src/lexicon/types/app/bsky/graph/getListBlocks.ts new file mode 100644 index 00000000000..04cca70b44d --- /dev/null +++ b/packages/pds/src/lexicon/types/app/bsky/graph/getListBlocks.ts @@ -0,0 +1,48 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import express from 'express' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +import { CID } from 'multiformats/cid' +import { HandlerAuth } from '@atproto/xrpc-server' +import * as AppBskyGraphDefs from './defs' + +export interface QueryParams { + limit: number + cursor?: string +} + +export type InputSchema = undefined + +export interface OutputSchema { + cursor?: string + lists: AppBskyGraphDefs.ListView[] + [k: string]: unknown +} + +export type HandlerInput = undefined + +export interface HandlerSuccess { + encoding: 'application/json' + body: OutputSchema + headers?: { [key: string]: string } +} + +export interface HandlerError { + status: number + message?: string +} + +export type HandlerOutput = HandlerError | HandlerSuccess +export type HandlerReqCtx = { + auth: HA + params: QueryParams + input: HandlerInput + req: express.Request + res: express.Response +} +export type Handler = ( + ctx: HandlerReqCtx, +) => Promise | HandlerOutput diff --git a/packages/pds/src/lexicon/types/app/bsky/graph/listblock.ts b/packages/pds/src/lexicon/types/app/bsky/graph/listblock.ts new file mode 100644 index 00000000000..59f2e057eb5 --- /dev/null +++ b/packages/pds/src/lexicon/types/app/bsky/graph/listblock.ts @@ -0,0 +1,26 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +import { CID } from 'multiformats/cid' + +export interface Record { + subject: string + createdAt: string + [k: string]: unknown +} + +export function isRecord(v: unknown): v is Record { + return ( + isObj(v) && + hasProp(v, '$type') && + (v.$type === 'app.bsky.graph.listblock#main' || + v.$type === 'app.bsky.graph.listblock') + ) +} + +export function validateRecord(v: unknown): ValidationResult { + return lexicons.validate('app.bsky.graph.listblock#main', v) +} diff --git a/packages/pds/tests/proxied/__snapshots__/views.test.ts.snap b/packages/pds/tests/proxied/__snapshots__/views.test.ts.snap index 6a055da07a2..1e7b75c9cc8 100644 --- a/packages/pds/tests/proxied/__snapshots__/views.test.ts.snap +++ b/packages/pds/tests/proxied/__snapshots__/views.test.ts.snap @@ -117,9 +117,9 @@ exports[`proxies view requests actor.getSuggestions 1`] = ` Object { "actors": Array [ Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(2)/cids(0)@jpeg", + "avatar": "https://bsky.public.url/img/avatar/plain/user(1)/cids(0)@jpeg", "description": "hi im bob label_me", - "did": "user(1)", + "did": "user(0)", "displayName": "bobby", "handle": "bob.test", "indexedAt": "1970-01-01T00:00:00.000Z", @@ -128,7 +128,7 @@ Object { "cid": "cids(1)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(1)", + "src": "user(0)", "uri": "record(1)", "val": "self-label-a", }, @@ -136,7 +136,7 @@ Object { "cid": "cids(1)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(1)", + "src": "user(0)", "uri": "record(1)", "val": "self-label-b", }, @@ -148,14 +148,14 @@ Object { }, }, Object { - "did": "user(0)", + "did": "user(2)", "handle": "dan.test", "labels": Array [ Object { "cts": "1970-01-01T00:00:00.000Z", "neg": false, "src": "did:example:labeler", - "uri": "user(0)", + "uri": "user(2)", "val": "repo-action-label", }, ], @@ -165,7 +165,7 @@ Object { }, }, ], - "cursor": "user(0)", + "cursor": "user(2)", } `; @@ -713,7 +713,7 @@ Object { "cts": "1970-01-01T00:00:00.000Z", "neg": false, "src": "user(0)", - "uri": "record(3)", + "uri": "record(2)", "val": "self-label-a", }, Object { @@ -721,14 +721,14 @@ Object { "cts": "1970-01-01T00:00:00.000Z", "neg": false, "src": "user(0)", - "uri": "record(3)", + "uri": "record(2)", "val": "self-label-b", }, ], "viewer": Object { "blockedBy": false, - "followedBy": "record(2)", - "following": "record(1)", + "followedBy": "record(1)", + "following": "record(0)", "muted": false, }, }, @@ -749,7 +749,7 @@ Object { "cts": "1970-01-01T00:00:00.000Z", "neg": false, "src": "user(2)", - "uri": "record(4)", + "uri": "record(3)", "val": "self-label-a", }, Object { @@ -757,7 +757,7 @@ Object { "cts": "1970-01-01T00:00:00.000Z", "neg": false, "src": "user(2)", - "uri": "record(4)", + "uri": "record(3)", "val": "self-label-b", }, ], @@ -770,7 +770,7 @@ Object { "indexedAt": "1970-01-01T00:00:00.000Z", }, ], - "uri": "record(0)", + "uri": "record(4)", } `; @@ -971,13 +971,13 @@ Object { "labels": Array [], "viewer": Object { "blockedBy": false, - "followedBy": "record(2)", - "following": "record(1)", + "followedBy": "record(1)", + "following": "record(0)", "muted": false, }, }, ], - "uri": "record(0)", + "uri": "record(2)", } `; @@ -2225,45 +2225,45 @@ Object { "cursor": "0000000000000::bafycid", "followers": Array [ Object { - "did": "user(2)", + "did": "user(0)", "handle": "dan.test", "labels": Array [ Object { "cts": "1970-01-01T00:00:00.000Z", "neg": false, "src": "did:example:labeler", - "uri": "user(2)", + "uri": "user(0)", "val": "repo-action-label", }, ], "viewer": Object { "blockedBy": false, - "following": "record(3)", + "following": "record(0)", "muted": false, }, }, Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(4)/cids(0)@jpeg", + "avatar": "https://bsky.public.url/img/avatar/plain/user(2)/cids(0)@jpeg", "description": "its me!", - "did": "user(3)", + "did": "user(1)", "displayName": "ali", "handle": "alice.test", "indexedAt": "1970-01-01T00:00:00.000Z", "labels": Array [ Object { - "cid": "cids(2)", + "cid": "cids(1)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(3)", - "uri": "record(4)", + "src": "user(1)", + "uri": "record(1)", "val": "self-label-a", }, Object { - "cid": "cids(2)", + "cid": "cids(1)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(3)", - "uri": "record(4)", + "src": "user(1)", + "uri": "record(1)", "val": "self-label-b", }, ], @@ -2274,34 +2274,34 @@ Object { }, ], "subject": Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(1)/cids(0)@jpeg", + "avatar": "https://bsky.public.url/img/avatar/plain/user(4)/cids(0)@jpeg", "description": "hi im bob label_me", - "did": "user(0)", + "did": "user(3)", "displayName": "bobby", "handle": "bob.test", "indexedAt": "1970-01-01T00:00:00.000Z", "labels": Array [ Object { - "cid": "cids(1)", + "cid": "cids(2)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(0)", - "uri": "record(2)", + "src": "user(3)", + "uri": "record(4)", "val": "self-label-a", }, Object { - "cid": "cids(1)", + "cid": "cids(2)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(0)", - "uri": "record(2)", + "src": "user(3)", + "uri": "record(4)", "val": "self-label-b", }, ], "viewer": Object { "blockedBy": false, - "followedBy": "record(1)", - "following": "record(0)", + "followedBy": "record(3)", + "following": "record(2)", "muted": false, }, }, @@ -2313,38 +2313,38 @@ Object { "cursor": "0000000000000::bafycid", "follows": Array [ Object { - "did": "user(2)", + "did": "user(0)", "handle": "carol.test", "labels": Array [], "viewer": Object { "blockedBy": false, - "followedBy": "record(4)", - "following": "record(3)", + "followedBy": "record(1)", + "following": "record(0)", "muted": false, }, }, Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(4)/cids(0)@jpeg", + "avatar": "https://bsky.public.url/img/avatar/plain/user(2)/cids(0)@jpeg", "description": "its me!", - "did": "user(3)", + "did": "user(1)", "displayName": "ali", "handle": "alice.test", "indexedAt": "1970-01-01T00:00:00.000Z", "labels": Array [ Object { - "cid": "cids(2)", + "cid": "cids(1)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(3)", - "uri": "record(5)", + "src": "user(1)", + "uri": "record(2)", "val": "self-label-a", }, Object { - "cid": "cids(2)", + "cid": "cids(1)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(3)", - "uri": "record(5)", + "src": "user(1)", + "uri": "record(2)", "val": "self-label-b", }, ], @@ -2355,34 +2355,34 @@ Object { }, ], "subject": Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(1)/cids(0)@jpeg", + "avatar": "https://bsky.public.url/img/avatar/plain/user(4)/cids(0)@jpeg", "description": "hi im bob label_me", - "did": "user(0)", + "did": "user(3)", "displayName": "bobby", "handle": "bob.test", "indexedAt": "1970-01-01T00:00:00.000Z", "labels": Array [ Object { - "cid": "cids(1)", + "cid": "cids(2)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(0)", - "uri": "record(2)", + "src": "user(3)", + "uri": "record(5)", "val": "self-label-a", }, Object { - "cid": "cids(1)", + "cid": "cids(2)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(0)", - "uri": "record(2)", + "src": "user(3)", + "uri": "record(5)", "val": "self-label-b", }, ], "viewer": Object { "blockedBy": false, - "followedBy": "record(1)", - "following": "record(0)", + "followedBy": "record(4)", + "following": "record(3)", "muted": false, }, }, @@ -2395,40 +2395,40 @@ Object { "items": Array [ Object { "subject": Object { - "did": "user(0)", + "did": "user(2)", "handle": "carol.test", "labels": Array [], "viewer": Object { "blockedBy": false, - "followedBy": "record(1)", - "following": "record(0)", + "followedBy": "record(5)", + "following": "record(4)", "muted": false, }, }, }, Object { "subject": Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(2)/cids(0)@jpeg", + "avatar": "https://bsky.public.url/img/avatar/plain/user(4)/cids(1)@jpeg", "description": "its me!", - "did": "user(1)", + "did": "user(3)", "displayName": "ali", "handle": "alice.test", "indexedAt": "1970-01-01T00:00:00.000Z", "labels": Array [ Object { - "cid": "cids(1)", + "cid": "cids(3)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(1)", - "uri": "record(2)", + "src": "user(3)", + "uri": "record(6)", "val": "self-label-a", }, Object { - "cid": "cids(1)", + "cid": "cids(3)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(1)", - "uri": "record(2)", + "src": "user(3)", + "uri": "record(6)", "val": "self-label-b", }, ], @@ -2440,36 +2440,36 @@ Object { }, ], "list": Object { - "cid": "cids(2)", + "cid": "cids(0)", "creator": Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(4)/cids(0)@jpeg", + "avatar": "https://bsky.public.url/img/avatar/plain/user(1)/cids(1)@jpeg", "description": "hi im bob label_me", - "did": "user(3)", + "did": "user(0)", "displayName": "bobby", "handle": "bob.test", "indexedAt": "1970-01-01T00:00:00.000Z", "labels": Array [ Object { - "cid": "cids(3)", + "cid": "cids(2)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(3)", - "uri": "record(6)", + "src": "user(0)", + "uri": "record(3)", "val": "self-label-a", }, Object { - "cid": "cids(3)", + "cid": "cids(2)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(3)", - "uri": "record(6)", + "src": "user(0)", + "uri": "record(3)", "val": "self-label-b", }, ], "viewer": Object { "blockedBy": false, - "followedBy": "record(5)", - "following": "record(4)", + "followedBy": "record(2)", + "following": "record(1)", "muted": false, }, }, @@ -2477,7 +2477,7 @@ Object { "indexedAt": "1970-01-01T00:00:00.000Z", "name": "bob mutes", "purpose": "app.bsky.graph.defs#modlist", - "uri": "record(3)", + "uri": "record(0)", "viewer": Object { "muted": false, }, @@ -2485,6 +2485,56 @@ Object { } `; +exports[`proxies view requests graph.getListBlocks 1`] = ` +Object { + "cursor": "0000000000000::bafycid", + "lists": Array [ + Object { + "cid": "cids(0)", + "creator": Object { + "avatar": "https://bsky.public.url/img/avatar/plain/user(1)/cids(1)@jpeg", + "description": "hi im bob label_me", + "did": "user(0)", + "displayName": "bobby", + "handle": "bob.test", + "indexedAt": "1970-01-01T00:00:00.000Z", + "labels": Array [ + Object { + "cid": "cids(2)", + "cts": "1970-01-01T00:00:00.000Z", + "neg": false, + "src": "user(0)", + "uri": "record(2)", + "val": "self-label-a", + }, + Object { + "cid": "cids(2)", + "cts": "1970-01-01T00:00:00.000Z", + "neg": false, + "src": "user(0)", + "uri": "record(2)", + "val": "self-label-b", + }, + ], + "viewer": Object { + "blockedBy": false, + "muted": false, + }, + }, + "description": "bob's list of mutes", + "indexedAt": "1970-01-01T00:00:00.000Z", + "name": "bob mutes", + "purpose": "app.bsky.graph.defs#modlist", + "uri": "record(0)", + "viewer": Object { + "blocked": "record(1)", + "muted": false, + }, + }, + ], +} +`; + exports[`proxies view requests graph.getLists 1`] = ` Object { "cursor": "0000000000000::bafycid", diff --git a/packages/pds/tests/proxied/views.test.ts b/packages/pds/tests/proxied/views.test.ts index 066cc780059..84079af1c44 100644 --- a/packages/pds/tests/proxied/views.test.ts +++ b/packages/pds/tests/proxied/views.test.ts @@ -524,4 +524,27 @@ describe('proxies view requests', () => { ) expect([...pt1.data.lists, ...pt2.data.lists]).toEqual(res.data.lists) }) + + it('graph.getListBlocks', async () => { + await agent.api.app.bsky.graph.listblock.create( + { repo: bob }, + { + subject: listUri, + createdAt: new Date().toISOString(), + }, + sc.getHeaders(bob), + ) + await network.processAll() + const pt1 = await agent.api.app.bsky.graph.getListBlocks( + {}, + { headers: sc.getHeaders(bob) }, + ) + expect(forSnapshot(pt1.data)).toMatchSnapshot() + const pt2 = await agent.api.app.bsky.graph.getListBlocks( + { cursor: pt1.data.cursor }, + { headers: sc.getHeaders(bob) }, + ) + expect(pt2.data.lists).toEqual([]) + expect(pt2.data.cursor).not.toBeDefined() + }) })