From d7ee9f3442634c748ccaa772d8a5e20d5e3ef4be Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Mon, 28 Aug 2023 17:30:02 -0400 Subject: [PATCH 01/49] lexicons for block lists --- lexicons/app/bsky/graph/defs.json | 3 +- lexicons/app/bsky/graph/getListBlocks.json | 31 +++++++ 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 ++++++ 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/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 ++++++ 18 files changed, 595 insertions(+), 1 deletion(-) 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/lexicon/types/app/bsky/graph/getListBlocks.ts create mode 100644 packages/bsky/src/lexicon/types/app/bsky/graph/listblock.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/lexicons/app/bsky/graph/defs.json b/lexicons/app/bsky/graph/defs.json index d5de7e5422c..a9c112f65dc 100644 --- a/lexicons/app/bsky/graph/defs.json +++ b/lexicons/app/bsky/graph/defs.json @@ -54,7 +54,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..e1c16b6eb6e --- /dev/null +++ b/lexicons/app/bsky/graph/getListBlocks.json @@ -0,0 +1,31 @@ +{ + "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..c763f83843e --- /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 94bb818d31a..edc395d17d0 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' @@ -1621,6 +1625,7 @@ export class GraphNS { block: BlockRecord follow: FollowRecord list: ListRecord + listblock: ListblockRecord listitem: ListitemRecord constructor(service: AtpServiceClient) { @@ -1628,6 +1633,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) } @@ -1675,6 +1681,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, @@ -1936,6 +1953,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 b5c82ce2f73..5347e6f76ad 100644 --- a/packages/api/src/client/lexicons.ts +++ b/packages/api/src/client/lexicons.ts @@ -5699,6 +5699,10 @@ export const schemaDict = { muted: { type: 'boolean', }, + blocked: { + type: 'string', + format: 'at-uri', + }, }, }, }, @@ -5927,6 +5931,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', @@ -6112,6 +6159,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', @@ -6769,10 +6841,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/lexicon/index.ts b/packages/bsky/src/lexicon/index.ts index 7143c1833a4..60a3137bf43 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' @@ -1218,6 +1219,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 b5c82ce2f73..5347e6f76ad 100644 --- a/packages/bsky/src/lexicon/lexicons.ts +++ b/packages/bsky/src/lexicon/lexicons.ts @@ -5699,6 +5699,10 @@ export const schemaDict = { muted: { type: 'boolean', }, + blocked: { + type: 'string', + format: 'at-uri', + }, }, }, }, @@ -5927,6 +5931,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', @@ -6112,6 +6159,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', @@ -6769,10 +6841,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/pds/src/lexicon/index.ts b/packages/pds/src/lexicon/index.ts index 7143c1833a4..60a3137bf43 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' @@ -1218,6 +1219,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 b5c82ce2f73..5347e6f76ad 100644 --- a/packages/pds/src/lexicon/lexicons.ts +++ b/packages/pds/src/lexicon/lexicons.ts @@ -5699,6 +5699,10 @@ export const schemaDict = { muted: { type: 'boolean', }, + blocked: { + type: 'string', + format: 'at-uri', + }, }, }, }, @@ -5927,6 +5931,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', @@ -6112,6 +6159,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', @@ -6769,10 +6841,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) +} From a21aa90ba6509736b3c817c4eb5e3e2e05ecc6a5 Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Tue, 29 Aug 2023 10:05:14 -0400 Subject: [PATCH 02/49] reorg blockset functionality into graph service, impl block/mute filtering --- packages/bsky/src/services/feed/index.ts | 62 +---------- packages/bsky/src/services/graph/index.ts | 123 +++++++++++++++++++++- 2 files changed, 123 insertions(+), 62 deletions(-) diff --git a/packages/bsky/src/services/feed/index.ts b/packages/bsky/src/services/feed/index.ts index 895cec08c7b..a4962c0372d 100644 --- a/packages/bsky/src/services/feed/index.ts +++ b/packages/bsky/src/services/feed/index.ts @@ -4,12 +4,7 @@ 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 { @@ -43,7 +38,7 @@ import { } from './types' import { LabelService, Labels, getSelfLabels } from '../label' import { ActorService } from '../actor' -import { GraphService } from '../graph' +import { GraphService, RelationshipPair } from '../graph' import { FeedViews } from './views' import { LabelCache } from '../../label-cache' @@ -466,7 +461,7 @@ export class FeedService { } } // compute block state from all actor relationships among posts - const blockSet = await this.getBlockSet(relationships) + const blockSet = await this.services.graph.getBlockSet(relationships) if (blockSet.empty()) return {} const result: PostBlocksMap = {} Object.entries(byPost).forEach(([uri, block]) => { @@ -482,28 +477,6 @@ 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, @@ -664,35 +637,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, diff --git a/packages/bsky/src/services/graph/index.ts b/packages/bsky/src/services/graph/index.ts index 4c05dc7cecd..dc5c7189189 100644 --- a/packages/bsky/src/services/graph/index.ts +++ b/packages/bsky/src/services/graph/index.ts @@ -1,10 +1,10 @@ +import { Selectable, WhereInterface, sql } from 'kysely' +import { NotEmptyArray } from '@atproto/common' 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 { DbRef, noMatch, valuesList } from '../../db/util' export class GraphService { constructor(public db: Database, public imgUriBuilder: ImageUriBuilder) {} @@ -196,6 +196,92 @@ export class GraphService { } } + 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, true)) + // 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], true)) + return blockSet + } + + async getMuteSet(relationships: RelationshipPair[]) { + const { ref } = this.db.db.dynamic + const muteSet = new RelationshipSet() + if (!relationships.length) return muteSet + const relationshipSet = new RelationshipSet() + relationships.forEach((pair) => relationshipSet.add(pair)) + // compute actual mute set from all actor relationships + const muteRows = await this.db.db + .selectFrom('mute') + .select(['mutedByDid', 'subjectDid']) + .where( + sql`(${ref('mutedByDid')}, ${ref('subjectDid')})`, + 'in', + valuesList( + relationshipSet.listAllPairs().map(([a, b]) => sql`${a}, ${b}`), + ), + ) + .unionAll( + this.db.db + .selectFrom('list_item') + .innerJoin('list_mute', 'list_mute.listUri', 'list_item.listUri') + .where( + sql`(${ref('list_mute.mutedByDid')}, ${ref( + 'list_item.subjectDid', + )})`, + 'in', + valuesList( + relationshipSet.listAllPairs().map(([a, b]) => sql`${a}, ${b}`), + ), + ) + .select(['list_mute.mutedByDid', 'list_item.subjectDid']), + ) + .execute() + muteRows.forEach((r) => muteSet.add([r.mutedByDid, r.subjectDid])) + return muteSet + } + + async filterBlocksAndMutes( + items: T[], + opts: { + getBlockPairs?: (item: T) => RelationshipPair[] | undefined + getMutePairs?: (item: T) => RelationshipPair[] | undefined + }, + ) { + const blockPairsPerItem = items.map( + (item) => opts.getBlockPairs?.(item) ?? [], + ) + const mutePairsPerItem = items.map( + (item) => opts.getMutePairs?.(item) ?? [], + ) + const [blockSet, muteSet] = await Promise.all([ + this.getBlockSet(blockPairsPerItem.flat()), + this.getMuteSet(mutePairsPerItem.flat()), + ]) + return items.filter((_, i) => { + const blockPairs = blockPairsPerItem[i] + const mutePairs = mutePairsPerItem[i] + return ( + blockPairs.every((pair) => !blockSet.has(pair)) && + mutePairs.every((pair) => !muteSet.has(pair)) + ) + }) + } + async getListViews(listUris: string[], requester: string | null) { if (listUris.length < 1) return {} const lists = await this.getListsQb(requester) @@ -259,3 +345,34 @@ export class GraphService { type ListInfo = Selectable & { viewerMuted: string | null } + +export type RelationshipPair = [didA: string, didB: string] + +export class RelationshipSet { + index = new Map>() + add([didA, didB]: RelationshipPair, bididrectional = false) { + const didAIdx = this.index.get(didA) ?? new Set() + if (!this.index.has(didA)) this.index.set(didA, didAIdx) + didAIdx.add(didB) + if (bididrectional) { + const didBIdx = this.index.get(didB) ?? new Set() + if (!this.index.has(didB)) this.index.set(didB, didBIdx) + 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 + } +} From 1a5eae28ce1e1420eb9215ab5b12171e7dd040ce Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Tue, 29 Aug 2023 10:50:14 -0400 Subject: [PATCH 03/49] apply filterBlocksAndMutes() throughout appview except feeds --- .../src/api/app/bsky/actor/getSuggestions.ts | 13 +++++- .../src/api/app/bsky/feed/getActorLikes.ts | 16 ++++--- .../src/api/app/bsky/feed/getAuthorFeed.ts | 29 ++++++------ .../bsky/src/api/app/bsky/feed/getLikes.ts | 14 ++++-- .../src/api/app/bsky/feed/getRepostedBy.ts | 16 +++++-- .../src/api/app/bsky/graph/getFollowers.ts | 26 +++++++---- .../bsky/src/api/app/bsky/graph/getFollows.ts | 23 ++++++---- .../bsky/notification/listNotifications.ts | 16 ++++--- packages/bsky/src/services/feed/index.ts | 46 ++++++++++--------- packages/bsky/src/services/graph/index.ts | 19 -------- 10 files changed, 122 insertions(+), 96 deletions(-) diff --git a/packages/bsky/src/api/app/bsky/actor/getSuggestions.ts b/packages/bsky/src/api/app/bsky/actor/getSuggestions.ts index 6a142519965..a19cdd0a7a9 100644 --- a/packages/bsky/src/api/app/bsky/actor/getSuggestions.ts +++ b/packages/bsky/src/api/app/bsky/actor/getSuggestions.ts @@ -28,7 +28,6 @@ export default function (server: Server, ctx: AppContext) { .where('creator', '=', viewer ?? '') .whereRef('subjectDid', '=', ref('actor.did')), ) - .whereNotExists(graphService.blockQb(viewer, [ref('actor.did')])) .selectAll() .select('profile_agg.postsCount as postsCount') .limit(limit) @@ -50,13 +49,23 @@ export default function (server: Server, ctx: AppContext) { } const suggestionsRes = await suggestionsQb.execute() + const suggestionsSafe = await graphService.filterBlocksAndMutes( + suggestionsRes, + { + getBlockPairs(item) { + if (viewer) { + return [[viewer, item.did]] + } + }, + }, + ) return { encoding: 'application/json', body: { cursor: suggestionsRes.at(-1)?.did, actors: await actorService.views.hydrateProfiles( - suggestionsRes, + suggestionsSafe, viewer, ), }, diff --git a/packages/bsky/src/api/app/bsky/feed/getActorLikes.ts b/packages/bsky/src/api/app/bsky/feed/getActorLikes.ts index 7c634e0a810..42769bb1a79 100644 --- a/packages/bsky/src/api/app/bsky/feed/getActorLikes.ts +++ b/packages/bsky/src/api/app/bsky/feed/getActorLikes.ts @@ -34,12 +34,6 @@ export default function (server: Server, ctx: AppContext) { .innerJoin('like', 'like.subject', 'feed_item.uri') .where('like.creator', '=', actorDid) - if (viewer !== null) { - feedItemsQb = feedItemsQb.whereNotExists( - graphService.blockQb(viewer, [ref('post.creator')]), - ) - } - const keyset = new FeedKeyset( ref('feed_item.sortAt'), ref('feed_item.cid'), @@ -57,7 +51,15 @@ export default function (server: Server, ctx: AppContext) { ]) setRepoRev(res, repoRev) - const feed = await feedService.hydrateFeed(feedItems, viewer) + const feedItemsSafe = await graphService.filterBlocksAndMutes(feedItems, { + getBlockPairs(item) { + if (viewer) { + return [[viewer, item.postAuthorDid]] + } + }, + }) + + const feed = await feedService.hydrateFeed(feedItemsSafe, viewer) return { encoding: 'application/json', diff --git a/packages/bsky/src/api/app/bsky/feed/getAuthorFeed.ts b/packages/bsky/src/api/app/bsky/feed/getAuthorFeed.ts index 06b93513444..8aca55e2321 100644 --- a/packages/bsky/src/api/app/bsky/feed/getAuthorFeed.ts +++ b/packages/bsky/src/api/app/bsky/feed/getAuthorFeed.ts @@ -67,19 +67,6 @@ export default function (server: Server, ctx: AppContext) { ) } - 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'), @@ -97,7 +84,21 @@ export default function (server: Server, ctx: AppContext) { ]) setRepoRev(res, repoRev) - const feed = await feedService.hydrateFeed(feedItems, viewer) + const feedItemsSafe = await graphService.filterBlocksAndMutes(feedItems, { + getBlockPairs(item) { + if (viewer) { + return [[viewer, item.postAuthorDid]] + } + }, + getMutePairs(item) { + // Hide reposts of muted content + if (viewer && item.type === 'repost') { + return [[viewer, item.postAuthorDid]] + } + }, + }) + + const feed = await feedService.hydrateFeed(feedItemsSafe, viewer) return { encoding: 'application/json', diff --git a/packages/bsky/src/api/app/bsky/feed/getLikes.ts b/packages/bsky/src/api/app/bsky/feed/getLikes.ts index 7ecd3b4e2db..8f8fb986a4f 100644 --- a/packages/bsky/src/api/app/bsky/feed/getLikes.ts +++ b/packages/bsky/src/api/app/bsky/feed/getLikes.ts @@ -21,7 +21,6 @@ export default function (server: Server, ctx: AppContext) { .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', @@ -42,11 +41,20 @@ export default function (server: Server, ctx: AppContext) { }) const likesRes = await builder.execute() + + const likesSafe = await graphService.filterBlocksAndMutes(likesRes, { + getBlockPairs: (like) => { + if (requester) { + return [[requester, like.did]] + } + }, + }) + const actors = await ctx.services .actor(db) - .views.profiles(likesRes, requester) + .views.profiles(likesSafe, requester) - const likes = mapDefined(likesRes, (row) => + const likes = mapDefined(likesSafe, (row) => actors[row.did] ? { createdAt: row.createdAt, diff --git a/packages/bsky/src/api/app/bsky/feed/getRepostedBy.ts b/packages/bsky/src/api/app/bsky/feed/getRepostedBy.ts index 69ad1e5c79e..1939507d02c 100644 --- a/packages/bsky/src/api/app/bsky/feed/getRepostedBy.ts +++ b/packages/bsky/src/api/app/bsky/feed/getRepostedBy.ts @@ -18,9 +18,6 @@ export default function (server: Server, ctx: AppContext) { .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']) @@ -36,9 +33,20 @@ export default function (server: Server, ctx: AppContext) { }) const repostedByRes = await builder.execute() + const repostedBySafe = await graphService.filterBlocksAndMutes( + repostedByRes, + { + getBlockPairs(item) { + if (requester) { + return [[requester, item.did]] + } + }, + }, + ) + const repostedBy = await ctx.services .actor(db) - .views.hydrateProfiles(repostedByRes, requester) + .views.hydrateProfiles(repostedBySafe, 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..0a95a2f826f 100644 --- a/packages/bsky/src/api/app/bsky/graph/getFollowers.ts +++ b/packages/bsky/src/api/app/bsky/graph/getFollowers.ts @@ -33,15 +33,6 @@ export default function (server: Server, ctx: AppContext) { .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']) @@ -53,14 +44,29 @@ export default function (server: Server, ctx: AppContext) { }) const followersRes = await followersReq.execute() + const followersSafe = await graphService.filterBlocksAndMutes( + followersRes, + { + getBlockPairs(item) { + return requester + ? [ + [requester, item.did], + [subjectRes.did, item.did], + ] + : [[subjectRes.did, item.did]] + }, + }, + ) + const [followers, subject] = await Promise.all([ - actorService.views.hydrateProfiles(followersRes, requester, { + actorService.views.hydrateProfiles(followersSafe, requester, { includeSoftDeleted: canViewTakendownProfile, }), actorService.views.profile(subjectRes, requester, { includeSoftDeleted: canViewTakendownProfile, }), ]) + if (!subject) { throw new InvalidRequestError(`Actor not found: ${actor}`) } diff --git a/packages/bsky/src/api/app/bsky/graph/getFollows.ts b/packages/bsky/src/api/app/bsky/graph/getFollows.ts index 344b17b0158..2ac1762fb01 100644 --- a/packages/bsky/src/api/app/bsky/graph/getFollows.ts +++ b/packages/bsky/src/api/app/bsky/graph/getFollows.ts @@ -33,15 +33,6 @@ export default function (server: Server, ctx: AppContext) { .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']) @@ -53,14 +44,26 @@ export default function (server: Server, ctx: AppContext) { }) const followsRes = await followsReq.execute() + const followsSafe = await graphService.filterBlocksAndMutes(followsRes, { + getBlockPairs(item) { + return requester + ? [ + [requester, item.did], + [creatorRes.did, item.did], + ] + : [[creatorRes.did, item.did]] + }, + }) + const [follows, subject] = await Promise.all([ - actorService.views.hydrateProfiles(followsRes, requester, { + actorService.views.hydrateProfiles(followsSafe, requester, { includeSoftDeleted: canViewTakendownProfile, }), actorService.views.profile(creatorRes, requester, { includeSoftDeleted: canViewTakendownProfile, }), ]) + if (!subject) { throw new InvalidRequestError(`Actor not found: ${actor}`) } diff --git a/packages/bsky/src/api/app/bsky/notification/listNotifications.ts b/packages/bsky/src/api/app/bsky/notification/listNotifications.ts index 1f36207820e..6a37838b8c8 100644 --- a/packages/bsky/src/api/app/bsky/notification/listNotifications.ts +++ b/packages/bsky/src/api/app/bsky/notification/listNotifications.ts @@ -28,10 +28,6 @@ export default function (server: Server, ctx: AppContext) { .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) @@ -80,7 +76,15 @@ export default function (server: Server, ctx: AppContext) { 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([ + const [notifsSafe, authors, labels] = await Promise.all([ + graphService.filterBlocksAndMutes(notifs, { + getBlockPairs(item) { + return [[requester, item.authorDid]] + }, + getMutePairs(item) { + return [[requester, item.authorDid]] + }, + }), actorService.views.profiles( notifs.map((notif) => ({ did: notif.authorDid, @@ -93,7 +97,7 @@ export default function (server: Server, ctx: AppContext) { labelService.getLabelsForUris(recordUris), ]) - const notifications = mapDefined(notifs, (notif) => { + const notifications = mapDefined(notifsSafe, (notif) => { const author = authors[notif.authorDid] if (!author) return undefined const record = jsonStringToLex(notif.recordJson) as Record< diff --git a/packages/bsky/src/services/feed/index.ts b/packages/bsky/src/services/feed/index.ts index a4962c0372d..dac28bd6f5d 100644 --- a/packages/bsky/src/services/feed/index.ts +++ b/packages/bsky/src/services/feed/index.ts @@ -342,27 +342,10 @@ export class FeedService { }, {} 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 }) @@ -377,10 +360,20 @@ export class FeedService { ): Promise { skeleton = skeleton.slice(0, limit) const feedItemUris = skeleton.map(getSkeleFeedItemUri) - const feedItems = await this.filterAndGetFeedItems(feedItemUris, requester) - + const [feedItems, skeletonSafe] = await Promise.all([ + this.getFeedItems(feedItemUris), + this.services.graph.filterBlocksAndMutes(skeleton, { + getBlockPairs(item) { + return getPostAndRepostPairs(item, requester) + }, + getMutePairs(item) { + // Hide posts and reposts of or by muted actors + return getPostAndRepostPairs(item, requester) + }, + }), + ]) const cleaned: FeedRow[] = [] - for (const skeleItem of skeleton) { + for (const skeleItem of skeletonSafe) { const feedItem = feedItems[getSkeleFeedItemUri(skeleItem)] if (feedItem && feedItem.postUri === skeleItem.post) { cleaned.push(feedItem) @@ -666,3 +659,14 @@ function getSkeleFeedItemUri(item: SkeletonFeedPost) { ? item.reason.repost : item.post } + +function getPostAndRepostPairs( + item: SkeletonFeedPost, + requester: string, +): RelationshipPair[] { + const uriStrs = + typeof item.reason?.repost === 'string' + ? [item.post, item.reason.repost] + : [item.post] + return uriStrs.map((uriStr) => [requester, new AtUri(uriStr).host]) +} diff --git a/packages/bsky/src/services/graph/index.ts b/packages/bsky/src/services/graph/index.ts index dc5c7189189..dbc7c769c4b 100644 --- a/packages/bsky/src/services/graph/index.ts +++ b/packages/bsky/src/services/graph/index.ts @@ -137,25 +137,6 @@ export class GraphService { .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 getBlocks( requester: string, subjectHandleOrDid: string, From 15d12202389bfa00980f6db9084eccb91aca94b5 Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Tue, 29 Aug 2023 11:17:50 -0400 Subject: [PATCH 04/49] update local feeds to pass through cleanFeedSkeleton(), offload block/mute application --- .../bsky/src/api/app/bsky/feed/getFeed.ts | 18 +++-- .../bsky/src/api/app/bsky/feed/getTimeline.ts | 1 - packages/bsky/src/feed-gen/best-of-follows.ts | 9 +-- packages/bsky/src/feed-gen/bsky-team.ts | 12 ++-- packages/bsky/src/feed-gen/hot-classic.ts | 12 ++-- packages/bsky/src/feed-gen/mutuals.ts | 10 +-- packages/bsky/src/feed-gen/types.ts | 18 ++++- packages/bsky/src/feed-gen/whats-hot.ts | 17 +---- packages/bsky/src/services/feed/index.ts | 3 - packages/bsky/src/services/graph/index.ts | 72 +++---------------- 10 files changed, 49 insertions(+), 123 deletions(-) diff --git a/packages/bsky/src/api/app/bsky/feed/getFeed.ts b/packages/bsky/src/api/app/bsky/feed/getFeed.ts index 910ea514e94..1f300791ba4 100644 --- a/packages/bsky/src/api/app/bsky/feed/getFeed.ts +++ b/packages/bsky/src/api/app/bsky/feed/getFeed.ts @@ -30,7 +30,7 @@ export default function (server: Server, ctx: AppContext) { const localAlgo = ctx.algos[feed] const timerSkele = new ServerTimer('skele').start() - const { feedItems, ...rest } = + const skeleton = localAlgo !== undefined ? await localAlgo(ctx, params, viewer) : await skeletonFromFeedGen( @@ -43,13 +43,16 @@ export default function (server: Server, ctx: AppContext) { timerSkele.stop() const timerHydr = new ServerTimer('hydr').start() - const hydrated = await feedService.hydrateFeed(feedItems, viewer) + const cleanedFeed = await ctx.services + .feed(db) + .cleanFeedSkeleton(skeleton.feed, viewer) + const hydrated = await feedService.hydrateFeed(cleanedFeed, viewer) timerHydr.stop() return { encoding: 'application/json', body: { - ...rest, + ...skeleton, feed: hydrated, }, headers: { @@ -129,13 +132,8 @@ async function skeletonFromFeedGen( throw err } - const { feed: skeletonFeed, ...rest } = skeleton - const cleanedFeed = await ctx.services - .feed(db) - .cleanFeedSkeleton(skeletonFeed, params.limit, viewer) - return { - ...rest, - feedItems: cleanedFeed, + ...skeleton, + feed: skeleton.feed.slice(0, params.limit), // enforce limit } } diff --git a/packages/bsky/src/api/app/bsky/feed/getTimeline.ts b/packages/bsky/src/api/app/bsky/feed/getTimeline.ts index 55f0a2837c3..3e083c1c955 100644 --- a/packages/bsky/src/api/app/bsky/feed/getTimeline.ts +++ b/packages/bsky/src/api/app/bsky/feed/getTimeline.ts @@ -28,7 +28,6 @@ export default function (server: Server, ctx: AppContext) { const feedService = ctx.services.feed(db) const feedItems = await feedService.cleanFeedSkeleton( skeleton.feed, - limit, viewer, ) const feed = await feedService.hydrateFeed(feedItems, viewer) diff --git a/packages/bsky/src/feed-gen/best-of-follows.ts b/packages/bsky/src/feed-gen/best-of-follows.ts index c154ad9aa0e..d40fc58e860 100644 --- a/packages/bsky/src/feed-gen/best-of-follows.ts +++ b/packages/bsky/src/feed-gen/best-of-follows.ts @@ -1,6 +1,6 @@ import { InvalidRequestError } from '@atproto/xrpc-server' import { QueryParams as SkeletonParams } from '../lexicon/types/app/bsky/feed/getFeedSkeleton' -import { AlgoHandler, AlgoResponse } from './types' +import { AlgoHandler, AlgoResponse, toSkeletonItem } from './types' import { GenericKeyset, paginate } from '../db/pagination' import AppContext from '../context' @@ -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') @@ -44,7 +39,7 @@ const handler: AlgoHandler = async ( const feedItems = await builder.execute() return { - feedItems, + feed: feedItems.map(toSkeletonItem), cursor: keyset.packFromResult(feedItems), } } diff --git a/packages/bsky/src/feed-gen/bsky-team.ts b/packages/bsky/src/feed-gen/bsky-team.ts index 40e9cc63fe5..a00546dd692 100644 --- a/packages/bsky/src/feed-gen/bsky-team.ts +++ b/packages/bsky/src/feed-gen/bsky-team.ts @@ -2,7 +2,7 @@ import { NotEmptyArray } from '@atproto/common' import { QueryParams as SkeletonParams } from '../lexicon/types/app/bsky/feed/getFeedSkeleton' import AppContext from '../context' import { paginate } from '../db/pagination' -import { AlgoHandler, AlgoResponse } from './types' +import { AlgoHandler, AlgoResponse, toSkeletonItem } from './types' import { FeedKeyset } from '../api/app/bsky/util/feed' const BSKY_TEAM: NotEmptyArray = [ @@ -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,8 +32,9 @@ const handler: AlgoHandler = async ( feedQb = paginate(feedQb, { limit, cursor, keyset }) const feedItems = await feedQb.execute() + return { - feedItems, + feed: feedItems.map(toSkeletonItem), 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..265be4bfa5d 100644 --- a/packages/bsky/src/feed-gen/hot-classic.ts +++ b/packages/bsky/src/feed-gen/hot-classic.ts @@ -2,7 +2,7 @@ import AppContext from '../context' import { NotEmptyArray } from '@atproto/common' import { QueryParams as SkeletonParams } from '../lexicon/types/app/bsky/feed/getFeedSkeleton' import { paginate } from '../db/pagination' -import { AlgoHandler, AlgoResponse } from './types' +import { AlgoHandler, AlgoResponse, toSkeletonItem } from './types' import { FeedKeyset } from '../api/app/bsky/util/feed' import { valuesList } from '../db/util' @@ -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,8 +45,9 @@ const handler: AlgoHandler = async ( feedQb = paginate(feedQb, { limit, cursor, keyset }) const feedItems = await feedQb.execute() + return { - feedItems, + feed: feedItems.map(toSkeletonItem), cursor: keyset.packFromResult(feedItems), } } diff --git a/packages/bsky/src/feed-gen/mutuals.ts b/packages/bsky/src/feed-gen/mutuals.ts index c12ef713ada..623b0cfb67e 100644 --- a/packages/bsky/src/feed-gen/mutuals.ts +++ b/packages/bsky/src/feed-gen/mutuals.ts @@ -1,7 +1,7 @@ import { QueryParams as SkeletonParams } from '../lexicon/types/app/bsky/feed/getFeedSkeleton' import AppContext from '../context' import { paginate } from '../db/pagination' -import { AlgoHandler, AlgoResponse } from './types' +import { AlgoHandler, AlgoResponse, toSkeletonItem } from './types' import { FeedKeyset, getFeedDateThreshold } from '../api/app/bsky/util/feed' const handler: AlgoHandler = async ( @@ -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,16 +39,13 @@ 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, + feed: feedItems.map(toSkeletonItem), cursor: keyset.packFromResult(feedItems), } } diff --git a/packages/bsky/src/feed-gen/types.ts b/packages/bsky/src/feed-gen/types.ts index 9670dff6018..9f4c91716d5 100644 --- a/packages/bsky/src/feed-gen/types.ts +++ b/packages/bsky/src/feed-gen/types.ts @@ -1,9 +1,9 @@ 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' export type AlgoResponse = { - feedItems: FeedRow[] + feed: SkeletonFeedPost[] cursor?: string } @@ -14,3 +14,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..bf74bc02e8d 100644 --- a/packages/bsky/src/feed-gen/whats-hot.ts +++ b/packages/bsky/src/feed-gen/whats-hot.ts @@ -1,7 +1,7 @@ import { NotEmptyArray } from '@atproto/common' import { InvalidRequestError } from '@atproto/xrpc-server' import { QueryParams as SkeletonParams } from '../lexicon/types/app/bsky/feed/getFeedSkeleton' -import { AlgoHandler, AlgoResponse } from './types' +import { AlgoHandler, AlgoResponse, toSkeletonItem } from './types' import { GenericKeyset, paginate } from '../db/pagination' import AppContext from '../context' import { valuesList } from '../db/util' @@ -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,20 +47,10 @@ 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', - 'post.replyParent as replyParent', - 'post.replyRoot as replyRoot', - 'post.indexedAt as sortAt', 'candidate.score', 'candidate.cid', ]) @@ -72,7 +61,7 @@ const handler: AlgoHandler = async ( const feedItems = await builder.execute() return { - feedItems, + feed: feedItems.map(toSkeletonItem), cursor: keyset.packFromResult(feedItems), } } diff --git a/packages/bsky/src/services/feed/index.ts b/packages/bsky/src/services/feed/index.ts index dac28bd6f5d..3319cbf7780 100644 --- a/packages/bsky/src/services/feed/index.ts +++ b/packages/bsky/src/services/feed/index.ts @@ -352,13 +352,10 @@ export class FeedService { }, {} 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, skeletonSafe] = await Promise.all([ this.getFeedItems(feedItemUris), diff --git a/packages/bsky/src/services/graph/index.ts b/packages/bsky/src/services/graph/index.ts index dbc7c769c4b..3b3037e612a 100644 --- a/packages/bsky/src/services/graph/index.ts +++ b/packages/bsky/src/services/graph/index.ts @@ -1,10 +1,9 @@ -import { Selectable, WhereInterface, sql } from 'kysely' -import { NotEmptyArray } from '@atproto/common' +import { Selectable, 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 { DbRef, noMatch, valuesList } from '../../db/util' +import { valuesList } from '../../db/util' export class GraphService { constructor(public db: Database, public imgUriBuilder: ImageUriBuilder) {} @@ -69,27 +68,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 @@ -116,27 +94,6 @@ 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']) - } - async getBlocks( requester: string, subjectHandleOrDid: string, @@ -156,28 +113,15 @@ export class GraphService { subjectDid = res.did } - const accnts = [requester, subjectDid] - const blockRes = await this.db.db - .selectFrom('actor_block') - .where('creator', 'in', accnts) - .where('subjectDid', 'in', accnts) - .selectAll() - .execute() - - const blocking = blockRes.some( - (row) => row.creator === requester && row.subjectDid === subjectDid, - ) - const blockedBy = blockRes.some( - (row) => row.creator === subjectDid && row.subjectDid === requester, - ) + const blockSet = await this.getBlockSet([[requester, subjectDid]], false) return { - blocking, - blockedBy, + blocking: blockSet.has([requester, subjectDid]), + blockedBy: blockSet.has([subjectDid, requester]), } } - async getBlockSet(relationships: RelationshipPair[]) { + async getBlockSet(relationships: RelationshipPair[], bidirectional = true) { const { ref } = this.db.db.dynamic const blockSet = new RelationshipSet() if (!relationships.length) return blockSet @@ -195,7 +139,9 @@ export class GraphService { ), ) .execute() - blockRows.forEach((r) => blockSet.add([r.creator, r.subjectDid], true)) + blockRows.forEach((r) => + blockSet.add([r.creator, r.subjectDid], bidirectional), + ) return blockSet } From b194a58eadad5bc9a1d8eeb66961a0ecf32964bb Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Wed, 30 Aug 2023 11:33:02 -0400 Subject: [PATCH 05/49] impl for grabbing block/mute details by did pair --- .../src/api/app/bsky/feed/getFeedSkeleton.ts | 17 +-- packages/bsky/src/services/graph/index.ts | 105 ++++++++++++++++++ 2 files changed, 108 insertions(+), 14 deletions(-) diff --git a/packages/bsky/src/api/app/bsky/feed/getFeedSkeleton.ts b/packages/bsky/src/api/app/bsky/feed/getFeedSkeleton.ts index 9f808b35726..998f787aa3f 100644 --- a/packages/bsky/src/api/app/bsky/feed/getFeedSkeleton.ts +++ b/packages/bsky/src/api/app/bsky/feed/getFeedSkeleton.ts @@ -14,24 +14,13 @@ 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, + feed: result.feed, // @TODO should we proactively filter blocks/mutes from the skeleton, or treat this similar to other cusotm feeds? + cursor: result.cursor, }, } }, diff --git a/packages/bsky/src/services/graph/index.ts b/packages/bsky/src/services/graph/index.ts index 3b3037e612a..45128c678b4 100644 --- a/packages/bsky/src/services/graph/index.ts +++ b/packages/bsky/src/services/graph/index.ts @@ -182,6 +182,48 @@ export class GraphService { return muteSet } + async getBlockAndMuteState(pairs: RelationshipPair[]) { + 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('actor_block') + .whereRef('creator', '=', targetRef) + .whereRef('subjectDid', '=', sourceRef) + .select('uri') + .as('blockedBy'), + 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() + return new BlockAndMuteState(items) + } + async filterBlocksAndMutes( items: T[], opts: { @@ -303,3 +345,66 @@ export class RelationshipSet { return this.index.size === 0 } } + +export class BlockAndMuteState { + blockIdx = new Map>() + muteIdx = new Map>() + muteListIdx = new Map>() + constructor(items: BlockAndMuteInfo[] = []) { + items.forEach((item) => this.add(item)) + } + add(item: BlockAndMuteInfo) { + if (item.blocking) { + const map = this.blockIdx.get(item.source) ?? new Map() + map.set(item.target, item.blocking) + if (!this.blockIdx.has(item.source)) { + this.blockIdx.set(item.source, map) + } + } + if (item.blockedBy) { + const map = this.blockIdx.get(item.target) ?? new Map() + map.set(item.source, item.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) + } + } + } + block(pair: RelationshipPair): boolean { + return !!this.blocking(pair) || !!this.blockedBy(pair) + } + blocking(pair: RelationshipPair): string | null { + return this.blockIdx.get(pair[0])?.get(pair[1]) ?? null + } + 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) + } + muteList(pair: RelationshipPair): string | null { + return this.muteListIdx.get(pair[0])?.get(pair[1]) ?? null + } +} + +type BlockAndMuteInfo = { + source: string + target: string + blocking: string | null + blockedBy: string | null + muting: true | null + mutingViaList: string | null +} From 0dc84133ddd9f6be2622322c1036fd12f27c5987 Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Wed, 30 Aug 2023 14:50:07 -0400 Subject: [PATCH 06/49] refactor getActorInfos away, use actor service --- .../src/api/app/bsky/feed/getFeedGenerator.ts | 3 +- .../api/app/bsky/feed/getFeedGenerators.ts | 3 +- .../src/api/app/bsky/feed/getPostThread.ts | 2 +- .../unspecced/getPopularFeedGenerators.ts | 3 +- packages/bsky/src/services/actor/index.ts | 2 + packages/bsky/src/services/actor/types.ts | 21 +++ packages/bsky/src/services/actor/views.ts | 10 +- packages/bsky/src/services/feed/index.ts | 138 ++---------------- packages/bsky/src/services/feed/types.ts | 21 --- 9 files changed, 45 insertions(+), 158 deletions(-) create mode 100644 packages/bsky/src/services/actor/types.ts diff --git a/packages/bsky/src/api/app/bsky/feed/getFeedGenerator.ts b/packages/bsky/src/api/app/bsky/feed/getFeedGenerator.ts index 2f433e3f0db..1612e19c9b4 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.profiles( [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..861fe719b1e 100644 --- a/packages/bsky/src/api/app/bsky/feed/getFeedGenerators.ts +++ b/packages/bsky/src/api/app/bsky/feed/getFeedGenerators.ts @@ -10,12 +10,13 @@ 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 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 profiles = await actorService.views.profiles(creators, requester) const feedViews = genList.map((gen) => feedService.views.formatFeedGeneratorView(gen, profiles), diff --git a/packages/bsky/src/api/app/bsky/feed/getPostThread.ts b/packages/bsky/src/api/app/bsky/feed/getPostThread.ts index 95bc83e86c1..4fc310fe0ab 100644 --- a/packages/bsky/src/api/app/bsky/feed/getPostThread.ts +++ b/packages/bsky/src/api/app/bsky/feed/getPostThread.ts @@ -51,7 +51,7 @@ export default function (server: Server, ctx: AppContext) { } const relevant = getRelevantIds(threadData) const [actors, posts, labels] = await Promise.all([ - feedService.getActorInfos(Array.from(relevant.dids), requester, { + actorService.views.profiles(Array.from(relevant.dids), requester, { skipLabels: true, }), feedService.getPostInfos(Array.from(relevant.uris), requester), 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/services/actor/index.ts b/packages/bsky/src/services/actor/index.ts index ef27b780a66..82cbc5f2a5f 100644 --- a/packages/bsky/src/services/actor/index.ts +++ b/packages/bsky/src/services/actor/index.ts @@ -7,6 +7,8 @@ import { Actor } from '../../db/tables/actor' import { TimeCidKeyset } from '../../db/pagination' import { LabelCache } from '../../label-cache' +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..33906909979 --- /dev/null +++ b/packages/bsky/src/services/actor/types.ts @@ -0,0 +1,21 @@ +import { Label } from '../../lexicon/types/com/atproto/label/defs' + +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 } diff --git a/packages/bsky/src/services/actor/views.ts b/packages/bsky/src/services/actor/views.ts index ff90a6be2c5..b1983adae29 100644 --- a/packages/bsky/src/services/actor/views.ts +++ b/packages/bsky/src/services/actor/views.ts @@ -13,6 +13,7 @@ import { ImageUriBuilder } from '../../image/uri' import { LabelService, getSelfLabels } from '../label' import { GraphService } from '../graph' import { LabelCache } from '../../label-cache' +import { ActorInfoMap, kSelfLabels } from './types' export class ActorViews { constructor( @@ -185,15 +186,15 @@ export class ActorViews { } async profiles( - results: ActorResult[], + results: (ActorResult | string)[], // @TODO simplify down to just srring[] viewer: string | null, opts?: { skipLabels?: boolean; includeSoftDeleted?: boolean }, - ): Promise> { + ): Promise { if (results.length === 0) return {} const { ref } = this.db.db.dynamic const { skipLabels = false, includeSoftDeleted = false } = opts ?? {} - const dids = results.map((r) => r.did) + const dids = results.map((r) => (typeof r === 'string' ? r : r.did)) const profileInfosQb = this.db.db .selectFrom('actor') @@ -306,10 +307,11 @@ export class ActorViews { } : undefined, labels: skipLabels ? undefined : [...actorLabels, ...selfLabels], + [kSelfLabels]: selfLabels, } acc[cur.did] = profile return acc - }, {} as Record) + }, {} as ActorInfoMap) } async hydrateProfiles( diff --git a/packages/bsky/src/services/feed/index.ts b/packages/bsky/src/services/feed/index.ts index 3319cbf7780..8cfabfcf9d9 100644 --- a/packages/bsky/src/services/feed/index.ts +++ b/packages/bsky/src/services/feed/index.ts @@ -1,7 +1,6 @@ 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 } from '../../db/util' @@ -23,7 +22,6 @@ import { SkeletonFeedPost, } from '../../lexicon/types/app/bsky/feed/defs' import { - ActorInfoMap, PostInfoMap, FeedItemType, FeedRow, @@ -34,10 +32,9 @@ import { PostInfo, RecordEmbedViewRecord, PostBlocksMap, - kSelfLabels, } from './types' -import { LabelService, Labels, getSelfLabels } from '../label' -import { ActorService } from '../actor' +import { LabelService, Labels } from '../label' +import { ActorInfoMap, ActorService } from '../actor' import { GraphService, RelationshipPair } from '../graph' import { FeedViews } from './views' import { LabelCache } from '../../label-cache' @@ -118,127 +115,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, @@ -323,7 +199,9 @@ export class FeedService { const [actors, posts, labels] = await Promise.all([ precomputed?.actors ?? - this.getActorInfos(dids, requester, { skipLabels: true }), + this.services.actor.views.profiles(dids, requester, { + skipLabels: true, + }), precomputed?.posts ?? this.getPostInfos(uris, requester), precomputed?.labels ?? this.services.label.getLabelsForSubjects([...uris, ...dids]), @@ -403,7 +281,7 @@ export class FeedService { } } const [actors, posts, labels] = await Promise.all([ - this.getActorInfos(Array.from(actorDids), viewer, { + this.services.actor.views.profiles(Array.from(actorDids), viewer, { skipLabels: true, }), this.getPostInfos(Array.from(postUris), viewer), @@ -541,7 +419,9 @@ export class FeedService { const [postInfos, actorInfos, labelViews, feedGenInfos, listViews] = await Promise.all([ this.getPostInfos(nestedPostUris, viewer), - this.getActorInfos(nestedDids, viewer, { skipLabels: true }), + this.services.actor.views.profiles(nestedDids, viewer, { + skipLabels: true, + }), this.services.label.getLabelsForSubjects([ ...nestedPostUris, ...nestedDids, diff --git a/packages/bsky/src/services/feed/types.ts b/packages/bsky/src/services/feed/types.ts index f1b5500c62e..2baf1591af9 100644 --- a/packages/bsky/src/services/feed/types.ts +++ b/packages/bsky/src/services/feed/types.ts @@ -14,7 +14,6 @@ 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' @@ -50,26 +49,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?: { From d571bfee07ca6ce54422ebe4dd1398f19e116218 Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Wed, 30 Aug 2023 18:04:11 -0400 Subject: [PATCH 07/49] experiment with moving getFeedGenerators over to a pipeline --- .../api/app/bsky/feed/getFeedGenerators.ts | 79 +++++++++++++++---- packages/bsky/src/pipeline.ts | 24 ++++++ packages/bsky/src/services/actor/views.ts | 2 +- 3 files changed, 88 insertions(+), 17 deletions(-) create mode 100644 packages/bsky/src/pipeline.ts diff --git a/packages/bsky/src/api/app/bsky/feed/getFeedGenerators.ts b/packages/bsky/src/api/app/bsky/feed/getFeedGenerators.ts index 861fe719b1e..b0c769a225d 100644 --- a/packages/bsky/src/api/app/bsky/feed/getFeedGenerators.ts +++ b/packages/bsky/src/api/app/bsky/feed/getFeedGenerators.ts @@ -1,27 +1,31 @@ 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( + () => { + const db = ctx.db.getReplica() + return { + db, + feedService: ctx.services.feed(db), + actorService: ctx.services.actor(db), + } + }, + 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 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 actorService.views.profiles(creators, requester) - - const feedViews = genList.map((gen) => - feedService.views.formatFeedGeneratorView(gen, profiles), - ) - + const viewer = auth.credentials.did + const feedViews = await getFeedGenerators({ feeds, viewer }) return { encoding: 'application/json', body: { @@ -31,3 +35,46 @@ export default function (server: Server, ctx: AppContext) { }, }) } + +async function skeleton(params: Params, ctx: Context) { + const { feedService } = ctx + const genInfos = await feedService.getFeedGeneratorInfos( + params.feeds, + params.viewer, + ) + return { + params, + generators: Object.values(genInfos), + } +} + +async function hydration(state: SkeletonState, ctx: Context) { + const { actorService } = ctx + const profiles = await actorService.views.profiles( + state.generators.map((gen) => gen.creator), + state.params.viewer, + ) + return { + ...state, + profiles, + } +} + +function presentation(state: HydrationState, ctx: Context) { + const { feedService } = ctx + return state.generators.map((gen) => + feedService.views.formatFeedGeneratorView(gen, state.profiles), + ) +} + +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/pipeline.ts b/packages/bsky/src/pipeline.ts new file mode 100644 index 00000000000..d3eef6f918a --- /dev/null +++ b/packages/bsky/src/pipeline.ts @@ -0,0 +1,24 @@ +export function createPipeline< + Params, + Context, + SkeletonState, + HydrationState extends SkeletonState, + View, +>( + context: (params: Params) => 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) => { + const ctx = context(params) + 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/views.ts b/packages/bsky/src/services/actor/views.ts index b1983adae29..cc83c4bb8ea 100644 --- a/packages/bsky/src/services/actor/views.ts +++ b/packages/bsky/src/services/actor/views.ts @@ -186,7 +186,7 @@ export class ActorViews { } async profiles( - results: (ActorResult | string)[], // @TODO simplify down to just srring[] + results: (ActorResult | string)[], // @TODO simplify down to just string[] viewer: string | null, opts?: { skipLabels?: boolean; includeSoftDeleted?: boolean }, ): Promise { From 890c9fc129f554122a6d01d1d09d487a6e317c62 Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Wed, 30 Aug 2023 19:15:02 -0400 Subject: [PATCH 08/49] move getPostThread over to a pipeline --- .../api/app/bsky/feed/getFeedGenerators.ts | 31 ++- .../src/api/app/bsky/feed/getPostThread.ts | 179 +++++++++++------- packages/bsky/src/pipeline.ts | 6 +- 3 files changed, 124 insertions(+), 92 deletions(-) diff --git a/packages/bsky/src/api/app/bsky/feed/getFeedGenerators.ts b/packages/bsky/src/api/app/bsky/feed/getFeedGenerators.ts index b0c769a225d..a8dbd0e514f 100644 --- a/packages/bsky/src/api/app/bsky/feed/getFeedGenerators.ts +++ b/packages/bsky/src/api/app/bsky/feed/getFeedGenerators.ts @@ -7,14 +7,6 @@ import { Database } from '../../../../db' export default function (server: Server, ctx: AppContext) { const getFeedGenerators = createPipeline( - () => { - const db = ctx.db.getReplica() - return { - db, - feedService: ctx.services.feed(db), - actorService: ctx.services.actor(db), - } - }, skeleton, hydration, noRules, @@ -25,18 +17,24 @@ export default function (server: Server, ctx: AppContext) { handler: async ({ params, auth }) => { const { feeds } = params const viewer = auth.credentials.did - const feedViews = await getFeedGenerators({ feeds, viewer }) + const db = ctx.db.getReplica() + const feedService = ctx.services.feed(db) + const actorService = ctx.services.actor(db) + + const view = await getFeedGenerators( + { feeds, viewer }, + { db, feedService, actorService }, + ) + return { encoding: 'application/json', - body: { - feeds: feedViews, - }, + body: view, } }, }) } -async function skeleton(params: Params, ctx: Context) { +const skeleton = async (params: Params, ctx: Context) => { const { feedService } = ctx const genInfos = await feedService.getFeedGeneratorInfos( params.feeds, @@ -48,7 +46,7 @@ async function skeleton(params: Params, ctx: Context) { } } -async function hydration(state: SkeletonState, ctx: Context) { +const hydration = async (state: SkeletonState, ctx: Context) => { const { actorService } = ctx const profiles = await actorService.views.profiles( state.generators.map((gen) => gen.creator), @@ -60,11 +58,12 @@ async function hydration(state: SkeletonState, ctx: Context) { } } -function presentation(state: HydrationState, ctx: Context) { +const presentation = (state: HydrationState, ctx: Context) => { const { feedService } = ctx - return state.generators.map((gen) => + const feeds = state.generators.map((gen) => feedService.views.formatFeedGeneratorView(gen, state.profiles), ) + return { feeds } } type Context = { diff --git a/packages/bsky/src/api/app/bsky/feed/getPostThread.ts b/packages/bsky/src/api/app/bsky/feed/getPostThread.ts index 4fc310fe0ab..0ba914189ac 100644 --- a/packages/bsky/src/api/app/bsky/feed/getPostThread.ts +++ b/packages/bsky/src/api/app/bsky/feed/getPostThread.ts @@ -3,95 +3,118 @@ 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 { LabelService, 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 { 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 { ActorInfoMap, ActorService } from '../../../../services/actor' +import { createPipeline, noRules } from '../../../../pipeline' export default function (server: Server, ctx: AppContext) { + const getPostThread = createPipeline( + skeleton, + hydration, + noRules, + 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 [threadData, repoRev] = await Promise.all([ - getThreadData(ctx, db, uri, depth, parentHeight), - actorService.getRepoRev(requester), + const [result, repoRev] = await Promise.allSettled([ + getPostThread( + { ...params, viewer }, + { db, actorService, feedService, labelService }, + ), + 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([ - actorService.views.profiles(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 { actorService, feedService, labelService } = ctx + const { + threadData, + params: { viewer }, + } = state + const relevant = getRelevantIds(threadData) + const [actors, posts, labels] = await Promise.all([ + actorService.views.profiles(Array.from(relevant.dids), viewer, { + skipLabels: true, + }), + feedService.getPostInfos(Array.from(relevant.uris), viewer), + labelService.getLabelsForSubjects([...relevant.uris, ...relevant.dids]), + ]) + const blocks = await feedService.blocksForPosts(posts) + const embeds = await feedService.embedsForPosts(posts, blocks, viewer) + return { + ...state, + actors, + posts, + labels, + blocks, + embeds, + } +} + +const presentation = (state: HydrationState, ctx: Context) => { + const { params } = state + const thread = composeThread(state.threadData, 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 { actors, posts, embeds, blocks, labels } = state + const post = feedService.views.formatPostView( threadData.post.postUri, actors, @@ -134,30 +157,14 @@ const composeThread = ( notFound: true, } } else { - parent = composeThread( - threadData.parent, - feedService, - posts, - actors, - embeds, - blocks, - labels, - ) + parent = composeThread(threadData.parent, 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, 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 +202,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 +284,32 @@ class ParentNotFoundError extends Error { super(`Parent not found: ${uri}`) } } + +type PostThread = { + post: FeedRow + parent?: PostThread | ParentNotFoundError + replies?: PostThread[] +} + +type Context = { + db: Database + actorService: ActorService + feedService: FeedService + labelService: LabelService +} + +type Params = QueryParams & { viewer: string | null } + +type SkeletonState = { + params: Params + threadData: PostThread +} + +type HydrationState = SkeletonState & { + threadData: PostThread + posts: PostInfoMap + actors: ActorInfoMap + embeds: PostEmbedViews + blocks: PostBlocksMap + labels: Labels +} diff --git a/packages/bsky/src/pipeline.ts b/packages/bsky/src/pipeline.ts index d3eef6f918a..7798519bfa2 100644 --- a/packages/bsky/src/pipeline.ts +++ b/packages/bsky/src/pipeline.ts @@ -1,18 +1,16 @@ export function createPipeline< Params, - Context, SkeletonState, HydrationState extends SkeletonState, View, + Context, >( - context: (params: Params) => 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) => { - const ctx = context(params) + return async (params: Params, ctx: Context) => { const skeletonState = await skeleton(params, ctx) const hydrationState = await hydration(skeletonState, ctx) return presentation(rules(hydrationState, ctx), ctx) From 57fbaa298754505b782e2baca8c9a15c72fa15d7 Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Thu, 31 Aug 2023 01:08:14 -0400 Subject: [PATCH 09/49] move feeds over to pipelines --- .../src/api/app/bsky/feed/getActorLikes.ts | 151 +++++++---- .../src/api/app/bsky/feed/getAuthorFeed.ts | 234 +++++++++++------- .../bsky/src/api/app/bsky/feed/getFeed.ts | 143 ++++++++--- .../bsky/src/api/app/bsky/feed/getTimeline.ts | 120 ++++++--- packages/bsky/src/services/feed/index.ts | 81 ++---- packages/bsky/src/services/feed/types.ts | 12 + packages/bsky/src/services/feed/views.ts | 3 +- 7 files changed, 485 insertions(+), 259 deletions(-) diff --git a/packages/bsky/src/api/app/bsky/feed/getActorLikes.ts b/packages/bsky/src/api/app/bsky/feed/getActorLikes.ts index 42769bb1a79..1046e804db7 100644 --- a/packages/bsky/src/api/app/bsky/feed/getActorLikes.ts +++ b/packages/bsky/src/api/app/bsky/feed/getActorLikes.ts @@ -1,73 +1,132 @@ +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 - const keyset = new FeedKeyset( - ref('feed_item.sortAt'), - ref('feed_item.cid'), - ) + const actorRes = await actorService.getActor(actor) + if (!actorRes) { + throw new InvalidRequestError('Profile not found') + } + const actorDid = actorRes.did - feedItemsQb = paginate(feedItemsQb, { - limit, - cursor, - keyset, - }) + if (!viewer || viewer !== actorDid) { + throw new InvalidRequestError('Profile not found') + } - const [feedItems, repoRev] = await Promise.all([ - feedItemsQb.execute(), - actorService.getRepoRev(viewer), - ]) - setRepoRev(res, repoRev) + let feedItemsQb = feedService + .selectFeedItemQb() + .innerJoin('like', 'like.subject', 'feed_item.uri') + .where('like.creator', '=', actorDid) - const feedItemsSafe = await graphService.filterBlocksAndMutes(feedItems, { - getBlockPairs(item) { - if (viewer) { - return [[viewer, item.postAuthorDid]] - } - }, - }) + const keyset = new FeedKeyset(ref('feed_item.sortAt'), ref('feed_item.cid')) - const feed = await feedService.hydrateFeed(feedItemsSafe, 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 } = state + const feed = feedService.views.formatFeed( + feedItems, + state.actors, + state.posts, + state.embeds, + state.labels, + state.blocks, + ) + 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 8aca55e2321..5fbb7a6dd69 100644 --- a/packages/bsky/src/api/app/bsky/feed/getAuthorFeed.ts +++ b/packages/bsky/src/api/app/bsky/feed/getAuthorFeed.ts @@ -1,112 +1,172 @@ +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'), - ) - } - - 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 feedItemsSafe = await graphService.filterBlocksAndMutes(feedItems, { - getBlockPairs(item) { - if (viewer) { - return [[viewer, item.postAuthorDid]] - } - }, - getMutePairs(item) { - // Hide reposts of muted content - if (viewer && item.type === 'repost') { - return [[viewer, item.postAuthorDid]] - } - }, - }) - - const feed = await feedService.hydrateFeed(feedItemsSafe, viewer) + setRepoRev(res, repoRev) return { encoding: 'application/json', - body: { - feed, - cursor: keyset.packFromResult(feedItems), - }, + body: result, } }, }) } + +export const skeleton = async (params: Params, ctx: Context) => { + const { cursor, limit, actor, filter, viewer } = params + const { db, actorService, feedService, graphService } = ctx + const { ref } = db.db.dynamic + + // first verify there is not a block between requester & subject + if (viewer !== null) { + const blocks = await graphService.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', + ) + } + } + + // 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'), + ) + } + + 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 } = state + const feed = feedService.views.formatFeed( + feedItems, + state.actors, + state.posts, + state.embeds, + state.labels, + state.blocks, + ) + 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 1f300791ba4..633f39209a6 100644 --- a/packages/bsky/src/api/app/bsky/feed/getFeed.ts +++ b/packages/bsky/src/api/app/bsky/feed/getFeed.ts @@ -13,48 +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 skeleton = - 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 cleanedFeed = await ctx.services - .feed(db) - .cleanFeedSkeleton(skeleton.feed, viewer) - const hydrated = await feedService.hydrateFeed(cleanedFeed, 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: { - ...skeleton, - feed: hydrated, - }, + body: result, headers: { 'server-timing': serverTimingHeader([timerSkele, timerHydr]), }, @@ -63,13 +60,99 @@ export default function (server: Server, ctx: AppContext) { }) } -async function skeletonFromFeedGen( +const skeleton = async (params: Params, ctx: Context) => { + const timerSkele = new ServerTimer('skele').start() + const { db } = ctx + const { feed } = params + const localAlgo = ctx.appCtx.algos[feed] + const skeleton = + localAlgo !== undefined + ? await localAlgo(ctx.appCtx, params, params.viewer) + : await skeletonFromFeedGen( + ctx.appCtx, + db, + params, + params.viewer, + ctx.authorization, + ) + return { + params, + cursor: skeleton.cursor, + feedSkele: skeleton.feed, + timerSkele: timerSkele.stop(), + } +} + +const hydration = async (state: SkeletonState, ctx: Context) => { + const timerHydr = new ServerTimer('hydr').start() + const { feedService } = ctx + const { params, feedSkele } = state + const feedItems = await feedService.cleanFeedSkeleton(feedSkele) + const refs = feedService.feedItemRefs(feedItems) + const hydrated = await feedService.feedHydration({ + ...refs, + viewer: params.viewer, + }) + return { ...state, ...hydrated, feedItems, 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 } = state + const feed = feedService.views.formatFeed( + feedItems, + state.actors, + state.posts, + state.embeds, + state.labels, + state.blocks, + ) + return { + feed, + cursor, + timerSkele: state.timerSkele, + timerHydr: state.timerHydr, + } +} + +type Context = { + db: Database + feedService: FeedService + appCtx: AppContext + authorization?: string +} + +type Params = GetFeedParams & { viewer: string } + +type SkeletonState = { + params: Params + feedSkele: SkeletonFeedPost[] + cursor?: string + timerSkele: ServerTimer +} + +type HydrationState = SkeletonState & + FeedHydrationState & { feedItems: FeedRow[]; timerHydr: ServerTimer } + +const skeletonFromFeedGen = async ( ctx: AppContext, db: Database, params: GetFeedParams, viewer: string, authorization?: string, -): Promise { +): Promise => { const { feed } = params // Resolve and fetch feed skeleton const found = await db.db diff --git a/packages/bsky/src/api/app/bsky/feed/getTimeline.ts b/packages/bsky/src/api/app/bsky/feed/getTimeline.ts index 3e083c1c955..4b5a832c779 100644 --- a/packages/bsky/src/api/app/bsky/feed/getTimeline.ts +++ b/packages/bsky/src/api/app/bsky/feed/getTimeline.ts @@ -4,53 +4,54 @@ 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, - 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) => { + 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 @@ -98,26 +99,69 @@ 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) => { + 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) => { + 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 } = state + const feed = feedService.views.formatFeed( + feedItems, + state.actors, + state.posts, + state.embeds, + state.labels, + state.blocks, + ) + 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/services/feed/index.ts b/packages/bsky/src/services/feed/index.ts index 8cfabfcf9d9..6222335c1c6 100644 --- a/packages/bsky/src/services/feed/index.ts +++ b/packages/bsky/src/services/feed/index.ts @@ -17,10 +17,7 @@ import { isViewRecord, } 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 { SkeletonFeedPost } from '../../lexicon/types/app/bsky/feed/defs' import { PostInfoMap, FeedItemType, @@ -32,6 +29,7 @@ import { PostInfo, RecordEmbedViewRecord, PostBlocksMap, + FeedHydrationState, } from './types' import { LabelService, Labels } from '../label' import { ActorInfoMap, ActorService } from '../actor' @@ -230,25 +228,11 @@ export class FeedService { }, {} as Record) } - async cleanFeedSkeleton( - skeleton: SkeletonFeedPost[], - requester: string, - ): Promise { + async cleanFeedSkeleton(skeleton: SkeletonFeedPost[]): Promise { const feedItemUris = skeleton.map(getSkeleFeedItemUri) - const [feedItems, skeletonSafe] = await Promise.all([ - this.getFeedItems(feedItemUris), - this.services.graph.filterBlocksAndMutes(skeleton, { - getBlockPairs(item) { - return getPostAndRepostPairs(item, requester) - }, - getMutePairs(item) { - // Hide posts and reposts of or by muted actors - return getPostAndRepostPairs(item, requester) - }, - }), - ]) + const feedItems = await this.getFeedItems(feedItemUris) const cleaned: FeedRow[] = [] - for (const skeleItem of skeletonSafe) { + for (const skeleItem of skeleton) { const feedItem = feedItems[getSkeleFeedItemUri(skeleItem)] if (feedItem && feedItem.postUri === skeleItem.post) { cleaned.push(feedItem) @@ -257,20 +241,13 @@ export class FeedService { 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) @@ -280,25 +257,28 @@ export class FeedService { actorDids.add(new AtUri(item.replyRoot).hostname) } } - const [actors, posts, labels] = await Promise.all([ - this.services.actor.views.profiles(Array.from(actorDids), viewer, { + return { dids: actorDids, uris: postUris } + } + + async feedHydration(refs: { + dids: Set + uris: Set + viewer: string | null + }): Promise { + const { viewer, dids, uris } = refs + const [posts, actors, labels, bam] = await Promise.all([ + this.getPostInfos(Array.from(uris), viewer), + this.services.actor.views.profiles(Array.from(dids), viewer, { skipLabels: true, }), - this.getPostInfos(Array.from(postUris), viewer), - this.services.label.getLabelsForSubjects([...postUris, ...actorDids]), + this.services.label.getLabelsForSubjects([...uris, ...dids]), + this.services.graph.getBlockAndMuteState( + viewer ? [...dids].map((did) => [viewer, did]) : [], + ), ]) const blocks = await this.blocksForPosts(posts) const embeds = await this.embedsForPosts(posts, blocks, viewer) - - return this.views.formatFeed( - items, - actors, - posts, - embeds, - labels, - blocks, - usePostViewUnion, - ) + return { posts, actors, labels, bam, blocks, embeds } } // applies blocks for visibility to third-parties (i.e. based on post content) @@ -536,14 +516,3 @@ function getSkeleFeedItemUri(item: SkeletonFeedPost) { ? item.reason.repost : item.post } - -function getPostAndRepostPairs( - item: SkeletonFeedPost, - requester: string, -): RelationshipPair[] { - const uriStrs = - typeof item.reason?.repost === 'string' - ? [item.post, item.reason.repost] - : [item.post] - return uriStrs.map((uriStr) => [requester, new AtUri(uriStr).host]) -} diff --git a/packages/bsky/src/services/feed/types.ts b/packages/bsky/src/services/feed/types.ts index 2baf1591af9..1fe4eeffe1c 100644 --- a/packages/bsky/src/services/feed/types.ts +++ b/packages/bsky/src/services/feed/types.ts @@ -16,6 +16,9 @@ import { } from '../../lexicon/types/app/bsky/feed/defs' import { FeedGenerator } from '../../db/tables/feed-generator' import { ListView } from '../../lexicon/types/app/bsky/graph/defs' +import { ActorInfoMap } from '../actor' +import { Labels } from '../label' +import { BlockAndMuteState } from '../graph' export type PostEmbedViews = { [uri: string]: PostEmbedView @@ -82,3 +85,12 @@ export type RecordEmbedViewRecord = | ListView export type RecordEmbedViewRecordMap = { [uri: string]: RecordEmbedViewRecord } + +export type FeedHydrationState = { + actors: ActorInfoMap + 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 c040f96522c..b2dab7a0b61 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,10 +28,10 @@ import { PostInfoMap, RecordEmbedViewRecord, PostBlocksMap, - kSelfLabels, } from './types' import { Labels, getSelfLabels } from '../label' import { ImageUriBuilder } from '../../image/uri' +import { ActorInfoMap, kSelfLabels } from '../actor' export class FeedViews { constructor(public db: Database, public imgUriBuilder: ImageUriBuilder) {} From 6712b2e57ab37f420184a105095a5060decb127b Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Thu, 31 Aug 2023 11:20:16 -0400 Subject: [PATCH 10/49] move suggestions and likes over to pipelines --- .../src/api/app/bsky/actor/getSuggestions.ts | 159 +++++++++++------ .../bsky/src/api/app/bsky/feed/getLikes.ts | 163 ++++++++++++------ 2 files changed, 210 insertions(+), 112 deletions(-) diff --git a/packages/bsky/src/api/app/bsky/actor/getSuggestions.ts b/packages/bsky/src/api/app/bsky/actor/getSuggestions.ts index a19cdd0a7a9..51ca7b46e27 100644 --- a/packages/bsky/src/api/app/bsky/actor/getSuggestions.ts +++ b/packages/bsky/src/api/app/bsky/actor/getSuggestions.ts @@ -1,75 +1,124 @@ +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, + noBlocks, + 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')), - ) - .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 suggestionsSafe = await graphService.filterBlocksAndMutes( - suggestionsRes, - { - getBlockPairs(item) { - if (viewer) { - return [[viewer, item.did]] - } - }, - }, + const result = await getSuggestions( + { ...params, viewer }, + { db, actorService, graphService }, ) return { encoding: 'application/json', - body: { - cursor: suggestionsRes.at(-1)?.did, - actors: await actorService.views.hydrateProfiles( - suggestionsSafe, - 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 noBlocks = (state: HydrationState) => { + const { viewer } = state.params + if (!viewer) return state + state.suggestions = state.suggestions.filter( + (item) => !state.bam.block([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/feed/getLikes.ts b/packages/bsky/src/api/app/bsky/feed/getLikes.ts index 8f8fb986a4f..893617f6bb0 100644 --- a/packages/bsky/src/api/app/bsky/feed/getLikes.ts +++ b/packages/bsky/src/api/app/bsky/feed/getLikes.ts @@ -1,78 +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'))) - .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 likesSafe = await graphService.filterBlocksAndMutes(likesRes, { - getBlockPairs: (like) => { - if (requester) { - return [[requester, like.did]] - } - }, - }) - - const actors = await ctx.services - .actor(db) - .views.profiles(likesSafe, requester) - - const likes = mapDefined(likesSafe, (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 +} From b550d1dfe22305f71e905ba53c6dd1180f37850b Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Thu, 31 Aug 2023 13:29:35 -0400 Subject: [PATCH 11/49] move reposted-by, follows, followers over to pipelines, tidy author feed and post thread --- .../src/api/app/bsky/feed/getAuthorFeed.ts | 23 +- .../src/api/app/bsky/feed/getPostThread.ts | 53 +--- .../src/api/app/bsky/feed/getRepostedBy.ts | 140 ++++++--- .../src/api/app/bsky/graph/getFollowers.ts | 186 ++++++++---- .../bsky/src/api/app/bsky/graph/getFollows.ts | 186 ++++++++---- .../bsky/notification/listNotifications.ts | 287 +++++++++++------- 6 files changed, 544 insertions(+), 331 deletions(-) diff --git a/packages/bsky/src/api/app/bsky/feed/getAuthorFeed.ts b/packages/bsky/src/api/app/bsky/feed/getAuthorFeed.ts index 5fbb7a6dd69..6e5d740bd2e 100644 --- a/packages/bsky/src/api/app/bsky/feed/getAuthorFeed.ts +++ b/packages/bsky/src/api/app/bsky/feed/getAuthorFeed.ts @@ -55,15 +55,23 @@ export const skeleton = async (params: Params, ctx: Context) => { const { db, actorService, feedService, graphService } = ctx const { ref } = db.db.dynamic - // first verify there is not a block between requester & subject + // 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.getBlocks(viewer, actor) - if (blocks.blocking) { + const bam = await graphService.getBlockAndMuteState([[viewer, actorDid]]) + if (bam.blocking([viewer, actorDid])) { throw new InvalidRequestError( `Requester has blocked actor: ${actor}`, 'BlockedActor', ) - } else if (blocks.blockedBy) { + } + if (bam.blockedBy([viewer, actorDid])) { throw new InvalidRequestError( `Requester is blocked by actor: $${actor}`, 'BlockedByActor', @@ -71,13 +79,6 @@ export const skeleton = async (params: Params, ctx: Context) => { } } - // 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() diff --git a/packages/bsky/src/api/app/bsky/feed/getPostThread.ts b/packages/bsky/src/api/app/bsky/feed/getPostThread.ts index 0ba914189ac..512d473bec9 100644 --- a/packages/bsky/src/api/app/bsky/feed/getPostThread.ts +++ b/packages/bsky/src/api/app/bsky/feed/getPostThread.ts @@ -1,13 +1,5 @@ import { InvalidRequestError } from '@atproto/xrpc-server' import { Server } from '../../../../lexicon' -import AppContext from '../../../../context' -import { - FeedRow, - PostEmbedViews, - PostBlocksMap, -} from '../../../../services/feed/types' -import { FeedService, PostInfoMap } from '../../../../services/feed' -import { LabelService, Labels } from '../../../../services/label' import { BlockedPost, NotFoundPost, @@ -15,13 +7,18 @@ import { 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' -import { ActorInfoMap, ActorService } from '../../../../services/actor' import { createPipeline, noRules } from '../../../../pipeline' export default function (server: Server, ctx: AppContext) { @@ -38,13 +35,9 @@ export default function (server: Server, ctx: AppContext) { 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 [result, repoRev] = await Promise.allSettled([ - getPostThread( - { ...params, viewer }, - { db, actorService, feedService, labelService }, - ), + getPostThread({ ...params, viewer }, { db, feedService }), actorService.getRepoRev(viewer), ]) @@ -72,29 +65,14 @@ const skeleton = async (params: Params, ctx: Context) => { } const hydration = async (state: SkeletonState, ctx: Context) => { - const { actorService, feedService, labelService } = ctx + const { feedService } = ctx const { threadData, params: { viewer }, } = state const relevant = getRelevantIds(threadData) - const [actors, posts, labels] = await Promise.all([ - actorService.views.profiles(Array.from(relevant.dids), viewer, { - skipLabels: true, - }), - feedService.getPostInfos(Array.from(relevant.uris), viewer), - labelService.getLabelsForSubjects([...relevant.uris, ...relevant.dids]), - ]) - const blocks = await feedService.blocksForPosts(posts) - const embeds = await feedService.embedsForPosts(posts, blocks, viewer) - return { - ...state, - actors, - posts, - labels, - blocks, - embeds, - } + const hydrated = await feedService.feedHydration({ ...relevant, viewer }) + return { ...state, ...hydrated } } const presentation = (state: HydrationState, ctx: Context) => { @@ -293,9 +271,7 @@ type PostThread = { type Context = { db: Database - actorService: ActorService feedService: FeedService - labelService: LabelService } type Params = QueryParams & { viewer: string | null } @@ -305,11 +281,4 @@ type SkeletonState = { threadData: PostThread } -type HydrationState = SkeletonState & { - threadData: PostThread - posts: PostInfoMap - actors: ActorInfoMap - embeds: PostEmbedViews - blocks: PostBlocksMap - labels: Labels -} +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 1939507d02c..5ca5c452b63 100644 --- a/packages/bsky/src/api/app/bsky/feed/getRepostedBy.ts +++ b/packages/bsky/src/api/app/bsky/feed/getRepostedBy.ts @@ -1,62 +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'))) - .selectAll('creator') - .select(['repost.cid as cid', 'repost.sortAt as sortAt']) - - if (cid) { - builder = builder.where('repost.subjectCid', '=', cid) - } + const viewer = auth.credentials.did - const keyset = new TimeCidKeyset(ref('repost.sortAt'), ref('repost.cid')) - builder = paginate(builder, { - limit, - cursor, - keyset, - }) - - const repostedByRes = await builder.execute() - const repostedBySafe = await graphService.filterBlocksAndMutes( - repostedByRes, - { - getBlockPairs(item) { - if (requester) { - return [[requester, item.did]] - } - }, - }, + const result = await getRepostedBy( + { ...params, viewer }, + { db, actorService, graphService }, ) - const repostedBy = await ctx.services - .actor(db) - .views.hydrateProfiles(repostedBySafe, requester) - 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/graph/getFollowers.ts b/packages/bsky/src/api/app/bsky/graph/getFollowers.ts index 0a95a2f826f..1382c1f87c7 100644 --- a/packages/bsky/src/api/app/bsky/graph/getFollowers.ts +++ b/packages/bsky/src/api/app/bsky/graph/getFollowers.ts @@ -1,84 +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}`) + + return { + encoding: 'application/json', + body: result, } + }, + }) +} - 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'))), - ) - .selectAll('creator') - .select(['follow.cid as cid', 'follow.sortAt as sortAt']) +const skeleton = async ( + params: Params, + ctx: Context, +): Promise => { + const { db, actorService } = ctx + const { limit, cursor, actor, canViewTakendownProfile } = params + const { ref } = db.db.dynamic - const keyset = new TimeCidKeyset(ref('follow.sortAt'), ref('follow.cid')) - followersReq = paginate(followersReq, { - limit, - cursor, - keyset, - }) + const subject = await actorService.getActor(actor, canViewTakendownProfile) + if (!subject) { + throw new InvalidRequestError(`Actor not found: ${actor}`) + } - const followersRes = await followersReq.execute() - const followersSafe = await graphService.filterBlocksAndMutes( - followersRes, - { - getBlockPairs(item) { - return requester - ? [ - [requester, item.did], - [subjectRes.did, item.did], - ] - : [[subjectRes.did, item.did]] - }, - }, - ) + 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 [followers, subject] = await Promise.all([ - actorService.views.hydrateProfiles(followersSafe, requester, { - includeSoftDeleted: canViewTakendownProfile, - }), - actorService.views.profile(subjectRes, requester, { - includeSoftDeleted: canViewTakendownProfile, - }), - ]) + const keyset = new TimeCidKeyset(ref('follow.sortAt'), ref('follow.cid')) + followersReq = paginate(followersReq, { + limit, + cursor, + keyset, + }) - if (!subject) { - throw new InvalidRequestError(`Actor not found: ${actor}`) - } + const followers = await followersReq.execute() + return { + params, + followers, + subject, + cursor: keyset.packFromResult(followers), + } +} - return { - encoding: 'application/json', - body: { - subject, - followers, - cursor: keyset.packFromResult(followersRes), - }, - } - }, - }) +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 2ac1762fb01..34b5d72a605 100644 --- a/packages/bsky/src/api/app/bsky/graph/getFollows.ts +++ b/packages/bsky/src/api/app/bsky/graph/getFollows.ts @@ -1,81 +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'))), - ) - .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 followsSafe = await graphService.filterBlocksAndMutes(followsRes, { - getBlockPairs(item) { - return requester - ? [ - [requester, item.did], - [creatorRes.did, item.did], - ] - : [[creatorRes.did, item.did]] - }, - }) - - const [follows, subject] = await Promise.all([ - actorService.views.hydrateProfiles(followsSafe, 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/notification/listNotifications.ts b/packages/bsky/src/api/app/bsky/notification/listNotifications.ts index 6a37838b8c8..7bcc88f12d3 100644 --- a/packages/bsky/src/api/app/bsky/notification/listNotifications.ts +++ b/packages/bsky/src/api/app/bsky/notification/listNotifications.ts @@ -2,139 +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((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 [notifsSafe, authors, labels] = await Promise.all([ - graphService.filterBlocksAndMutes(notifs, { - getBlockPairs(item) { - return [[requester, item.authorDid]] - }, - getMutePairs(item) { - return [[requester, item.authorDid]] - }, - }), - 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(notifsSafe, (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 } From 4f4580c65e51bfabdbdc60e83a5c426c038df95e Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Thu, 31 Aug 2023 13:29:48 -0400 Subject: [PATCH 12/49] remove old block/mute checks --- packages/bsky/src/services/feed/index.ts | 7 +- packages/bsky/src/services/graph/index.ts | 176 ++++------------------ 2 files changed, 33 insertions(+), 150 deletions(-) diff --git a/packages/bsky/src/services/feed/index.ts b/packages/bsky/src/services/feed/index.ts index 6222335c1c6..c715e826fd2 100644 --- a/packages/bsky/src/services/feed/index.ts +++ b/packages/bsky/src/services/feed/index.ts @@ -309,15 +309,14 @@ export class FeedService { } } // compute block state from all actor relationships among posts - const blockSet = await this.services.graph.getBlockSet(relationships) - if (blockSet.empty()) return {} + const blockState = await this.services.graph.getBlockState(relationships) 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 } diff --git a/packages/bsky/src/services/graph/index.ts b/packages/bsky/src/services/graph/index.ts index 45128c678b4..cd3e6ccf09c 100644 --- a/packages/bsky/src/services/graph/index.ts +++ b/packages/bsky/src/services/graph/index.ts @@ -94,94 +94,6 @@ export class GraphService { .select(['list_item.cid as cid', 'list_item.sortAt as sortAt']) } - 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 blockSet = await this.getBlockSet([[requester, subjectDid]], false) - - return { - blocking: blockSet.has([requester, subjectDid]), - blockedBy: blockSet.has([subjectDid, requester]), - } - } - - async getBlockSet(relationships: RelationshipPair[], bidirectional = true) { - 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, true)) - // 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], bidirectional), - ) - return blockSet - } - - async getMuteSet(relationships: RelationshipPair[]) { - const { ref } = this.db.db.dynamic - const muteSet = new RelationshipSet() - if (!relationships.length) return muteSet - const relationshipSet = new RelationshipSet() - relationships.forEach((pair) => relationshipSet.add(pair)) - // compute actual mute set from all actor relationships - const muteRows = await this.db.db - .selectFrom('mute') - .select(['mutedByDid', 'subjectDid']) - .where( - sql`(${ref('mutedByDid')}, ${ref('subjectDid')})`, - 'in', - valuesList( - relationshipSet.listAllPairs().map(([a, b]) => sql`${a}, ${b}`), - ), - ) - .unionAll( - this.db.db - .selectFrom('list_item') - .innerJoin('list_mute', 'list_mute.listUri', 'list_item.listUri') - .where( - sql`(${ref('list_mute.mutedByDid')}, ${ref( - 'list_item.subjectDid', - )})`, - 'in', - valuesList( - relationshipSet.listAllPairs().map(([a, b]) => sql`${a}, ${b}`), - ), - ) - .select(['list_mute.mutedByDid', 'list_item.subjectDid']), - ) - .execute() - muteRows.forEach((r) => muteSet.add([r.mutedByDid, r.subjectDid])) - return muteSet - } - async getBlockAndMuteState(pairs: RelationshipPair[]) { const { ref } = this.db.db.dynamic const sourceRef = ref('pair.source') @@ -224,31 +136,32 @@ export class GraphService { return new BlockAndMuteState(items) } - async filterBlocksAndMutes( - items: T[], - opts: { - getBlockPairs?: (item: T) => RelationshipPair[] | undefined - getMutePairs?: (item: T) => RelationshipPair[] | undefined - }, - ) { - const blockPairsPerItem = items.map( - (item) => opts.getBlockPairs?.(item) ?? [], - ) - const mutePairsPerItem = items.map( - (item) => opts.getMutePairs?.(item) ?? [], - ) - const [blockSet, muteSet] = await Promise.all([ - this.getBlockSet(blockPairsPerItem.flat()), - this.getMuteSet(mutePairsPerItem.flat()), - ]) - return items.filter((_, i) => { - const blockPairs = blockPairsPerItem[i] - const mutePairs = mutePairsPerItem[i] - return ( - blockPairs.every((pair) => !blockSet.has(pair)) && - mutePairs.every((pair) => !muteSet.has(pair)) - ) - }) + async getBlockState(pairs: RelationshipPair[]) { + 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('actor_block') + .whereRef('creator', '=', targetRef) + .whereRef('subjectDid', '=', sourceRef) + .select('uri') + .as('blockedBy'), + ]) + .selectAll() + .execute() + return new BlockAndMuteState(items) } async getListViews(listUris: string[], requester: string | null) { @@ -317,35 +230,6 @@ type ListInfo = Selectable & { export type RelationshipPair = [didA: string, didB: string] -export class RelationshipSet { - index = new Map>() - add([didA, didB]: RelationshipPair, bididrectional = false) { - const didAIdx = this.index.get(didA) ?? new Set() - if (!this.index.has(didA)) this.index.set(didA, didAIdx) - didAIdx.add(didB) - if (bididrectional) { - const didBIdx = this.index.get(didB) ?? new Set() - if (!this.index.has(didB)) this.index.set(didB, didBIdx) - 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 - } -} - export class BlockAndMuteState { blockIdx = new Map>() muteIdx = new Map>() @@ -403,8 +287,8 @@ export class BlockAndMuteState { type BlockAndMuteInfo = { source: string target: string - blocking: string | null - blockedBy: string | null - muting: true | null - mutingViaList: string | null + blocking?: string | null + blockedBy?: string | null + muting?: true | null + mutingViaList?: string | null } From 9801663b806401e25c55dad87fc1cf2796369ddf Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Thu, 31 Aug 2023 16:40:09 -0400 Subject: [PATCH 13/49] unify post presentation logic --- .../src/api/app/bsky/feed/getAuthorFeed.ts | 6 +- .../bsky/src/api/app/bsky/feed/getFeed.ts | 25 +++++- .../bsky/src/api/app/bsky/feed/getPosts.ts | 90 ++++++++++++++----- packages/bsky/src/services/feed/index.ts | 63 +------------ packages/bsky/src/services/feed/types.ts | 2 - 5 files changed, 98 insertions(+), 88 deletions(-) diff --git a/packages/bsky/src/api/app/bsky/feed/getAuthorFeed.ts b/packages/bsky/src/api/app/bsky/feed/getAuthorFeed.ts index 6e5d740bd2e..b15733c4a67 100644 --- a/packages/bsky/src/api/app/bsky/feed/getAuthorFeed.ts +++ b/packages/bsky/src/api/app/bsky/feed/getAuthorFeed.ts @@ -64,14 +64,14 @@ export const skeleton = async (params: Params, ctx: Context) => { // verify there is not a block between requester & subject if (viewer !== null) { - const bam = await graphService.getBlockAndMuteState([[viewer, actorDid]]) - if (bam.blocking([viewer, actorDid])) { + const blocks = await graphService.getBlockState([[viewer, actorDid]]) + if (blocks.blocking([viewer, actorDid])) { throw new InvalidRequestError( `Requester has blocked actor: ${actor}`, 'BlockedActor', ) } - if (bam.blockedBy([viewer, actorDid])) { + if (blocks.blockedBy([viewer, actorDid])) { throw new InvalidRequestError( `Requester is blocked by actor: $${actor}`, 'BlockedByActor', diff --git a/packages/bsky/src/api/app/bsky/feed/getFeed.ts b/packages/bsky/src/api/app/bsky/feed/getFeed.ts index 633f39209a6..01d6a5364c5 100644 --- a/packages/bsky/src/api/app/bsky/feed/getFeed.ts +++ b/packages/bsky/src/api/app/bsky/feed/getFeed.ts @@ -87,7 +87,7 @@ const hydration = async (state: SkeletonState, ctx: Context) => { const timerHydr = new ServerTimer('hydr').start() const { feedService } = ctx const { params, feedSkele } = state - const feedItems = await feedService.cleanFeedSkeleton(feedSkele) + const feedItems = await cleanFeedSkeleton(feedSkele, ctx) const refs = feedService.feedItemRefs(feedItems) const hydrated = await feedService.feedHydration({ ...refs, @@ -220,3 +220,26 @@ const skeletonFromFeedGen = async ( feed: skeleton.feed.slice(0, params.limit), // enforce limit } } + +const cleanFeedSkeleton = async ( + skeleton: SkeletonFeedPost[], + ctx: Context, +): Promise => { + const { feedService } = ctx + const feedItemUris = skeleton.map(getSkeleFeedItemUri) + const feedItems = await feedService.getFeedItems(feedItemUris) + const cleaned: FeedRow[] = [] + for (const skeleItem of skeleton) { + const feedItem = feedItems[getSkeleFeedItemUri(skeleItem)] + if (feedItem && feedItem.postUri === skeleItem.post) { + cleaned.push(feedItem) + } + } + return cleaned +} + +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/getPosts.ts b/packages/bsky/src/api/app/bsky/feed/getPosts.ts index 4b092a5e717..55c941eca5a 100644 --- a/packages/bsky/src/api/app/bsky/feed/getPosts.ts +++ b/packages/bsky/src/api/app/bsky/feed/getPosts.ts @@ -1,36 +1,84 @@ -import * as common 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' 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 viewer = auth.credentials.did + + const results = await getPosts({ ...params, viewer }, { db, feedService }) return { encoding: 'application/json', - body: { posts }, + body: results, } }, }) } + +const skeleton = async (params: Params) => { + return { params, postUris: 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 } = ctx + const { postUris } = state + const SKIP = [] + const postViews = postUris.flatMap((uri) => { + const postView = feedService.views.formatPostView( + uri, + state.actors, + state.posts, + state.embeds, + state.labels, + ) + return postView ?? SKIP + }) + return { posts: postViews } +} + +type Context = { + db: Database + feedService: FeedService +} + +type Params = QueryParams & { viewer: string | null } + +type SkeletonState = { + params: Params + postUris: string[] +} + +type HydrationState = SkeletonState & FeedHydrationState diff --git a/packages/bsky/src/services/feed/index.ts b/packages/bsky/src/services/feed/index.ts index c715e826fd2..f2d9622b09a 100644 --- a/packages/bsky/src/services/feed/index.ts +++ b/packages/bsky/src/services/feed/index.ts @@ -1,6 +1,5 @@ import { sql } from 'kysely' import { AtUri } from '@atproto/syntax' -import { dedupeStrs } from '@atproto/common' import { jsonStringToLex } from '@atproto/lexicon' import { Database } from '../../db' import { countAll, noMatch, notSoftDeletedClause } from '../../db/util' @@ -17,13 +16,11 @@ import { isViewRecord, } from '../../lexicon/types/app/bsky/embed/record' import { isMain as isEmbedRecordWithMedia } from '../../lexicon/types/app/bsky/embed/recordWithMedia' -import { SkeletonFeedPost } from '../../lexicon/types/app/bsky/feed/defs' import { PostInfoMap, FeedItemType, FeedRow, FeedGenInfoMap, - PostViews, PostEmbedViews, RecordEmbedViewRecordMap, PostInfo, @@ -31,8 +28,8 @@ import { PostBlocksMap, FeedHydrationState, } from './types' -import { LabelService, Labels } from '../label' -import { ActorInfoMap, ActorService } from '../actor' +import { LabelService } from '../label' +import { ActorService } from '../actor' import { GraphService, RelationshipPair } from '../graph' import { FeedViews } from './views' import { LabelCache } from '../../label-cache' @@ -181,43 +178,6 @@ 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.services.actor.views.profiles(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 getFeedItems(uris: string[]): Promise> { if (uris.length < 1) return {} const feedItems = await this.selectFeedItemQb() @@ -228,19 +188,6 @@ export class FeedService { }, {} as Record) } - async cleanFeedSkeleton(skeleton: SkeletonFeedPost[]): Promise { - const feedItemUris = skeleton.map(getSkeleFeedItemUri) - const feedItems = await this.getFeedItems(feedItemUris) - const cleaned: FeedRow[] = [] - for (const skeleItem of skeleton) { - const feedItem = feedItems[getSkeleFeedItemUri(skeleItem)] - if (feedItem && feedItem.postUri === skeleItem.post) { - cleaned.push(feedItem) - } - } - return cleaned - } - feedItemRefs(items: FeedRow[]) { const actorDids = new Set() const postUris = new Set() @@ -509,9 +456,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 1fe4eeffe1c..27b99e3e369 100644 --- a/packages/bsky/src/services/feed/types.ts +++ b/packages/bsky/src/services/feed/types.ts @@ -30,8 +30,6 @@ export type PostEmbedView = | RecordEmbedView | RecordWithMediaEmbedView -export type PostViews = { [uri: string]: PostView } - export type PostInfo = { uri: string cid: string From 2a1ecedd83fcfa836fea0cd704d30fc64cd08ca4 Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Thu, 31 Aug 2023 17:32:37 -0400 Subject: [PATCH 14/49] move profiles endpoints over to pipelines --- .../bsky/src/api/app/bsky/actor/getProfile.ts | 101 +++++++++++++----- .../src/api/app/bsky/actor/getProfiles.ts | 66 ++++++++++-- packages/bsky/src/services/actor/views.ts | 18 ---- 3 files changed, 132 insertions(+), 53 deletions(-) diff --git a/packages/bsky/src/api/app/bsky/actor/getProfile.ts b/packages/bsky/src/api/app/bsky/actor/getProfile.ts index aab10478a32..03b1cb0a9b1 100644 --- a/packages/bsky/src/api/app/bsky/actor/getProfile.ts +++ b/packages/bsky/src/api/app/bsky/actor/getProfile.ts @@ -1,48 +1,101 @@ import { InvalidRequestError } from '@atproto/xrpc-server' import { Server } from '../../../../lexicon' +import { ProfileViewDetailed } from '../../../../lexicon/types/app/bsky/actor/defs' +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 } 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 profilesDetailed = await actorService.views.profilesDetailed( + [actor], + viewer, + { includeSoftDeleted: canViewTakendownProfile }, + ) + return { ...state, profilesDetailed } +} + +const presentation = (state: HydrationState) => { + const { actor, profilesDetailed } = state + const profile = profilesDetailed[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 & { + profilesDetailed: Record +} diff --git a/packages/bsky/src/api/app/bsky/actor/getProfiles.ts b/packages/bsky/src/api/app/bsky/actor/getProfiles.ts index 0fd7cdf844c..f4e5ebc290c 100644 --- a/packages/bsky/src/api/app/bsky/actor/getProfiles.ts +++ b/packages/bsky/src/api/app/bsky/actor/getProfiles.ts @@ -1,31 +1,75 @@ +import { mapDefined } from '@atproto/common' import { Server } from '../../../../lexicon' +import { ProfileViewDetailed } from '../../../../lexicon/types/app/bsky/actor/defs' +import { QueryParams } from '../../../../lexicon/types/app/bsky/actor/getProfiles' import AppContext from '../../../../context' +import { Database } from '../../../../db' +import { Actor } from '../../../../db/tables/actor' +import { ActorService } 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, actors } +} + +const hydration = async (state: SkeletonState, ctx: Context) => { + const { actorService } = ctx + const { params, actors } = state + const { viewer } = params + const profilesDetailed = await actorService.views.profilesDetailed( + actors, + viewer, + ) + return { ...state, profilesDetailed } +} + +const presentation = (state: HydrationState) => { + const { actors, profilesDetailed } = state + const profiles = mapDefined(actors, (actor) => profilesDetailed[actor.did]) + return { profiles } +} + +type Context = { + db: Database + actorService: ActorService +} + +type Params = QueryParams & { + viewer: string | null +} + +type SkeletonState = { params: Params; actors: Actor[] } + +type HydrationState = SkeletonState & { + profilesDetailed: Record +} diff --git a/packages/bsky/src/services/actor/views.ts b/packages/bsky/src/services/actor/views.ts index cc83c4bb8ea..8ff122f3b92 100644 --- a/packages/bsky/src/services/actor/views.ts +++ b/packages/bsky/src/services/actor/views.ts @@ -167,24 +167,6 @@ export class ActorViews { }, {} 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 | string)[], // @TODO simplify down to just string[] viewer: string | null, From 8a72f5a8fbb7e5f2acc850ba5524a209a574a4d3 Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Thu, 31 Aug 2023 17:59:35 -0400 Subject: [PATCH 15/49] tidy --- .../bsky/src/api/app/bsky/feed/getAuthorFeed.ts | 5 ++++- packages/bsky/src/api/app/bsky/feed/getFeed.ts | 5 ++++- packages/bsky/src/api/app/bsky/feed/getTimeline.ts | 12 +++++++++--- .../api/app/bsky/unspecced/getTimelineSkeleton.ts | 14 +++++++++----- packages/bsky/src/feed-gen/with-friends.ts | 5 +++-- packages/bsky/src/services/actor/views.ts | 9 --------- 6 files changed, 29 insertions(+), 21 deletions(-) diff --git a/packages/bsky/src/api/app/bsky/feed/getAuthorFeed.ts b/packages/bsky/src/api/app/bsky/feed/getAuthorFeed.ts index b15733c4a67..daeabc4dbb1 100644 --- a/packages/bsky/src/api/app/bsky/feed/getAuthorFeed.ts +++ b/packages/bsky/src/api/app/bsky/feed/getAuthorFeed.ts @@ -50,7 +50,10 @@ export default function (server: Server, ctx: AppContext) { }) } -export const skeleton = async (params: Params, ctx: Context) => { +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 diff --git a/packages/bsky/src/api/app/bsky/feed/getFeed.ts b/packages/bsky/src/api/app/bsky/feed/getFeed.ts index 01d6a5364c5..ddda72c94fb 100644 --- a/packages/bsky/src/api/app/bsky/feed/getFeed.ts +++ b/packages/bsky/src/api/app/bsky/feed/getFeed.ts @@ -60,7 +60,10 @@ export default function (server: Server, ctx: AppContext) { }) } -const skeleton = async (params: Params, ctx: Context) => { +const skeleton = async ( + params: Params, + ctx: Context, +): Promise => { const timerSkele = new ServerTimer('skele').start() const { db } = ctx const { feed } = params diff --git a/packages/bsky/src/api/app/bsky/feed/getTimeline.ts b/packages/bsky/src/api/app/bsky/feed/getTimeline.ts index 4b5a832c779..636621811ee 100644 --- a/packages/bsky/src/api/app/bsky/feed/getTimeline.ts +++ b/packages/bsky/src/api/app/bsky/feed/getTimeline.ts @@ -43,7 +43,10 @@ export default function (server: Server, ctx: AppContext) { }) } -export const skeleton = async (params: Params, ctx: Context) => { +export const skeleton = async ( + params: Params, + ctx: Context, +): Promise => { const { cursor, limit, algorithm, viewer } = params const { db } = ctx const { ref } = db.db.dynamic @@ -114,7 +117,10 @@ export const skeleton = async (params: Params, ctx: Context) => { } } -const hydration = async (state: SkeletonState, ctx: Context) => { +const hydration = async ( + state: SkeletonState, + ctx: Context, +): Promise => { const { feedService } = ctx const { params, feedItems } = state const refs = feedService.feedItemRefs(feedItems) @@ -125,7 +131,7 @@ const hydration = async (state: SkeletonState, ctx: Context) => { return { ...state, ...hydrated } } -const noBlocksOrMutes = (state: HydrationState) => { +const noBlocksOrMutes = (state: HydrationState): HydrationState => { const { viewer } = state.params state.feedItems = state.feedItems.filter( (item) => 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/feed-gen/with-friends.ts b/packages/bsky/src/feed-gen/with-friends.ts index efd4b961e9d..08892a78a91 100644 --- a/packages/bsky/src/feed-gen/with-friends.ts +++ b/packages/bsky/src/feed-gen/with-friends.ts @@ -1,7 +1,7 @@ import AppContext from '../context' import { QueryParams as SkeletonParams } from '../lexicon/types/app/bsky/feed/getFeedSkeleton' import { paginate } from '../db/pagination' -import { AlgoHandler, AlgoResponse } from './types' +import { AlgoHandler, AlgoResponse, toSkeletonItem } from './types' import { FeedKeyset, getFeedDateThreshold } from '../api/app/bsky/util/feed' const handler: AlgoHandler = async ( @@ -29,8 +29,9 @@ const handler: AlgoHandler = async ( postsQb = paginate(postsQb, { limit, cursor, keyset, tryIndex: true }) const feedItems = await postsQb.execute() + return { - feedItems, + feed: feedItems.map(toSkeletonItem), cursor: keyset.packFromResult(feedItems), } } diff --git a/packages/bsky/src/services/actor/views.ts b/packages/bsky/src/services/actor/views.ts index 8ff122f3b92..5ff651f4f5c 100644 --- a/packages/bsky/src/services/actor/views.ts +++ b/packages/bsky/src/services/actor/views.ts @@ -343,15 +343,6 @@ export class ActorViews { 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 - } } type ActorResult = Actor From e5b51c29deb8d41a1c4df7bd275fd1d8221ba1d5 Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Fri, 1 Sep 2023 00:54:50 -0400 Subject: [PATCH 16/49] tidy --- packages/bsky/src/services/actor/views.ts | 12 +----------- packages/bsky/src/services/graph/index.ts | 2 ++ 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/packages/bsky/src/services/actor/views.ts b/packages/bsky/src/services/actor/views.ts index 5ff651f4f5c..28c8d074068 100644 --- a/packages/bsky/src/services/actor/views.ts +++ b/packages/bsky/src/services/actor/views.ts @@ -314,9 +314,8 @@ export class ActorViews { return profiles[result.did] ?? null } - // @NOTE keep in sync with feedService.getActorViews() async profilesBasic( - results: ActorResult[], + results: (ActorResult | string)[], viewer: string | null, opts?: { skipLabels?: boolean; includeSoftDeleted?: boolean }, ): Promise> { @@ -334,15 +333,6 @@ export class ActorViews { 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]) - } } type ActorResult = Actor diff --git a/packages/bsky/src/services/graph/index.ts b/packages/bsky/src/services/graph/index.ts index cd3e6ccf09c..4204d29fefd 100644 --- a/packages/bsky/src/services/graph/index.ts +++ b/packages/bsky/src/services/graph/index.ts @@ -95,6 +95,7 @@ export class GraphService { } async getBlockAndMuteState(pairs: RelationshipPair[]) { + if (!pairs.length) return new BlockAndMuteState() const { ref } = this.db.db.dynamic const sourceRef = ref('pair.source') const targetRef = ref('pair.target') @@ -137,6 +138,7 @@ export class GraphService { } async getBlockState(pairs: RelationshipPair[]) { + if (!pairs.length) return new BlockAndMuteState() const { ref } = this.db.db.dynamic const sourceRef = ref('pair.source') const targetRef = ref('pair.target') From cdcc26b05c1d748c58be55bf7eb74ddf758ab4f6 Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Fri, 1 Sep 2023 11:03:25 -0400 Subject: [PATCH 17/49] misc fixes --- .../app/bsky/actor/searchActorsTypeahead.ts | 11 +- .../bsky/src/api/app/bsky/feed/getFeed.ts | 27 +-- .../src/api/app/bsky/feed/getFeedGenerator.ts | 2 +- .../api/app/bsky/feed/getFeedGenerators.ts | 2 +- .../bsky/src/api/app/bsky/feed/getPosts.ts | 3 +- .../api/app/bsky/feed/getSuggestedFeeds.ts | 3 +- packages/bsky/src/services/actor/types.ts | 2 + packages/bsky/src/services/actor/views.ts | 7 +- packages/bsky/src/services/feed/index.ts | 4 +- .../views/__snapshots__/follows.test.ts.snap | 192 +++++++++--------- .../views/__snapshots__/likes.test.ts.snap | 18 +- 11 files changed, 140 insertions(+), 131 deletions(-) diff --git a/packages/bsky/src/api/app/bsky/actor/searchActorsTypeahead.ts b/packages/bsky/src/api/app/bsky/actor/searchActorsTypeahead.ts index b8533073be5..6d59cc91a03 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, { skipLabels: 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/getFeed.ts b/packages/bsky/src/api/app/bsky/feed/getFeed.ts index ddda72c94fb..79e8f363d31 100644 --- a/packages/bsky/src/api/app/bsky/feed/getFeed.ts +++ b/packages/bsky/src/api/app/bsky/feed/getFeed.ts @@ -66,23 +66,23 @@ const skeleton = async ( ): Promise => { const timerSkele = new ServerTimer('skele').start() const { db } = ctx - const { feed } = params - const localAlgo = ctx.appCtx.algos[feed] + const localAlgo = ctx.appCtx.algos[params.feed] + const feedParams: GetFeedParams = { + feed: params.feed, + limit: params.limit, + cursor: params.cursor, + } const skeleton = localAlgo !== undefined ? await localAlgo(ctx.appCtx, params, params.viewer) - : await skeletonFromFeedGen( - ctx.appCtx, - db, - params, - params.viewer, - ctx.authorization, - ) + : await skeletonFromFeedGen(ctx.appCtx, db, feedParams, ctx.authorization) + const { feed, cursor, ...passthrough } = skeleton return { params, - cursor: skeleton.cursor, - feedSkele: skeleton.feed, + cursor, + feedSkele: feed, timerSkele: timerSkele.stop(), + passthrough, } } @@ -113,7 +113,7 @@ const noBlocksOrMutes = (state: HydrationState) => { const presentation = (state: HydrationState, ctx: Context) => { const { feedService } = ctx - const { feedItems, cursor } = state + const { feedItems, cursor, passthrough } = state const feed = feedService.views.formatFeed( feedItems, state.actors, @@ -127,6 +127,7 @@ const presentation = (state: HydrationState, ctx: Context) => { cursor, timerSkele: state.timerSkele, timerHydr: state.timerHydr, + ...passthrough, } } @@ -142,6 +143,7 @@ type Params = GetFeedParams & { viewer: string } type SkeletonState = { params: Params feedSkele: SkeletonFeedPost[] + passthrough: Record // pass through additional items in feedgen response cursor?: string timerSkele: ServerTimer } @@ -153,7 +155,6 @@ const skeletonFromFeedGen = async ( ctx: AppContext, db: Database, params: GetFeedParams, - viewer: string, authorization?: string, ): Promise => { const { feed } = params diff --git a/packages/bsky/src/api/app/bsky/feed/getFeedGenerator.ts b/packages/bsky/src/api/app/bsky/feed/getFeedGenerator.ts index 1612e19c9b4..6207ba1e1aa 100644 --- a/packages/bsky/src/api/app/bsky/feed/getFeedGenerator.ts +++ b/packages/bsky/src/api/app/bsky/feed/getFeedGenerator.ts @@ -47,7 +47,7 @@ export default function (server: Server, ctx: AppContext) { ) } - const profiles = await actorService.views.profiles( + 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 a8dbd0e514f..a973ee6c2fb 100644 --- a/packages/bsky/src/api/app/bsky/feed/getFeedGenerators.ts +++ b/packages/bsky/src/api/app/bsky/feed/getFeedGenerators.ts @@ -48,7 +48,7 @@ const skeleton = async (params: Params, ctx: Context) => { const hydration = async (state: SkeletonState, ctx: Context) => { const { actorService } = ctx - const profiles = await actorService.views.profiles( + const profiles = await actorService.views.profilesBasic( state.generators.map((gen) => gen.creator), state.params.viewer, ) diff --git a/packages/bsky/src/api/app/bsky/feed/getPosts.ts b/packages/bsky/src/api/app/bsky/feed/getPosts.ts index 55c941eca5a..e89d0207963 100644 --- a/packages/bsky/src/api/app/bsky/feed/getPosts.ts +++ b/packages/bsky/src/api/app/bsky/feed/getPosts.ts @@ -1,3 +1,4 @@ +import { dedupeStrs } from '@atproto/common' import { AtUri } from '@atproto/syntax' import { Server } from '../../../../lexicon' import { QueryParams } from '../../../../lexicon/types/app/bsky/feed/getPosts' @@ -26,7 +27,7 @@ export default function (server: Server, ctx: AppContext) { } const skeleton = async (params: Params) => { - return { params, postUris: params.uris } + return { params, postUris: dedupeStrs(params.uris) } } const hydration = async (state: SkeletonState, ctx: Context) => { diff --git a/packages/bsky/src/api/app/bsky/feed/getSuggestedFeeds.ts b/packages/bsky/src/api/app/bsky/feed/getSuggestedFeeds.ts index 23bf349f53e..c437e0d0dcb 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') @@ -22,7 +23,7 @@ export default function (server: Server, ctx: AppContext) { const genList = Object.values(genInfos) 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/services/actor/types.ts b/packages/bsky/src/services/actor/types.ts index 33906909979..997011184e6 100644 --- a/packages/bsky/src/services/actor/types.ts +++ b/packages/bsky/src/services/actor/types.ts @@ -6,7 +6,9 @@ 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 blockedBy?: boolean diff --git a/packages/bsky/src/services/actor/views.ts b/packages/bsky/src/services/actor/views.ts index 28c8d074068..c0d08e03276 100644 --- a/packages/bsky/src/services/actor/views.ts +++ b/packages/bsky/src/services/actor/views.ts @@ -4,7 +4,6 @@ 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' @@ -318,7 +317,7 @@ export class ActorViews { results: (ActorResult | string)[], viewer: string | null, opts?: { skipLabels?: boolean; includeSoftDeleted?: boolean }, - ): Promise> { + ): Promise { if (results.length === 0) return {} const profiles = await this.profiles(results, viewer, opts) return Object.values(profiles).reduce((acc, cur) => { @@ -328,10 +327,12 @@ export class ActorViews { displayName: cur.displayName, avatar: cur.avatar, viewer: cur.viewer, + labels: cur.labels, + [kSelfLabels]: cur[kSelfLabels], } acc[cur.did] = profile return acc - }, {} as Record) + }, {} as ActorInfoMap) } } diff --git a/packages/bsky/src/services/feed/index.ts b/packages/bsky/src/services/feed/index.ts index f2d9622b09a..9c7ec16034b 100644 --- a/packages/bsky/src/services/feed/index.ts +++ b/packages/bsky/src/services/feed/index.ts @@ -215,7 +215,7 @@ export class FeedService { const { viewer, dids, uris } = refs const [posts, actors, labels, bam] = await Promise.all([ this.getPostInfos(Array.from(uris), viewer), - this.services.actor.views.profiles(Array.from(dids), viewer, { + this.services.actor.views.profilesBasic(Array.from(dids), viewer, { skipLabels: true, }), this.services.label.getLabelsForSubjects([...uris, ...dids]), @@ -345,7 +345,7 @@ export class FeedService { const [postInfos, actorInfos, labelViews, feedGenInfos, listViews] = await Promise.all([ this.getPostInfos(nestedPostUris, viewer), - this.services.actor.views.profiles(nestedDids, viewer, { + this.services.actor.views.profilesBasic(nestedDids, viewer, { skipLabels: true, }), this.services.label.getLabelsForSubjects([ 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)", } `; From 992c25f6b29a8013554d6a8069ce07e3a61cfca8 Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Fri, 1 Sep 2023 15:33:21 -0400 Subject: [PATCH 18/49] unify some profile hydration/presentation in appview --- .../src/api/app/bsky/actor/searchActors.ts | 2 +- .../src/api/app/bsky/feed/getActorFeeds.ts | 7 +- .../bsky/src/api/app/bsky/graph/getBlocks.ts | 5 +- .../bsky/src/api/app/bsky/graph/getList.ts | 8 +- .../bsky/src/api/app/bsky/graph/getLists.ts | 11 +- .../bsky/src/api/app/bsky/graph/getMutes.ts | 2 +- packages/bsky/src/services/actor/types.ts | 29 ++ packages/bsky/src/services/actor/views.ts | 253 ++++++++++-------- packages/bsky/src/services/graph/index.ts | 42 ++- packages/bsky/src/services/graph/types.ts | 8 + 10 files changed, 226 insertions(+), 141 deletions(-) create mode 100644 packages/bsky/src/services/graph/types.ts 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/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/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/getList.ts b/packages/bsky/src/api/app/bsky/graph/getList.ts index 068b35fb6df..5535ae22243 100644 --- a/packages/bsky/src/api/app/bsky/graph/getList.ts +++ b/packages/bsky/src/api/app/bsky/graph/getList.ts @@ -39,14 +39,18 @@ export default function (server: Server, ctx: AppContext) { const itemsRes = await itemsReq.execute() const actorService = ctx.services.actor(db) - const profiles = await actorService.views.hydrateProfiles( + const profiles = await actorService.views.profilesList( itemsRes, requester, ) const items = profiles.map((subject) => ({ subject })) - const creator = await actorService.views.profile(listRes, requester) + const profilesCreator = await actorService.views.profiles( + [listRes], + requester, + ) + const creator = profilesCreator[listRes.did] if (!creator) { throw new InvalidRequestError(`Actor not found: ${listRes.handle}`) } 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/services/actor/types.ts b/packages/bsky/src/services/actor/types.ts index 997011184e6..fb96e483f60 100644 --- a/packages/bsky/src/services/actor/types.ts +++ b/packages/bsky/src/services/actor/types.ts @@ -1,3 +1,4 @@ +import { ListViewBasic } from '../../lexicon/types/app/bsky/graph/defs' import { Label } from '../../lexicon/types/com/atproto/label/defs' export const kSelfLabels = Symbol('selfLabels') @@ -11,6 +12,7 @@ export type ActorInfo = { indexedAt?: string // omitted from basic profile view viewer?: { muted?: boolean + mutedByList?: ListViewBasic blockedBy?: boolean blocking?: string following?: string @@ -21,3 +23,30 @@ export type ActorInfo = { [kSelfLabels]?: 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 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 c0d08e03276..25c384740de 100644 --- a/packages/bsky/src/services/actor/views.ts +++ b/packages/bsky/src/services/actor/views.ts @@ -9,10 +9,17 @@ 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, kSelfLabels } from './types' +import { + ActorInfoMap, + ProfileInfoMap, + ProfileViewMap, + kSelfLabels, + toMapByDid, +} from './types' +import { ListInfoMap } from '../graph/types' export class ActorViews { constructor( @@ -172,14 +179,64 @@ export class ActorViews { opts?: { skipLabels?: 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, + ...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) => (typeof r === 'string' ? r : r.did)) + async profilesBasic( + results: (ActorResult | string)[], + viewer: string | null, + opts?: { skipLabels?: 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, + ...opts, + }) + return this.profileBasicPresentation(dids, hydrated, { + viewer, + ...opts, + }) + } + async profilesList( + 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 profileHydration( + dids: string[], + opts: { + viewer?: string | null + includeSoftDeleted?: boolean + skipLabels?: boolean + }, + state?: { + bam: BlockAndMuteState + }, + ): Promise<{ + profiles: ProfileInfoMap + labels: Labels + lists: ListInfoMap + bam: BlockAndMuteState + }> { + const { viewer = null, includeSoftDeleted, skipLabels } = 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('record', 'record.uri', 'profile.uri') .if(!includeSoftDeleted, (qb) => @@ -201,138 +258,118 @@ 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(!skipLabels ? dids : []), + 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 + skipLabels?: boolean + }, + ): ProfileViewMap { + const { viewer, skipLabels } = 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], [kSelfLabels]: selfLabels, } - acc[cur.did] = profile return acc - }, {} as ActorInfoMap) - } - - 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]) + }, {} as ProfileViewMap) } - 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 - } - - async profilesBasic( - results: (ActorResult | string)[], - 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, - labels: cur.labels, - [kSelfLabels]: cur[kSelfLabels], + profileBasicPresentation( + dids: string[], + state: { + profiles: ProfileInfoMap + lists: ListInfoMap + labels: Labels + bam: BlockAndMuteState + }, + opts: { + viewer?: string | null + skipLabels?: 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: prof.labels, + [kSelfLabels]: prof[kSelfLabels], } - acc[cur.did] = profile + acc[prof.did] = profileBasic return acc - }, {} as ActorInfoMap) + }, {}) } } diff --git a/packages/bsky/src/services/graph/index.ts b/packages/bsky/src/services/graph/index.ts index 4204d29fefd..a445416395b 100644 --- a/packages/bsky/src/services/graph/index.ts +++ b/packages/bsky/src/services/graph/index.ts @@ -1,9 +1,9 @@ -import { Selectable, sql } from 'kysely' +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 { valuesList } from '../../db/util' +import { ListInfo } from './types' +import { ActorInfoMap } from '../actor' export class GraphService { constructor(public db: Database, public imgUriBuilder: ImageUriBuilder) {} @@ -94,8 +94,13 @@ export class GraphService { .select(['list_item.cid as cid', 'list_item.sortAt as sortAt']) } - async getBlockAndMuteState(pairs: RelationshipPair[]) { - if (!pairs.length) return new BlockAndMuteState() + async getBlockAndMuteState( + pairs: RelationshipPair[], + bam?: BlockAndMuteState, + ) { + pairs = bam ? pairs.filter((pair) => !bam.seen(pair)) : pairs + const result = bam ?? new BlockAndMuteState() + if (!pairs.length) result const { ref } = this.db.db.dynamic const sourceRef = ref('pair.source') const targetRef = ref('pair.target') @@ -134,11 +139,14 @@ export class GraphService { ]) .selectAll() .execute() - return new BlockAndMuteState(items) + items.forEach((item) => result.add(item)) + return result } - async getBlockState(pairs: RelationshipPair[]) { - if (!pairs.length) return new BlockAndMuteState() + async getBlockState(pairs: RelationshipPair[], bam: BlockAndMuteState) { + pairs = bam ? pairs.filter((pair) => !bam.seen(pair)) : pairs + const result = bam ?? new BlockAndMuteState() + if (!pairs.length) result const { ref } = this.db.db.dynamic const sourceRef = ref('pair.source') const targetRef = ref('pair.target') @@ -163,7 +171,8 @@ export class GraphService { ]) .selectAll() .execute() - return new BlockAndMuteState(items) + items.forEach((item) => result.add(item)) + return result } async getListViews(listUris: string[], requester: string | null) { @@ -180,7 +189,7 @@ export class GraphService { ) } - formatListView(list: ListInfo, profiles: Record) { + formatListView(list: ListInfo, profiles: ActorInfoMap) { return { uri: list.uri, cid: list.cid, @@ -226,13 +235,10 @@ export class GraphService { } } -type ListInfo = Selectable & { - viewerMuted: string | null -} - export type RelationshipPair = [didA: string, didB: string] export class BlockAndMuteState { + seenIdx = new Map>() blockIdx = new Map>() muteIdx = new Map>() muteListIdx = new Map>() @@ -268,6 +274,11 @@ export class BlockAndMuteState { this.muteListIdx.set(item.source, map) } } + const set = this.seenIdx.get(item.source) ?? new Set() + set.add(item.target) + if (!this.seenIdx.has(item.source)) { + this.seenIdx.set(item.source, set) + } } block(pair: RelationshipPair): boolean { return !!this.blocking(pair) || !!this.blockedBy(pair) @@ -284,6 +295,9 @@ export class BlockAndMuteState { muteList(pair: RelationshipPair): string | null { return this.muteListIdx.get(pair[0])?.get(pair[1]) ?? null } + seen(pair: RelationshipPair) { + return !!this.seenIdx.get(pair[0])?.has(pair[1]) + } } type BlockAndMuteInfo = { diff --git a/packages/bsky/src/services/graph/types.ts b/packages/bsky/src/services/graph/types.ts new file mode 100644 index 00000000000..579c47d6536 --- /dev/null +++ b/packages/bsky/src/services/graph/types.ts @@ -0,0 +1,8 @@ +import { Selectable } from 'kysely' +import { List } from '../../db/tables/list' + +export type ListInfo = Selectable & { + viewerMuted: string | null +} + +export type ListInfoMap = Record From da221b02c44561e03ce6b8388cffb9d97f57b4ff Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Sat, 2 Sep 2023 00:55:25 -0400 Subject: [PATCH 19/49] profile detail, split hydration and presentation, misc fixes --- .../bsky/src/api/app/bsky/actor/getProfile.ts | 32 ++- .../src/api/app/bsky/actor/getProfiles.ts | 37 +-- packages/bsky/src/services/actor/types.ts | 26 ++ packages/bsky/src/services/actor/views.ts | 230 +++++++++--------- packages/bsky/src/services/graph/index.ts | 6 +- 5 files changed, 178 insertions(+), 153 deletions(-) diff --git a/packages/bsky/src/api/app/bsky/actor/getProfile.ts b/packages/bsky/src/api/app/bsky/actor/getProfile.ts index 03b1cb0a9b1..09699b8914b 100644 --- a/packages/bsky/src/api/app/bsky/actor/getProfile.ts +++ b/packages/bsky/src/api/app/bsky/actor/getProfile.ts @@ -1,12 +1,14 @@ import { InvalidRequestError } from '@atproto/xrpc-server' import { Server } from '../../../../lexicon' -import { ProfileViewDetailed } from '../../../../lexicon/types/app/bsky/actor/defs' 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 } from '../../../../services/actor' +import { + ActorService, + ProfileDetailHydrationState, +} from '../../../../services/actor' import { setRepoRev } from '../../../util' import { createPipeline, noRules } from '../../../../pipeline' @@ -67,17 +69,23 @@ const hydration = async (state: SkeletonState, ctx: Context) => { const { actorService } = ctx const { params, actor } = state const { viewer, canViewTakendownProfile } = params - const profilesDetailed = await actorService.views.profilesDetailed( - [actor], - viewer, - { includeSoftDeleted: canViewTakendownProfile }, + const hydration = await actorService.views.profileDetailHydration( + [actor.did], + { viewer, includeSoftDeleted: canViewTakendownProfile }, ) - return { ...state, profilesDetailed } + return { ...state, ...hydration } } -const presentation = (state: HydrationState) => { - const { actor, profilesDetailed } = state - const profile = profilesDetailed[actor.did] +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') } @@ -96,6 +104,4 @@ type Params = QueryParams & { type SkeletonState = { params: Params; actor: Actor } -type HydrationState = SkeletonState & { - profilesDetailed: Record -} +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 f4e5ebc290c..f2e0eb3fd50 100644 --- a/packages/bsky/src/api/app/bsky/actor/getProfiles.ts +++ b/packages/bsky/src/api/app/bsky/actor/getProfiles.ts @@ -1,11 +1,12 @@ import { mapDefined } from '@atproto/common' import { Server } from '../../../../lexicon' -import { ProfileViewDetailed } from '../../../../lexicon/types/app/bsky/actor/defs' import { QueryParams } from '../../../../lexicon/types/app/bsky/actor/getProfiles' import AppContext from '../../../../context' import { Database } from '../../../../db' -import { Actor } from '../../../../db/tables/actor' -import { ActorService } from '../../../../services/actor' +import { + ActorService, + ProfileDetailHydrationState, +} from '../../../../services/actor' import { setRepoRev } from '../../../util' import { createPipeline, noRules } from '../../../../pipeline' @@ -39,24 +40,28 @@ const skeleton = async ( ): Promise => { const { actorService } = ctx const actors = await actorService.getActors(params.actors) - return { params, actors } + return { params, dids: actors.map((a) => a.did) } } const hydration = async (state: SkeletonState, ctx: Context) => { const { actorService } = ctx - const { params, actors } = state + const { params, dids } = state const { viewer } = params - const profilesDetailed = await actorService.views.profilesDetailed( - actors, + const hydration = await actorService.views.profileDetailHydration(dids, { viewer, - ) - return { ...state, profilesDetailed } + }) + return { ...state, ...hydration } } -const presentation = (state: HydrationState) => { - const { actors, profilesDetailed } = state - const profiles = mapDefined(actors, (actor) => profilesDetailed[actor.did]) - return { profiles } +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 = { @@ -68,8 +73,6 @@ type Params = QueryParams & { viewer: string | null } -type SkeletonState = { params: Params; actors: Actor[] } +type SkeletonState = { params: Params; dids: string[] } -type HydrationState = SkeletonState & { - profilesDetailed: Record -} +type HydrationState = SkeletonState & ProfileDetailHydrationState diff --git a/packages/bsky/src/services/actor/types.ts b/packages/bsky/src/services/actor/types.ts index fb96e483f60..22ea7160ac6 100644 --- a/packages/bsky/src/services/actor/types.ts +++ b/packages/bsky/src/services/actor/types.ts @@ -1,5 +1,8 @@ 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 const kSelfLabels = Symbol('selfLabels') @@ -42,6 +45,29 @@ export type ProfileInfo = { 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 => { diff --git a/packages/bsky/src/services/actor/views.ts b/packages/bsky/src/services/actor/views.ts index 25c384740de..3e74bb3f9b9 100644 --- a/packages/bsky/src/services/actor/views.ts +++ b/packages/bsky/src/services/actor/views.ts @@ -14,6 +14,7 @@ import { BlockAndMuteState, GraphService } from '../graph' import { LabelCache } from '../../label-cache' import { ActorInfoMap, + ProfileDetailHydrationState, ProfileInfoMap, ProfileViewMap, kSelfLabels, @@ -33,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> { + ): 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?: { skipLabels?: 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, + ...opts, + }) + return this.profileBasicPresentation(dids, hydrated, { + viewer, + ...opts, + }) + } + + async profilesList( + 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 profileDetailHydration( + dids: string[], + opts: { + viewer?: string | null + includeSoftDeleted?: boolean + skipLabels?: boolean + }, + state?: { + bam: BlockAndMuteState + }, + ): Promise { + const { viewer = null, includeSoftDeleted, skipLabels } = 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') @@ -73,149 +119,93 @@ 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(!skipLabels ? dids : []), + 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 + skipLabels?: boolean + }, + ): Record { + const { viewer, skipLabels } = 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], + [kSelfLabels]: selfLabels, } - acc[cur.did] = profile return acc }, {} as Record) } - async profiles( - results: (ActorResult | string)[], // @TODO simplify down to just string[] - viewer: string | null, - opts?: { skipLabels?: 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, - ...opts, - }) - return this.profilePresentation(dids, hydrated, { - viewer, - ...opts, - }) - } - - async profilesBasic( - results: (ActorResult | string)[], - viewer: string | null, - opts?: { skipLabels?: 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, - ...opts, - }) - return this.profileBasicPresentation(dids, hydrated, { - viewer, - ...opts, - }) - } - - async profilesList( - 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 profileHydration( dids: string[], opts: { @@ -369,7 +359,7 @@ export class ActorViews { } acc[prof.did] = profileBasic return acc - }, {}) + }, {} as ProfileViewMap) } } diff --git a/packages/bsky/src/services/graph/index.ts b/packages/bsky/src/services/graph/index.ts index a445416395b..2145fa24929 100644 --- a/packages/bsky/src/services/graph/index.ts +++ b/packages/bsky/src/services/graph/index.ts @@ -100,7 +100,7 @@ export class GraphService { ) { pairs = bam ? pairs.filter((pair) => !bam.seen(pair)) : pairs const result = bam ?? new BlockAndMuteState() - if (!pairs.length) result + if (!pairs.length) return result const { ref } = this.db.db.dynamic const sourceRef = ref('pair.source') const targetRef = ref('pair.target') @@ -143,10 +143,10 @@ export class GraphService { return result } - async getBlockState(pairs: RelationshipPair[], bam: BlockAndMuteState) { + async getBlockState(pairs: RelationshipPair[], bam?: BlockAndMuteState) { pairs = bam ? pairs.filter((pair) => !bam.seen(pair)) : pairs const result = bam ?? new BlockAndMuteState() - if (!pairs.length) result + if (!pairs.length) return result const { ref } = this.db.db.dynamic const sourceRef = ref('pair.source') const targetRef = ref('pair.target') From a353624c0959cb93faf6137ca6397da2695ab6ef Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Mon, 4 Sep 2023 14:10:05 -0400 Subject: [PATCH 20/49] unify feed hydration w/ profile hydration --- .../src/api/app/bsky/feed/getActorLikes.ts | 13 ++--- .../src/api/app/bsky/feed/getAuthorFeed.ts | 13 ++--- .../bsky/src/api/app/bsky/feed/getFeed.ts | 13 ++--- .../src/api/app/bsky/feed/getPostThread.ts | 15 ++++-- .../bsky/src/api/app/bsky/feed/getPosts.ts | 19 ++++++-- .../bsky/src/api/app/bsky/feed/getTimeline.ts | 13 ++--- packages/bsky/src/services/actor/views.ts | 27 ++++------- packages/bsky/src/services/feed/index.ts | 40 ++++++++++++---- packages/bsky/src/services/feed/types.ts | 5 +- packages/bsky/src/services/feed/views.ts | 47 +++++++++++++------ packages/bsky/src/services/graph/index.ts | 22 ++++----- packages/bsky/src/services/label/index.ts | 14 +++--- 12 files changed, 134 insertions(+), 107 deletions(-) diff --git a/packages/bsky/src/api/app/bsky/feed/getActorLikes.ts b/packages/bsky/src/api/app/bsky/feed/getActorLikes.ts index 1046e804db7..73b8b070262 100644 --- a/packages/bsky/src/api/app/bsky/feed/getActorLikes.ts +++ b/packages/bsky/src/api/app/bsky/feed/getActorLikes.ts @@ -106,15 +106,10 @@ const noPostBlocks = (state: HydrationState) => { const presentation = (state: HydrationState, ctx: Context) => { const { feedService } = ctx - const { feedItems, cursor } = state - const feed = feedService.views.formatFeed( - feedItems, - state.actors, - state.posts, - state.embeds, - state.labels, - state.blocks, - ) + const { feedItems, cursor, params } = state + const feed = feedService.views.formatFeed(feedItems, state, { + viewer: params.viewer, + }) return { feed, cursor } } diff --git a/packages/bsky/src/api/app/bsky/feed/getAuthorFeed.ts b/packages/bsky/src/api/app/bsky/feed/getAuthorFeed.ts index daeabc4dbb1..9f25eb131e1 100644 --- a/packages/bsky/src/api/app/bsky/feed/getAuthorFeed.ts +++ b/packages/bsky/src/api/app/bsky/feed/getAuthorFeed.ts @@ -146,15 +146,10 @@ const noBlocksOrMutedReposts = (state: HydrationState) => { const presentation = (state: HydrationState, ctx: Context) => { const { feedService } = ctx - const { feedItems, cursor } = state - const feed = feedService.views.formatFeed( - feedItems, - state.actors, - state.posts, - state.embeds, - state.labels, - state.blocks, - ) + const { feedItems, cursor, params } = state + const feed = feedService.views.formatFeed(feedItems, state, { + viewer: params.viewer, + }) return { feed, cursor } } diff --git a/packages/bsky/src/api/app/bsky/feed/getFeed.ts b/packages/bsky/src/api/app/bsky/feed/getFeed.ts index 79e8f363d31..e875e385247 100644 --- a/packages/bsky/src/api/app/bsky/feed/getFeed.ts +++ b/packages/bsky/src/api/app/bsky/feed/getFeed.ts @@ -113,15 +113,10 @@ const noBlocksOrMutes = (state: HydrationState) => { const presentation = (state: HydrationState, ctx: Context) => { const { feedService } = ctx - const { feedItems, cursor, passthrough } = state - const feed = feedService.views.formatFeed( - feedItems, - state.actors, - state.posts, - state.embeds, - state.labels, - state.blocks, - ) + const { feedItems, cursor, passthrough, params } = state + const feed = feedService.views.formatFeed(feedItems, state, { + viewer: params.viewer, + }) return { feed, cursor, diff --git a/packages/bsky/src/api/app/bsky/feed/getPostThread.ts b/packages/bsky/src/api/app/bsky/feed/getPostThread.ts index 512d473bec9..8883fac18bb 100644 --- a/packages/bsky/src/api/app/bsky/feed/getPostThread.ts +++ b/packages/bsky/src/api/app/bsky/feed/getPostThread.ts @@ -20,6 +20,7 @@ import { import { Database } from '../../../../db' import { setRepoRev } from '../../../util' import { createPipeline, noRules } from '../../../../pipeline' +import { ActorService } from '../../../../services/actor' export default function (server: Server, ctx: AppContext) { const getPostThread = createPipeline( @@ -33,11 +34,11 @@ export default function (server: Server, ctx: AppContext) { handler: async ({ params, auth, res }) => { const viewer = auth.credentials.did const db = ctx.db.getReplica('thread') - const actorService = ctx.services.actor(db) const feedService = ctx.services.feed(db) + const actorService = ctx.services.actor(db) const [result, repoRev] = await Promise.allSettled([ - getPostThread({ ...params, viewer }, { db, feedService }), + getPostThread({ ...params, viewer }, { db, feedService, actorService }), actorService.getRepoRev(viewer), ]) @@ -90,9 +91,14 @@ const composeThread = ( state: HydrationState, ctx: Context, ) => { - const { feedService } = ctx - const { actors, posts, embeds, blocks, labels } = state + const { feedService, actorService } = ctx + const { profiles, posts, embeds, blocks, labels, params } = state + const actors = actorService.views.profileBasicPresentation( + Object.keys(profiles), + state, + { viewer: params.viewer }, + ) const post = feedService.views.formatPostView( threadData.post.postUri, actors, @@ -272,6 +278,7 @@ type PostThread = { type Context = { db: Database feedService: FeedService + actorService: ActorService } type Params = QueryParams & { viewer: string | null } diff --git a/packages/bsky/src/api/app/bsky/feed/getPosts.ts b/packages/bsky/src/api/app/bsky/feed/getPosts.ts index e89d0207963..fc35b203034 100644 --- a/packages/bsky/src/api/app/bsky/feed/getPosts.ts +++ b/packages/bsky/src/api/app/bsky/feed/getPosts.ts @@ -6,6 +6,7 @@ import AppContext from '../../../../context' 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) @@ -14,9 +15,13 @@ export default function (server: Server, ctx: AppContext) { handler: async ({ params, auth }) => { const db = ctx.db.getReplica() 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 }) + const results = await getPosts( + { ...params, viewer }, + { db, feedService, actorService }, + ) return { encoding: 'application/json', @@ -54,13 +59,18 @@ const noBlocks = (state: HydrationState) => { } const presentation = (state: HydrationState, ctx: Context) => { - const { feedService } = ctx - const { postUris } = state + 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, - state.actors, + actors, state.posts, state.embeds, state.labels, @@ -73,6 +83,7 @@ const presentation = (state: HydrationState, ctx: Context) => { type Context = { db: Database feedService: FeedService + actorService: ActorService } type Params = QueryParams & { viewer: string | null } diff --git a/packages/bsky/src/api/app/bsky/feed/getTimeline.ts b/packages/bsky/src/api/app/bsky/feed/getTimeline.ts index 636621811ee..e9d61764a99 100644 --- a/packages/bsky/src/api/app/bsky/feed/getTimeline.ts +++ b/packages/bsky/src/api/app/bsky/feed/getTimeline.ts @@ -145,15 +145,10 @@ const noBlocksOrMutes = (state: HydrationState): HydrationState => { const presentation = (state: HydrationState, ctx: Context) => { const { feedService } = ctx - const { feedItems, cursor } = state - const feed = feedService.views.formatFeed( - feedItems, - state.actors, - state.posts, - state.embeds, - state.labels, - state.blocks, - ) + const { feedItems, cursor, params } = state + const feed = feedService.views.formatFeed(feedItems, state, { + viewer: params.viewer, + }) return { feed, cursor } } diff --git a/packages/bsky/src/services/actor/views.ts b/packages/bsky/src/services/actor/views.ts index 3e74bb3f9b9..c5a4a709812 100644 --- a/packages/bsky/src/services/actor/views.ts +++ b/packages/bsky/src/services/actor/views.ts @@ -15,6 +15,7 @@ import { LabelCache } from '../../label-cache' import { ActorInfoMap, ProfileDetailHydrationState, + ProfileHydrationState, ProfileInfoMap, ProfileViewMap, kSelfLabels, @@ -211,18 +212,13 @@ export class ActorViews { opts: { viewer?: string | null includeSoftDeleted?: boolean - skipLabels?: boolean }, state?: { bam: BlockAndMuteState + labels: Labels }, - ): Promise<{ - profiles: ProfileInfoMap - labels: Labels - lists: ListInfoMap - bam: BlockAndMuteState - }> { - const { viewer = null, includeSoftDeleted, skipLabels } = opts + ): Promise { + const { viewer = null, includeSoftDeleted } = opts const { ref } = this.db.db.dynamic const profileInfosQb = this.db.db .selectFrom('actor') @@ -259,7 +255,7 @@ export class ActorViews { ]) 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, @@ -282,12 +278,12 @@ export class ActorViews { labels: Labels bam: BlockAndMuteState }, - opts: { + opts?: { viewer?: string | null skipLabels?: boolean }, ): ProfileViewMap { - const { viewer, skipLabels } = opts + const { viewer, skipLabels } = opts ?? {} const { profiles, lists, labels, bam } = state return dids.reduce((acc, did) => { const prof = profiles[did] @@ -335,13 +331,8 @@ export class ActorViews { profileBasicPresentation( dids: string[], - state: { - profiles: ProfileInfoMap - lists: ListInfoMap - labels: Labels - bam: BlockAndMuteState - }, - opts: { + state: ProfileHydrationState, + opts?: { viewer?: string | null skipLabels?: boolean }, diff --git a/packages/bsky/src/services/feed/index.ts b/packages/bsky/src/services/feed/index.ts index 9c7ec16034b..d9b2b4716cb 100644 --- a/packages/bsky/src/services/feed/index.ts +++ b/packages/bsky/src/services/feed/index.ts @@ -30,7 +30,7 @@ import { } from './types' import { LabelService } from '../label' import { ActorService } from '../actor' -import { GraphService, RelationshipPair } from '../graph' +import { BlockAndMuteState, GraphService, RelationshipPair } from '../graph' import { FeedViews } from './views' import { LabelCache } from '../../label-cache' @@ -43,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), @@ -213,23 +213,39 @@ export class FeedService { viewer: string | null }): Promise { const { viewer, dids, uris } = refs - const [posts, actors, labels, bam] = await Promise.all([ + const [posts, labels, bam] = await Promise.all([ this.getPostInfos(Array.from(uris), viewer), - this.services.actor.views.profilesBasic(Array.from(dids), viewer, { - skipLabels: true, - }), this.services.label.getLabelsForSubjects([...uris, ...dids]), this.services.graph.getBlockAndMuteState( viewer ? [...dids].map((did) => [viewer, did]) : [], ), ]) - const blocks = await this.blocksForPosts(posts) + // 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) - return { posts, actors, labels, bam, blocks, embeds } + return { + posts, + blocks, + 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 @@ -256,7 +272,10 @@ export class FeedService { } } // compute block state from all actor relationships among posts - const blockState = await this.services.graph.getBlockState(relationships) + const blockState = await this.services.graph.getBlockState( + relationships, + bam, + ) const result: PostBlocksMap = {} Object.entries(byPost).forEach(([uri, block]) => { if (block.embed && blockState.block(block.embed)) { @@ -342,6 +361,7 @@ export class FeedService { } } const nestedDids = [...nestedDidsSet] + // @TODO utilize feedHydration() const [postInfos, actorInfos, labelViews, feedGenInfos, listViews] = await Promise.all([ this.getPostInfos(nestedPostUris, viewer), diff --git a/packages/bsky/src/services/feed/types.ts b/packages/bsky/src/services/feed/types.ts index 27b99e3e369..894ee0a564f 100644 --- a/packages/bsky/src/services/feed/types.ts +++ b/packages/bsky/src/services/feed/types.ts @@ -16,7 +16,7 @@ import { } from '../../lexicon/types/app/bsky/feed/defs' import { FeedGenerator } from '../../db/tables/feed-generator' import { ListView } from '../../lexicon/types/app/bsky/graph/defs' -import { ActorInfoMap } from '../actor' +import { ProfileHydrationState } from '../actor' import { Labels } from '../label' import { BlockAndMuteState } from '../graph' @@ -84,8 +84,7 @@ export type RecordEmbedViewRecord = export type RecordEmbedViewRecordMap = { [uri: string]: RecordEmbedViewRecord } -export type FeedHydrationState = { - actors: ActorInfoMap +export type FeedHydrationState = ProfileHydrationState & { posts: PostInfoMap embeds: PostEmbedViews labels: Labels diff --git a/packages/bsky/src/services/feed/views.ts b/packages/bsky/src/services/feed/views.ts index b2dab7a0b61..f00f1f173b6 100644 --- a/packages/bsky/src/services/feed/views.ts +++ b/packages/bsky/src/services/feed/views.ts @@ -28,16 +28,26 @@ import { PostInfoMap, RecordEmbedViewRecord, PostBlocksMap, + FeedHydrationState, } from './types' import { Labels, getSelfLabels } from '../label' import { ImageUriBuilder } from '../../image/uri' -import { ActorInfoMap, kSelfLabels } from '../actor' +import { LabelCache } from '../../label-cache' +import { ActorInfoMap, ActorService, kSelfLabels } 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( @@ -82,13 +92,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( @@ -129,7 +144,7 @@ export class FeedViews { embeds, labels, blocks, - usePostViewUnion, + opts, ) const replyRoot = this.formatMaybePostView( item.replyRoot, @@ -138,7 +153,7 @@ export class FeedViews { embeds, labels, blocks, - usePostViewUnion, + opts, ) if (replyRoot && replyParent) { feedPost['reply'] = { @@ -200,11 +215,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 ( @@ -212,7 +229,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 2145fa24929..cce71d9dc69 100644 --- a/packages/bsky/src/services/graph/index.ts +++ b/packages/bsky/src/services/graph/index.ts @@ -98,7 +98,7 @@ export class GraphService { pairs: RelationshipPair[], bam?: BlockAndMuteState, ) { - pairs = bam ? pairs.filter((pair) => !bam.seen(pair)) : pairs + 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 @@ -144,7 +144,7 @@ export class GraphService { } async getBlockState(pairs: RelationshipPair[], bam?: BlockAndMuteState) { - pairs = bam ? pairs.filter((pair) => !bam.seen(pair)) : pairs + 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 @@ -238,10 +238,10 @@ export class GraphService { export type RelationshipPair = [didA: string, didB: string] export class BlockAndMuteState { - seenIdx = new Map>() - blockIdx = new Map>() - muteIdx = new Map>() - muteListIdx = new Map>() + 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)) } @@ -274,10 +274,10 @@ export class BlockAndMuteState { this.muteListIdx.set(item.source, map) } } - const set = this.seenIdx.get(item.source) ?? new Set() + const set = this.hasIdx.get(item.source) ?? new Set() set.add(item.target) - if (!this.seenIdx.has(item.source)) { - this.seenIdx.set(item.source, set) + if (!this.hasIdx.has(item.source)) { + this.hasIdx.set(item.source, set) } } block(pair: RelationshipPair): boolean { @@ -295,8 +295,8 @@ export class BlockAndMuteState { muteList(pair: RelationshipPair): string | null { return this.muteListIdx.get(pair[0])?.get(pair[1]) ?? null } - seen(pair: RelationshipPair) { - return !!this.seenIdx.get(pair[0])?.has(pair[1]) + has(pair: RelationshipPair) { + return !!this.hasIdx.get(pair[0])?.has(pair[1]) } } diff --git a/packages/bsky/src/services/label/index.ts b/packages/bsky/src/services/label/index.ts index 42b263138ab..cd9167b24c0 100644 --- a/packages/bsky/src/services/label/index.ts +++ b/packages/bsky/src/services/label/index.ts @@ -98,9 +98,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, @@ -109,8 +111,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 && @@ -120,12 +122,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( From 001572943adf8da1f547d156297676b30789f44c Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Mon, 4 Sep 2023 15:49:52 -0400 Subject: [PATCH 21/49] unify hydration step for embeds, tidy application of labels --- .../app/bsky/actor/searchActorsTypeahead.ts | 2 +- packages/bsky/src/services/actor/types.ts | 4 - packages/bsky/src/services/actor/views.ts | 34 ++++---- packages/bsky/src/services/feed/index.ts | 84 +++++++++---------- packages/bsky/src/services/feed/views.ts | 22 +---- .../__snapshots__/mute-lists.test.ts.snap | 18 ++++ 6 files changed, 74 insertions(+), 90 deletions(-) diff --git a/packages/bsky/src/api/app/bsky/actor/searchActorsTypeahead.ts b/packages/bsky/src/api/app/bsky/actor/searchActorsTypeahead.ts index 6d59cc91a03..64bcd811d02 100644 --- a/packages/bsky/src/api/app/bsky/actor/searchActorsTypeahead.ts +++ b/packages/bsky/src/api/app/bsky/actor/searchActorsTypeahead.ts @@ -23,7 +23,7 @@ export default function (server: Server, ctx: AppContext) { const actors = await ctx.services .actor(db) - .views.profilesBasic(results, requester, { skipLabels: true }) + .views.profilesBasic(results, requester, { omitLabels: true }) const SKIP = [] const filtered = results.flatMap((res) => { diff --git a/packages/bsky/src/services/actor/types.ts b/packages/bsky/src/services/actor/types.ts index 22ea7160ac6..e853406e22e 100644 --- a/packages/bsky/src/services/actor/types.ts +++ b/packages/bsky/src/services/actor/types.ts @@ -4,8 +4,6 @@ import { BlockAndMuteState } from '../graph' import { ListInfoMap } from '../graph/types' import { Labels } from '../label' -export const kSelfLabels = Symbol('selfLabels') - export type ActorInfo = { did: string handle: string @@ -22,8 +20,6 @@ export type ActorInfo = { 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 } diff --git a/packages/bsky/src/services/actor/views.ts b/packages/bsky/src/services/actor/views.ts index c5a4a709812..80652599f80 100644 --- a/packages/bsky/src/services/actor/views.ts +++ b/packages/bsky/src/services/actor/views.ts @@ -18,7 +18,6 @@ import { ProfileHydrationState, ProfileInfoMap, ProfileViewMap, - kSelfLabels, toMapByDid, } from './types' import { ListInfoMap } from '../graph/types' @@ -38,7 +37,7 @@ export class ActorViews { async profiles( results: (ActorResult | string)[], // @TODO simplify down to just string[] viewer: string | null, - opts?: { skipLabels?: boolean; includeSoftDeleted?: boolean }, + opts?: { includeSoftDeleted?: boolean }, ): Promise { if (results.length === 0) return {} const dids = results.map((res) => (typeof res === 'string' ? res : res.did)) @@ -55,24 +54,24 @@ export class ActorViews { async profilesBasic( results: (ActorResult | string)[], viewer: string | null, - opts?: { skipLabels?: boolean; includeSoftDeleted?: boolean }, + 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, - ...opts, + includeSoftDeleted: opts?.includeSoftDeleted, }) return this.profileBasicPresentation(dids, hydrated, { viewer, - ...opts, + omitLabels: opts?.omitLabels, }) } async profilesList( results: ActorResult[], viewer: string | null, - opts?: { skipLabels?: boolean; includeSoftDeleted?: boolean }, + opts?: { includeSoftDeleted?: boolean }, ): Promise { const profiles = await this.profiles(results, viewer, opts) return mapDefined(results, (result) => profiles[result.did]) @@ -83,13 +82,13 @@ export class ActorViews { opts: { viewer?: string | null includeSoftDeleted?: boolean - skipLabels?: boolean }, state?: { bam: BlockAndMuteState + labels: Labels }, ): Promise { - const { viewer = null, includeSoftDeleted, skipLabels } = opts + const { viewer = null, includeSoftDeleted } = opts const { ref } = this.db.db.dynamic const profileInfosQb = this.db.db .selectFrom('actor') @@ -131,7 +130,7 @@ export class ActorViews { ]) 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, @@ -151,10 +150,9 @@ export class ActorViews { state: ProfileDetailHydrationState, opts: { viewer?: string | null - skipLabels?: boolean }, ): Record { - const { viewer, skipLabels } = opts + const { viewer } = opts const { profilesDetailed, lists, labels, bam } = state return dids.reduce((acc, did) => { const prof = profilesDetailed[did] @@ -200,8 +198,7 @@ export class ActorViews { followedBy: prof?.viewerFollowedBy || undefined, } : undefined, - labels: skipLabels ? undefined : [...actorLabels, ...selfLabels], - [kSelfLabels]: selfLabels, + labels: [...actorLabels, ...selfLabels], } return acc }, {} as Record) @@ -280,10 +277,9 @@ export class ActorViews { }, opts?: { viewer?: string | null - skipLabels?: boolean }, ): ProfileViewMap { - const { viewer, skipLabels } = opts ?? {} + const { viewer } = opts ?? {} const { profiles, lists, labels, bam } = state return dids.reduce((acc, did) => { const prof = profiles[did] @@ -322,8 +318,7 @@ export class ActorViews { followedBy: prof?.viewerFollowedBy || undefined, } : undefined, - labels: skipLabels ? undefined : [...actorLabels, ...selfLabels], - [kSelfLabels]: selfLabels, + labels: [...actorLabels, ...selfLabels], } return acc }, {} as ProfileViewMap) @@ -334,7 +329,7 @@ export class ActorViews { state: ProfileHydrationState, opts?: { viewer?: string | null - skipLabels?: boolean + omitLabels?: boolean }, ): ProfileViewMap { const result = this.profilePresentation(dids, state, opts) @@ -345,8 +340,7 @@ export class ActorViews { displayName: prof.displayName, avatar: prof.avatar, viewer: prof.viewer, - labels: prof.labels, - [kSelfLabels]: prof[kSelfLabels], + labels: opts?.omitLabels ? undefined : prof.labels, } acc[prof.did] = profileBasic return acc diff --git a/packages/bsky/src/services/feed/index.ts b/packages/bsky/src/services/feed/index.ts index d9b2b4716cb..db32f1971bc 100644 --- a/packages/bsky/src/services/feed/index.ts +++ b/packages/bsky/src/services/feed/index.ts @@ -207,11 +207,14 @@ export class FeedService { return { dids: actorDids, uris: postUris } } - async feedHydration(refs: { - dids: Set - uris: Set - viewer: string | null - }): Promise { + 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), @@ -229,7 +232,7 @@ export class FeedService { ), this.blocksForPosts(posts, bam), ]) - const embeds = await this.embedsForPosts(posts, blocks, viewer) + const embeds = await this.embedsForPosts(posts, blocks, viewer, depth) return { posts, blocks, @@ -294,7 +297,7 @@ export class FeedService { postInfos: PostInfoMap, blocks: PostBlocksMap, viewer: string | null, - depth = 0, + depth: number, ) { const postMap = postRecordsFromInfos(postInfos) const posts = Object.values(postMap) @@ -345,42 +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] - // @TODO utilize feedHydration() - const [postInfos, actorInfos, labelViews, feedGenInfos, listViews] = - await Promise.all([ - this.getPostInfos(nestedPostUris, viewer), - this.services.actor.views.profilesBasic(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) { @@ -388,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, diff --git a/packages/bsky/src/services/feed/views.ts b/packages/bsky/src/services/feed/views.ts index f00f1f173b6..a7ed2790c58 100644 --- a/packages/bsky/src/services/feed/views.ts +++ b/packages/bsky/src/services/feed/views.ts @@ -33,7 +33,7 @@ import { import { Labels, getSelfLabels } from '../label' import { ImageUriBuilder } from '../../image/uri' import { LabelCache } from '../../label-cache' -import { ActorInfoMap, ActorService, kSelfLabels } from '../actor' +import { ActorInfoMap, ActorService } from '../actor' export class FeedViews { constructor( @@ -53,16 +53,8 @@ export class FeedViews { 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, @@ -124,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, } } @@ -177,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, 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..4fdb9662919 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, From 32f826ee337c19bf13b85b900125a87f7d7e9447 Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Mon, 4 Sep 2023 17:22:15 -0400 Subject: [PATCH 22/49] setup indexing of list-blocks in bsky appview --- .../src/api/app/bsky/feed/getFeedSkeleton.ts | 2 +- packages/bsky/src/db/database-schema.ts | 2 + .../20230904T211011773Z-block-lists.ts | 30 +++++++ packages/bsky/src/db/migrations/index.ts | 1 + packages/bsky/src/db/tables/list-block.ts | 15 ++++ packages/bsky/src/services/indexing/index.ts | 3 + .../services/indexing/plugins/list-block.ts | 90 +++++++++++++++++++ 7 files changed, 142 insertions(+), 1 deletion(-) 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/services/indexing/plugins/list-block.ts diff --git a/packages/bsky/src/api/app/bsky/feed/getFeedSkeleton.ts b/packages/bsky/src/api/app/bsky/feed/getFeedSkeleton.ts index 998f787aa3f..ed012256ee6 100644 --- a/packages/bsky/src/api/app/bsky/feed/getFeedSkeleton.ts +++ b/packages/bsky/src/api/app/bsky/feed/getFeedSkeleton.ts @@ -19,7 +19,7 @@ export default function (server: Server, ctx: AppContext) { return { encoding: 'application/json', body: { - feed: result.feed, // @TODO should we proactively filter blocks/mutes from the skeleton, or treat this similar to other cusotm feeds? + feed: result.feed, // @TODO should we proactively filter blocks/mutes from the skeleton, or treat this similar to other custom feeds? cursor: result.cursor, }, } 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..370fbd651e2 --- /dev/null +++ b/packages/bsky/src/db/migrations/20230904T211011773Z-block-lists.ts @@ -0,0 +1,30 @@ +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() + + await db.schema + .createIndex('list_block_subject_idx') + .on('list_block') + .column('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/services/indexing/index.ts b/packages/bsky/src/services/indexing/index.ts index 2af633cce29..f97c75f9586 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, 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..6655ec62350 --- /dev/null +++ b/packages/bsky/src/services/indexing/plugins/list-block.ts @@ -0,0 +1,90 @@ +import { Selectable } from 'kysely' +import { AtUri } from '@atproto/uri' +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 From ec21246894581226793054b4a9ba36ae3414ae71 Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Mon, 4 Sep 2023 23:33:51 -0400 Subject: [PATCH 23/49] apply list-blocks, impl getListBlocks, tidy getList, tests --- .../bsky/src/api/app/bsky/graph/getList.ts | 175 +++--- .../src/api/app/bsky/graph/getListBlocks.ts | 114 ++++ packages/bsky/src/api/index.ts | 2 + packages/bsky/src/services/graph/index.ts | 78 ++- packages/bsky/src/services/graph/types.ts | 1 + packages/bsky/src/services/indexing/index.ts | 4 + .../__snapshots__/block-lists.test.ts.snap | 553 ++++++++++++++++++ .../__snapshots__/mute-lists.test.ts.snap | 30 +- packages/bsky/tests/views/block-lists.test.ts | 407 +++++++++++++ 9 files changed, 1257 insertions(+), 107 deletions(-) create mode 100644 packages/bsky/src/api/app/bsky/graph/getListBlocks.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 diff --git a/packages/bsky/src/api/app/bsky/graph/getList.ts b/packages/bsky/src/api/app/bsky/graph/getList.ts index 5535ae22243..1e6775d01cb 100644 --- a/packages/bsky/src/api/app/bsky/graph/getList.ts +++ b/packages/bsky/src/api/app/bsky/graph/getList.ts @@ -1,91 +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.profilesList( - 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 profilesCreator = await actorService.views.profiles( - [listRes], - requester, - ) - const creator = profilesCreator[listRes.did] - 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..dc0fbcf5610 --- /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.profileBasicPresentation( + 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/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/services/graph/index.ts b/packages/bsky/src/services/graph/index.ts index cce71d9dc69..53592ac4021 100644 --- a/packages/bsky/src/services/graph/index.ts +++ b/packages/bsky/src/services/graph/index.ts @@ -5,6 +5,8 @@ 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) {} @@ -76,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,12 +124,28 @@ export class GraphService { .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) @@ -162,12 +186,28 @@ export class GraphService { .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() @@ -191,26 +231,12 @@ export class GraphService { 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, - }, } } @@ -227,9 +253,10 @@ export class GraphService { list.avatarCid, ) : undefined, - indexedAt: list.indexedAt, + indexedAt: list.sortAt, viewer: { muted: !!list.viewerMuted, + blocked: list.viewerListBlockUri ?? undefined, }, } } @@ -246,16 +273,18 @@ export class BlockAndMuteState { items.forEach((item) => this.add(item)) } add(item: BlockAndMuteInfo) { - if (item.blocking) { + 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, item.blocking) + map.set(item.target, blocking) if (!this.blockIdx.has(item.source)) { this.blockIdx.set(item.source, map) } } - if (item.blockedBy) { + 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, item.blockedBy) + map.set(item.source, blockedBy) if (!this.blockIdx.has(item.target)) { this.blockIdx.set(item.target, map) } @@ -283,15 +312,18 @@ export class BlockAndMuteState { 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 } @@ -304,7 +336,9 @@ 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 index 579c47d6536..f5ee0c13026 100644 --- a/packages/bsky/src/services/graph/types.ts +++ b/packages/bsky/src/services/graph/types.ts @@ -3,6 +3,7 @@ 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 f97c75f9586..415d2610943 100644 --- a/packages/bsky/src/services/indexing/index.ts +++ b/packages/bsky/src/services/indexing/index.ts @@ -333,6 +333,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/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..dff579ef9b5 --- /dev/null +++ b/packages/bsky/tests/views/__snapshots__/block-lists.test.ts.snap @@ -0,0 +1,553 @@ +// 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", + "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)", + "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", + "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)", + "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__/mute-lists.test.ts.snap b/packages/bsky/tests/views/__snapshots__/mute-lists.test.ts.snap index 4fdb9662919..0a081f91292 100644 --- a/packages/bsky/tests/views/__snapshots__/mute-lists.test.ts.snap +++ b/packages/bsky/tests/views/__snapshots__/mute-lists.test.ts.snap @@ -487,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", @@ -510,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", @@ -537,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", @@ -551,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) + }) +}) From eb30fd1be94857339eb0d6857eccd025e1d87e25 Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Mon, 4 Sep 2023 23:39:21 -0400 Subject: [PATCH 24/49] tidy --- packages/bsky/src/api/app/bsky/graph/getListBlocks.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/bsky/src/api/app/bsky/graph/getListBlocks.ts b/packages/bsky/src/api/app/bsky/graph/getListBlocks.ts index dc0fbcf5610..0884005b244 100644 --- a/packages/bsky/src/api/app/bsky/graph/getListBlocks.ts +++ b/packages/bsky/src/api/app/bsky/graph/getListBlocks.ts @@ -84,7 +84,7 @@ const hydration = async (state: SkeletonState, ctx: Context) => { const presentation = (state: HydrationState, ctx: Context) => { const { actorService, graphService } = ctx const { params, listInfos, cursor, ...profileState } = state - const actors = actorService.views.profileBasicPresentation( + const actors = actorService.views.profilePresentation( Object.keys(profileState.profiles), profileState, { viewer: params.viewer }, From 8711f6ca1c6446104c3e3f8380bb5bcbc8837de5 Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Tue, 5 Sep 2023 00:08:30 -0400 Subject: [PATCH 25/49] update pds proxy snaps --- .../proxied/__snapshots__/views.test.ts.snap | 114 +++++++++--------- 1 file changed, 57 insertions(+), 57 deletions(-) diff --git a/packages/pds/tests/proxied/__snapshots__/views.test.ts.snap b/packages/pds/tests/proxied/__snapshots__/views.test.ts.snap index 65baff8fa50..9a8396ca220 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,7 +148,7 @@ Object { }, }, Object { - "did": "user(0)", + "did": "user(2)", "handle": "dan.test", "labels": Array [], "viewer": Object { @@ -157,7 +157,7 @@ Object { }, }, ], - "cursor": "user(0)", + "cursor": "user(2)", } `; @@ -697,7 +697,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 { @@ -705,14 +705,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, }, }, @@ -733,7 +733,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 { @@ -741,7 +741,7 @@ Object { "cts": "1970-01-01T00:00:00.000Z", "neg": false, "src": "user(2)", - "uri": "record(4)", + "uri": "record(3)", "val": "self-label-b", }, ], @@ -754,7 +754,7 @@ Object { "indexedAt": "1970-01-01T00:00:00.000Z", }, ], - "uri": "record(0)", + "uri": "record(4)", } `; @@ -955,13 +955,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)", } `; @@ -2209,37 +2209,37 @@ Object { "cursor": "0000000000000::bafycid", "followers": Array [ Object { - "did": "user(2)", + "did": "user(0)", "handle": "dan.test", "labels": Array [], "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", }, ], @@ -2250,34 +2250,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, }, }, @@ -2289,38 +2289,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", }, ], @@ -2331,34 +2331,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, }, }, From 0265e49a72281f148dd59a363d5d8599e22787e4 Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Tue, 5 Sep 2023 00:11:26 -0400 Subject: [PATCH 26/49] update pds proxy snaps --- .../proxied/__snapshots__/views.test.ts.snap | 46 +++++++++---------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/packages/pds/tests/proxied/__snapshots__/views.test.ts.snap b/packages/pds/tests/proxied/__snapshots__/views.test.ts.snap index 9a8396ca220..c6c14f206c5 100644 --- a/packages/pds/tests/proxied/__snapshots__/views.test.ts.snap +++ b/packages/pds/tests/proxied/__snapshots__/views.test.ts.snap @@ -2371,40 +2371,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", }, ], @@ -2416,36 +2416,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, }, }, @@ -2453,7 +2453,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, }, From 280eb5aa46c71e4763172eb5caddbbc80dffd13f Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Tue, 5 Sep 2023 10:47:55 -0400 Subject: [PATCH 27/49] fix snap --- .../bsky/tests/views/__snapshots__/block-lists.test.ts.snap | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/bsky/tests/views/__snapshots__/block-lists.test.ts.snap b/packages/bsky/tests/views/__snapshots__/block-lists.test.ts.snap index dff579ef9b5..fae6e7f4fa9 100644 --- a/packages/bsky/tests/views/__snapshots__/block-lists.test.ts.snap +++ b/packages/bsky/tests/views/__snapshots__/block-lists.test.ts.snap @@ -294,9 +294,11 @@ 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)", @@ -336,9 +338,11 @@ Object { "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)", From 0dfa73b3f6916e625705e9d01379f4258b3c0871 Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Tue, 5 Sep 2023 23:45:14 -0400 Subject: [PATCH 28/49] make algos return feed items, save work in getFeed --- .../bsky/src/api/app/bsky/feed/getFeed.ts | 47 +++++++++---------- packages/bsky/src/feed-gen/best-of-follows.ts | 4 +- packages/bsky/src/feed-gen/bsky-team.ts | 4 +- packages/bsky/src/feed-gen/hot-classic.ts | 4 +- packages/bsky/src/feed-gen/mutuals.ts | 4 +- packages/bsky/src/feed-gen/types.ts | 3 +- packages/bsky/src/feed-gen/whats-hot.ts | 9 +++- packages/bsky/src/feed-gen/with-friends.ts | 4 +- 8 files changed, 42 insertions(+), 37 deletions(-) diff --git a/packages/bsky/src/api/app/bsky/feed/getFeed.ts b/packages/bsky/src/api/app/bsky/feed/getFeed.ts index e875e385247..8af159decd3 100644 --- a/packages/bsky/src/api/app/bsky/feed/getFeed.ts +++ b/packages/bsky/src/api/app/bsky/feed/getFeed.ts @@ -65,22 +65,20 @@ const skeleton = async ( ctx: Context, ): Promise => { const timerSkele = new ServerTimer('skele').start() - const { db } = ctx const localAlgo = ctx.appCtx.algos[params.feed] const feedParams: GetFeedParams = { feed: params.feed, limit: params.limit, cursor: params.cursor, } - const skeleton = + const { feedItems, cursor, ...passthrough } = localAlgo !== undefined ? await localAlgo(ctx.appCtx, params, params.viewer) - : await skeletonFromFeedGen(ctx.appCtx, db, feedParams, ctx.authorization) - const { feed, cursor, ...passthrough } = skeleton + : await skeletonFromFeedGen(ctx, feedParams) return { params, cursor, - feedSkele: feed, + feedItems, timerSkele: timerSkele.stop(), passthrough, } @@ -89,14 +87,13 @@ const skeleton = async ( const hydration = async (state: SkeletonState, ctx: Context) => { const timerHydr = new ServerTimer('hydr').start() const { feedService } = ctx - const { params, feedSkele } = state - const feedItems = await cleanFeedSkeleton(feedSkele, ctx) + const { params, feedItems } = state const refs = feedService.feedItemRefs(feedItems) const hydrated = await feedService.feedHydration({ ...refs, viewer: params.viewer, }) - return { ...state, ...hydrated, feedItems, timerHydr: timerHydr.stop() } + return { ...state, ...hydrated, timerHydr: timerHydr.stop() } } const noBlocksOrMutes = (state: HydrationState) => { @@ -137,7 +134,7 @@ type Params = GetFeedParams & { viewer: string } type SkeletonState = { params: Params - feedSkele: SkeletonFeedPost[] + feedItems: FeedRow[] passthrough: Record // pass through additional items in feedgen response cursor?: string timerSkele: ServerTimer @@ -147,11 +144,10 @@ type HydrationState = SkeletonState & FeedHydrationState & { feedItems: FeedRow[]; timerHydr: ServerTimer } const skeletonFromFeedGen = async ( - ctx: AppContext, - db: Database, + ctx: Context, params: GetFeedParams, - authorization?: string, ): Promise => { + const { db, appCtx, authorization } = ctx const { feed } = params // Resolve and fetch feed skeleton const found = await db.db @@ -166,7 +162,7 @@ const skeletonFromFeedGen = async ( 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}`) @@ -190,7 +186,7 @@ const skeletonFromFeedGen = async ( 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, @@ -214,27 +210,30 @@ const skeletonFromFeedGen = async ( throw err } - return { - ...skeleton, - feed: skeleton.feed.slice(0, params.limit), // enforce limit - } + const { feed: feedSkele, ...skele } = skeleton + const feedItems = await skeletonToFeedItems( + feedSkele.slice(0, params.limit), + ctx, + ) + + return { ...skele, feedItems } } -const cleanFeedSkeleton = async ( +const skeletonToFeedItems = async ( skeleton: SkeletonFeedPost[], ctx: Context, ): Promise => { const { feedService } = ctx const feedItemUris = skeleton.map(getSkeleFeedItemUri) - const feedItems = await feedService.getFeedItems(feedItemUris) - const cleaned: FeedRow[] = [] + const feedItemsRaw = await feedService.getFeedItems(feedItemUris) + const results: FeedRow[] = [] for (const skeleItem of skeleton) { - const feedItem = feedItems[getSkeleFeedItemUri(skeleItem)] + const feedItem = feedItemsRaw[getSkeleFeedItemUri(skeleItem)] if (feedItem && feedItem.postUri === skeleItem.post) { - cleaned.push(feedItem) + results.push(feedItem) } } - return cleaned + return results } const getSkeleFeedItemUri = (item: SkeletonFeedPost) => { diff --git a/packages/bsky/src/feed-gen/best-of-follows.ts b/packages/bsky/src/feed-gen/best-of-follows.ts index d40fc58e860..c1d4ee4d21b 100644 --- a/packages/bsky/src/feed-gen/best-of-follows.ts +++ b/packages/bsky/src/feed-gen/best-of-follows.ts @@ -1,6 +1,6 @@ import { InvalidRequestError } from '@atproto/xrpc-server' import { QueryParams as SkeletonParams } from '../lexicon/types/app/bsky/feed/getFeedSkeleton' -import { AlgoHandler, AlgoResponse, toSkeletonItem } from './types' +import { AlgoHandler, AlgoResponse } from './types' import { GenericKeyset, paginate } from '../db/pagination' import AppContext from '../context' @@ -39,7 +39,7 @@ const handler: AlgoHandler = async ( const feedItems = await builder.execute() return { - feed: feedItems.map(toSkeletonItem), + feedItems, cursor: keyset.packFromResult(feedItems), } } diff --git a/packages/bsky/src/feed-gen/bsky-team.ts b/packages/bsky/src/feed-gen/bsky-team.ts index a00546dd692..3592dd42e26 100644 --- a/packages/bsky/src/feed-gen/bsky-team.ts +++ b/packages/bsky/src/feed-gen/bsky-team.ts @@ -2,7 +2,7 @@ import { NotEmptyArray } from '@atproto/common' import { QueryParams as SkeletonParams } from '../lexicon/types/app/bsky/feed/getFeedSkeleton' import AppContext from '../context' import { paginate } from '../db/pagination' -import { AlgoHandler, AlgoResponse, toSkeletonItem } from './types' +import { AlgoHandler, AlgoResponse } from './types' import { FeedKeyset } from '../api/app/bsky/util/feed' const BSKY_TEAM: NotEmptyArray = [ @@ -34,7 +34,7 @@ const handler: AlgoHandler = async ( const feedItems = await feedQb.execute() return { - feed: feedItems.map(toSkeletonItem), + 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 265be4bfa5d..c042cea7116 100644 --- a/packages/bsky/src/feed-gen/hot-classic.ts +++ b/packages/bsky/src/feed-gen/hot-classic.ts @@ -2,7 +2,7 @@ import AppContext from '../context' import { NotEmptyArray } from '@atproto/common' import { QueryParams as SkeletonParams } from '../lexicon/types/app/bsky/feed/getFeedSkeleton' import { paginate } from '../db/pagination' -import { AlgoHandler, AlgoResponse, toSkeletonItem } from './types' +import { AlgoHandler, AlgoResponse } from './types' import { FeedKeyset } from '../api/app/bsky/util/feed' import { valuesList } from '../db/util' @@ -47,7 +47,7 @@ const handler: AlgoHandler = async ( const feedItems = await feedQb.execute() return { - feed: feedItems.map(toSkeletonItem), + feedItems, cursor: keyset.packFromResult(feedItems), } } diff --git a/packages/bsky/src/feed-gen/mutuals.ts b/packages/bsky/src/feed-gen/mutuals.ts index 623b0cfb67e..65a3311a524 100644 --- a/packages/bsky/src/feed-gen/mutuals.ts +++ b/packages/bsky/src/feed-gen/mutuals.ts @@ -1,7 +1,7 @@ import { QueryParams as SkeletonParams } from '../lexicon/types/app/bsky/feed/getFeedSkeleton' import AppContext from '../context' import { paginate } from '../db/pagination' -import { AlgoHandler, AlgoResponse, toSkeletonItem } from './types' +import { AlgoHandler, AlgoResponse } from './types' import { FeedKeyset, getFeedDateThreshold } from '../api/app/bsky/util/feed' const handler: AlgoHandler = async ( @@ -45,7 +45,7 @@ const handler: AlgoHandler = async ( const feedItems = await feedQb.execute() return { - feed: feedItems.map(toSkeletonItem), + feedItems, cursor: keyset.packFromResult(feedItems), } } diff --git a/packages/bsky/src/feed-gen/types.ts b/packages/bsky/src/feed-gen/types.ts index 9f4c91716d5..11ebf53fb39 100644 --- a/packages/bsky/src/feed-gen/types.ts +++ b/packages/bsky/src/feed-gen/types.ts @@ -1,9 +1,10 @@ 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' export type AlgoResponse = { - feed: SkeletonFeedPost[] + feedItems: FeedRow[] cursor?: string } diff --git a/packages/bsky/src/feed-gen/whats-hot.ts b/packages/bsky/src/feed-gen/whats-hot.ts index bf74bc02e8d..511c767804e 100644 --- a/packages/bsky/src/feed-gen/whats-hot.ts +++ b/packages/bsky/src/feed-gen/whats-hot.ts @@ -1,7 +1,7 @@ import { NotEmptyArray } from '@atproto/common' import { InvalidRequestError } from '@atproto/xrpc-server' import { QueryParams as SkeletonParams } from '../lexicon/types/app/bsky/feed/getFeedSkeleton' -import { AlgoHandler, AlgoResponse, toSkeletonItem } from './types' +import { AlgoHandler, AlgoResponse } from './types' import { GenericKeyset, paginate } from '../db/pagination' import AppContext from '../context' import { valuesList } from '../db/util' @@ -51,6 +51,11 @@ const handler: AlgoHandler = async ( sql`${'post'}`.as('type'), 'post.uri as uri', 'post.uri as postUri', + 'post.creator as originatorDid', + 'post.creator as postAuthorDid', + 'post.replyParent as replyParent', + 'post.replyRoot as replyRoot', + 'post.indexedAt as sortAt', 'candidate.score', 'candidate.cid', ]) @@ -61,7 +66,7 @@ const handler: AlgoHandler = async ( const feedItems = await builder.execute() return { - feed: feedItems.map(toSkeletonItem), + feedItems, cursor: keyset.packFromResult(feedItems), } } diff --git a/packages/bsky/src/feed-gen/with-friends.ts b/packages/bsky/src/feed-gen/with-friends.ts index 08892a78a91..98f784102a5 100644 --- a/packages/bsky/src/feed-gen/with-friends.ts +++ b/packages/bsky/src/feed-gen/with-friends.ts @@ -1,7 +1,7 @@ import AppContext from '../context' import { QueryParams as SkeletonParams } from '../lexicon/types/app/bsky/feed/getFeedSkeleton' import { paginate } from '../db/pagination' -import { AlgoHandler, AlgoResponse, toSkeletonItem } from './types' +import { AlgoHandler, AlgoResponse } from './types' import { FeedKeyset, getFeedDateThreshold } from '../api/app/bsky/util/feed' const handler: AlgoHandler = async ( @@ -31,7 +31,7 @@ const handler: AlgoHandler = async ( const feedItems = await postsQb.execute() return { - feed: feedItems.map(toSkeletonItem), + feedItems, cursor: keyset.packFromResult(feedItems), } } From 025978562f4534f1699560d2a3139b31ab2ecc48 Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Wed, 6 Sep 2023 00:28:55 -0400 Subject: [PATCH 29/49] misc changes, tidy --- packages/bsky/src/api/app/bsky/actor/getSuggestions.ts | 8 +++++--- packages/bsky/src/api/app/bsky/feed/getPostThread.ts | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/bsky/src/api/app/bsky/actor/getSuggestions.ts b/packages/bsky/src/api/app/bsky/actor/getSuggestions.ts index 51ca7b46e27..18ab99debe2 100644 --- a/packages/bsky/src/api/app/bsky/actor/getSuggestions.ts +++ b/packages/bsky/src/api/app/bsky/actor/getSuggestions.ts @@ -13,7 +13,7 @@ export default function (server: Server, ctx: AppContext) { const getSuggestions = createPipeline( skeleton, hydration, - noBlocks, + noBlocksOrMutes, presentation, ) server.app.bsky.actor.getSuggestions({ @@ -93,11 +93,13 @@ const hydration = async (state: SkeletonState, ctx: Context) => { return { ...state, bam, actors } } -const noBlocks = (state: HydrationState) => { +const noBlocksOrMutes = (state: HydrationState) => { const { viewer } = state.params if (!viewer) return state state.suggestions = state.suggestions.filter( - (item) => !state.bam.block([viewer, item.did]), + (item) => + !state.bam.block([viewer, item.did]) && + !state.bam.mute([viewer, item.did]), ) return state } diff --git a/packages/bsky/src/api/app/bsky/feed/getPostThread.ts b/packages/bsky/src/api/app/bsky/feed/getPostThread.ts index 8883fac18bb..5c3b4b178e9 100644 --- a/packages/bsky/src/api/app/bsky/feed/getPostThread.ts +++ b/packages/bsky/src/api/app/bsky/feed/getPostThread.ts @@ -26,7 +26,7 @@ export default function (server: Server, ctx: AppContext) { const getPostThread = createPipeline( skeleton, hydration, - noRules, + noRules, // handled in presentation: 3p block-violating replies are turned to #blockedPost, viewer blocks turned to #notFoundPost. presentation, ) server.app.bsky.feed.getPostThread({ From c84bc28dcdcc95ecc6e3166a327035de7c7d9005 Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Wed, 6 Sep 2023 01:29:29 -0400 Subject: [PATCH 30/49] tidy --- packages/bsky/src/api/app/bsky/feed/getFeedSkeleton.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/bsky/src/api/app/bsky/feed/getFeedSkeleton.ts b/packages/bsky/src/api/app/bsky/feed/getFeedSkeleton.ts index 998f787aa3f..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({ @@ -19,7 +20,8 @@ export default function (server: Server, ctx: AppContext) { return { encoding: 'application/json', body: { - feed: result.feed, // @TODO should we proactively filter blocks/mutes from the skeleton, or treat this similar to other cusotm feeds? + // @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, }, } From 447d7a5b07ac7d477b09fb96dd53089a2106cbef Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Wed, 6 Sep 2023 11:56:05 -0400 Subject: [PATCH 31/49] fix aturi import --- packages/bsky/src/services/indexing/plugins/list-block.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/bsky/src/services/indexing/plugins/list-block.ts b/packages/bsky/src/services/indexing/plugins/list-block.ts index 6655ec62350..4285ca8d4bc 100644 --- a/packages/bsky/src/services/indexing/plugins/list-block.ts +++ b/packages/bsky/src/services/indexing/plugins/list-block.ts @@ -1,5 +1,5 @@ import { Selectable } from 'kysely' -import { AtUri } from '@atproto/uri' +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' From ce1f73f1b3cc8a103ad83f6ee811275318afcc33 Mon Sep 17 00:00:00 2001 From: dholms Date: Wed, 6 Sep 2023 10:56:53 -0500 Subject: [PATCH 32/49] lex --- lexicons/app/bsky/feed/getListFeed.json | 36 +++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 lexicons/app/bsky/feed/getListFeed.json diff --git a/lexicons/app/bsky/feed/getListFeed.json b/lexicons/app/bsky/feed/getListFeed.json new file mode 100644 index 00000000000..4cf36ea625a --- /dev/null +++ b/lexicons/app/bsky/feed/getListFeed.json @@ -0,0 +1,36 @@ +{ + "lexicon": 1, + "id": "app.bsky.feed.getListFeed", + "defs": { + "main": { + "type": "query", + "description": "A view of a recent posts from actors in a list", + "parameters": { + "type": "params", + "required": ["list"], + "properties": { + "list": {"type": "string", "format": "at-uri"}, + "limit": {"type": "integer", "minimum": 1, "maximum": 100, "default": 50}, + "cursor": {"type": "string"} + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["feed"], + "properties": { + "cursor": {"type": "string"}, + "feed": { + "type": "array", + "items": {"type": "ref", "ref": "app.bsky.feed.defs#feedViewPost"} + } + } + } + }, + "errors": [ + {"name": "UnknownList"} + ] + } + } +} From e6f842049f356d3e43bc71531d3b3f9553de1292 Mon Sep 17 00:00:00 2001 From: dholms Date: Wed, 6 Sep 2023 10:58:45 -0500 Subject: [PATCH 33/49] list purpose --- lexicons/app/bsky/graph/defs.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lexicons/app/bsky/graph/defs.json b/lexicons/app/bsky/graph/defs.json index a9c112f65dc..d71672774fd 100644 --- a/lexicons/app/bsky/graph/defs.json +++ b/lexicons/app/bsky/graph/defs.json @@ -44,13 +44,18 @@ "listPurpose": { "type": "string", "knownValues": [ - "app.bsky.graph.defs#modlist" + "app.bsky.graph.defs#modlist", + "app.bsky.graph.defs#curatelist" ] }, "modlist": { "type": "token", "description": "A list of actors to apply an aggregate moderation action (mute/block) on" }, + "curatelist": { + "type": "token", + "description": "A list of actors used for curation purposes such as list feeds or interaction gating" + }, "listViewerState": { "type": "object", "properties": { From 1eccea022dcbd1c257a131740dc164cf22fa1a03 Mon Sep 17 00:00:00 2001 From: dholms Date: Wed, 6 Sep 2023 19:31:55 -0500 Subject: [PATCH 34/49] lex gen --- packages/api/src/client/index.ts | 14 ++++ packages/api/src/client/lexicons.ts | 64 ++++++++++++++++++- .../client/types/app/bsky/feed/getListFeed.ts | 46 +++++++++++++ .../src/client/types/app/bsky/graph/defs.ts | 7 +- packages/bsky/src/lexicon/index.ts | 13 ++++ packages/bsky/src/lexicon/lexicons.ts | 64 ++++++++++++++++++- .../types/app/bsky/feed/getListFeed.ts | 50 +++++++++++++++ .../src/lexicon/types/app/bsky/graph/defs.ts | 7 +- packages/pds/src/lexicon/index.ts | 13 ++++ packages/pds/src/lexicon/lexicons.ts | 64 ++++++++++++++++++- .../types/app/bsky/feed/getListFeed.ts | 50 +++++++++++++++ .../src/lexicon/types/app/bsky/graph/defs.ts | 7 +- 12 files changed, 393 insertions(+), 6 deletions(-) create mode 100644 packages/api/src/client/types/app/bsky/feed/getListFeed.ts create mode 100644 packages/bsky/src/lexicon/types/app/bsky/feed/getListFeed.ts create mode 100644 packages/pds/src/lexicon/types/app/bsky/feed/getListFeed.ts diff --git a/packages/api/src/client/index.ts b/packages/api/src/client/index.ts index f2921d3e5a5..8d976a64934 100644 --- a/packages/api/src/client/index.ts +++ b/packages/api/src/client/index.ts @@ -95,6 +95,7 @@ import * as AppBskyFeedGetFeedGenerator from './types/app/bsky/feed/getFeedGener import * as AppBskyFeedGetFeedGenerators from './types/app/bsky/feed/getFeedGenerators' import * as AppBskyFeedGetFeedSkeleton from './types/app/bsky/feed/getFeedSkeleton' import * as AppBskyFeedGetLikes from './types/app/bsky/feed/getLikes' +import * as AppBskyFeedGetListFeed from './types/app/bsky/feed/getListFeed' import * as AppBskyFeedGetPostThread from './types/app/bsky/feed/getPostThread' import * as AppBskyFeedGetPosts from './types/app/bsky/feed/getPosts' import * as AppBskyFeedGetRepostedBy from './types/app/bsky/feed/getRepostedBy' @@ -219,6 +220,7 @@ export * as AppBskyFeedGetFeedGenerator from './types/app/bsky/feed/getFeedGener export * as AppBskyFeedGetFeedGenerators from './types/app/bsky/feed/getFeedGenerators' export * as AppBskyFeedGetFeedSkeleton from './types/app/bsky/feed/getFeedSkeleton' export * as AppBskyFeedGetLikes from './types/app/bsky/feed/getLikes' +export * as AppBskyFeedGetListFeed from './types/app/bsky/feed/getListFeed' export * as AppBskyFeedGetPostThread from './types/app/bsky/feed/getPostThread' export * as AppBskyFeedGetPosts from './types/app/bsky/feed/getPosts' export * as AppBskyFeedGetRepostedBy from './types/app/bsky/feed/getRepostedBy' @@ -271,6 +273,7 @@ export const COM_ATPROTO_MODERATION = { } export const APP_BSKY_GRAPH = { DefsModlist: 'app.bsky.graph.defs#modlist', + DefsCuratelist: 'app.bsky.graph.defs#curatelist', } export class AtpBaseClient { @@ -1330,6 +1333,17 @@ export class FeedNS { }) } + getListFeed( + params?: AppBskyFeedGetListFeed.QueryParams, + opts?: AppBskyFeedGetListFeed.CallOptions, + ): Promise { + return this._service.xrpc + .call('app.bsky.feed.getListFeed', params, undefined, opts) + .catch((e) => { + throw AppBskyFeedGetListFeed.toKnownErr(e) + }) + } + getPostThread( params?: AppBskyFeedGetPostThread.QueryParams, opts?: AppBskyFeedGetPostThread.CallOptions, diff --git a/packages/api/src/client/lexicons.ts b/packages/api/src/client/lexicons.ts index 3e694cb9f4c..209a7bd283c 100644 --- a/packages/api/src/client/lexicons.ts +++ b/packages/api/src/client/lexicons.ts @@ -5177,6 +5177,59 @@ export const schemaDict = { }, }, }, + AppBskyFeedGetListFeed: { + lexicon: 1, + id: 'app.bsky.feed.getListFeed', + defs: { + main: { + type: 'query', + description: 'A view of a recent posts from actors in a list', + parameters: { + type: 'params', + required: ['list'], + properties: { + list: { + type: 'string', + format: 'at-uri', + }, + limit: { + type: 'integer', + minimum: 1, + maximum: 100, + default: 50, + }, + cursor: { + type: 'string', + }, + }, + }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['feed'], + properties: { + cursor: { + type: 'string', + }, + feed: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:app.bsky.feed.defs#feedViewPost', + }, + }, + }, + }, + }, + errors: [ + { + name: 'UnknownList', + }, + ], + }, + }, + }, AppBskyFeedGetPostThread: { lexicon: 1, id: 'app.bsky.feed.getPostThread', @@ -5704,13 +5757,21 @@ export const schemaDict = { }, listPurpose: { type: 'string', - knownValues: ['app.bsky.graph.defs#modlist'], + knownValues: [ + 'app.bsky.graph.defs#modlist', + 'app.bsky.graph.defs#curatelist', + ], }, modlist: { type: 'token', description: 'A list of actors to apply an aggregate moderation action (mute/block) on', }, + curatelist: { + type: 'token', + description: + 'A list of actors used for curation purposes such as list feeds or interaction gating', + }, listViewerState: { type: 'object', properties: { @@ -6844,6 +6905,7 @@ export const ids = { AppBskyFeedGetFeedGenerators: 'app.bsky.feed.getFeedGenerators', AppBskyFeedGetFeedSkeleton: 'app.bsky.feed.getFeedSkeleton', AppBskyFeedGetLikes: 'app.bsky.feed.getLikes', + AppBskyFeedGetListFeed: 'app.bsky.feed.getListFeed', AppBskyFeedGetPostThread: 'app.bsky.feed.getPostThread', AppBskyFeedGetPosts: 'app.bsky.feed.getPosts', AppBskyFeedGetRepostedBy: 'app.bsky.feed.getRepostedBy', diff --git a/packages/api/src/client/types/app/bsky/feed/getListFeed.ts b/packages/api/src/client/types/app/bsky/feed/getListFeed.ts new file mode 100644 index 00000000000..511e9526c6d --- /dev/null +++ b/packages/api/src/client/types/app/bsky/feed/getListFeed.ts @@ -0,0 +1,46 @@ +/** + * 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 AppBskyFeedDefs from './defs' + +export interface QueryParams { + list: string + limit?: number + cursor?: string +} + +export type InputSchema = undefined + +export interface OutputSchema { + cursor?: string + feed: AppBskyFeedDefs.FeedViewPost[] + [k: string]: unknown +} + +export interface CallOptions { + headers?: Headers +} + +export interface Response { + success: boolean + headers: Headers + data: OutputSchema +} + +export class UnknownListError extends XRPCError { + constructor(src: XRPCError) { + super(src.status, src.error, src.message, src.headers) + } +} + +export function toKnownErr(e: any) { + if (e instanceof XRPCError) { + if (e.error === 'UnknownList') return new UnknownListError(e) + } + return e +} 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 566ea2446d8..fb40758534f 100644 --- a/packages/api/src/client/types/app/bsky/graph/defs.ts +++ b/packages/api/src/client/types/app/bsky/graph/defs.ts @@ -74,10 +74,15 @@ export function validateListItemView(v: unknown): ValidationResult { return lexicons.validate('app.bsky.graph.defs#listItemView', v) } -export type ListPurpose = 'app.bsky.graph.defs#modlist' | (string & {}) +export type ListPurpose = + | 'app.bsky.graph.defs#modlist' + | 'app.bsky.graph.defs#curatelist' + | (string & {}) /** A list of actors to apply an aggregate moderation action (mute/block) on */ export const MODLIST = 'app.bsky.graph.defs#modlist' +/** A list of actors used for curation purposes such as list feeds or interaction gating */ +export const CURATELIST = 'app.bsky.graph.defs#curatelist' export interface ListViewerState { muted?: boolean diff --git a/packages/bsky/src/lexicon/index.ts b/packages/bsky/src/lexicon/index.ts index df15a497c63..e58133b8c5e 100644 --- a/packages/bsky/src/lexicon/index.ts +++ b/packages/bsky/src/lexicon/index.ts @@ -84,6 +84,7 @@ import * as AppBskyFeedGetFeedGenerator from './types/app/bsky/feed/getFeedGener import * as AppBskyFeedGetFeedGenerators from './types/app/bsky/feed/getFeedGenerators' import * as AppBskyFeedGetFeedSkeleton from './types/app/bsky/feed/getFeedSkeleton' import * as AppBskyFeedGetLikes from './types/app/bsky/feed/getLikes' +import * as AppBskyFeedGetListFeed from './types/app/bsky/feed/getListFeed' import * as AppBskyFeedGetPostThread from './types/app/bsky/feed/getPostThread' import * as AppBskyFeedGetPosts from './types/app/bsky/feed/getPosts' import * as AppBskyFeedGetRepostedBy from './types/app/bsky/feed/getRepostedBy' @@ -126,6 +127,7 @@ export const COM_ATPROTO_MODERATION = { } export const APP_BSKY_GRAPH = { DefsModlist: 'app.bsky.graph.defs#modlist', + DefsCuratelist: 'app.bsky.graph.defs#curatelist', } export function createServer(options?: XrpcOptions): Server { @@ -1122,6 +1124,17 @@ export class FeedNS { return this._server.xrpc.method(nsid, cfg) } + getListFeed( + cfg: ConfigOf< + AV, + AppBskyFeedGetListFeed.Handler>, + AppBskyFeedGetListFeed.HandlerReqCtx> + >, + ) { + const nsid = 'app.bsky.feed.getListFeed' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } + getPostThread( cfg: ConfigOf< AV, diff --git a/packages/bsky/src/lexicon/lexicons.ts b/packages/bsky/src/lexicon/lexicons.ts index 3e694cb9f4c..209a7bd283c 100644 --- a/packages/bsky/src/lexicon/lexicons.ts +++ b/packages/bsky/src/lexicon/lexicons.ts @@ -5177,6 +5177,59 @@ export const schemaDict = { }, }, }, + AppBskyFeedGetListFeed: { + lexicon: 1, + id: 'app.bsky.feed.getListFeed', + defs: { + main: { + type: 'query', + description: 'A view of a recent posts from actors in a list', + parameters: { + type: 'params', + required: ['list'], + properties: { + list: { + type: 'string', + format: 'at-uri', + }, + limit: { + type: 'integer', + minimum: 1, + maximum: 100, + default: 50, + }, + cursor: { + type: 'string', + }, + }, + }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['feed'], + properties: { + cursor: { + type: 'string', + }, + feed: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:app.bsky.feed.defs#feedViewPost', + }, + }, + }, + }, + }, + errors: [ + { + name: 'UnknownList', + }, + ], + }, + }, + }, AppBskyFeedGetPostThread: { lexicon: 1, id: 'app.bsky.feed.getPostThread', @@ -5704,13 +5757,21 @@ export const schemaDict = { }, listPurpose: { type: 'string', - knownValues: ['app.bsky.graph.defs#modlist'], + knownValues: [ + 'app.bsky.graph.defs#modlist', + 'app.bsky.graph.defs#curatelist', + ], }, modlist: { type: 'token', description: 'A list of actors to apply an aggregate moderation action (mute/block) on', }, + curatelist: { + type: 'token', + description: + 'A list of actors used for curation purposes such as list feeds or interaction gating', + }, listViewerState: { type: 'object', properties: { @@ -6844,6 +6905,7 @@ export const ids = { AppBskyFeedGetFeedGenerators: 'app.bsky.feed.getFeedGenerators', AppBskyFeedGetFeedSkeleton: 'app.bsky.feed.getFeedSkeleton', AppBskyFeedGetLikes: 'app.bsky.feed.getLikes', + AppBskyFeedGetListFeed: 'app.bsky.feed.getListFeed', AppBskyFeedGetPostThread: 'app.bsky.feed.getPostThread', AppBskyFeedGetPosts: 'app.bsky.feed.getPosts', AppBskyFeedGetRepostedBy: 'app.bsky.feed.getRepostedBy', diff --git a/packages/bsky/src/lexicon/types/app/bsky/feed/getListFeed.ts b/packages/bsky/src/lexicon/types/app/bsky/feed/getListFeed.ts new file mode 100644 index 00000000000..e24c3f8ed22 --- /dev/null +++ b/packages/bsky/src/lexicon/types/app/bsky/feed/getListFeed.ts @@ -0,0 +1,50 @@ +/** + * 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 AppBskyFeedDefs from './defs' + +export interface QueryParams { + list: string + limit: number + cursor?: string +} + +export type InputSchema = undefined + +export interface OutputSchema { + cursor?: string + feed: AppBskyFeedDefs.FeedViewPost[] + [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 + error?: 'UnknownList' +} + +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/defs.ts b/packages/bsky/src/lexicon/types/app/bsky/graph/defs.ts index 63c05b5faa3..121d9db200a 100644 --- a/packages/bsky/src/lexicon/types/app/bsky/graph/defs.ts +++ b/packages/bsky/src/lexicon/types/app/bsky/graph/defs.ts @@ -74,10 +74,15 @@ export function validateListItemView(v: unknown): ValidationResult { return lexicons.validate('app.bsky.graph.defs#listItemView', v) } -export type ListPurpose = 'app.bsky.graph.defs#modlist' | (string & {}) +export type ListPurpose = + | 'app.bsky.graph.defs#modlist' + | 'app.bsky.graph.defs#curatelist' + | (string & {}) /** A list of actors to apply an aggregate moderation action (mute/block) on */ export const MODLIST = 'app.bsky.graph.defs#modlist' +/** A list of actors used for curation purposes such as list feeds or interaction gating */ +export const CURATELIST = 'app.bsky.graph.defs#curatelist' export interface ListViewerState { muted?: boolean diff --git a/packages/pds/src/lexicon/index.ts b/packages/pds/src/lexicon/index.ts index df15a497c63..e58133b8c5e 100644 --- a/packages/pds/src/lexicon/index.ts +++ b/packages/pds/src/lexicon/index.ts @@ -84,6 +84,7 @@ import * as AppBskyFeedGetFeedGenerator from './types/app/bsky/feed/getFeedGener import * as AppBskyFeedGetFeedGenerators from './types/app/bsky/feed/getFeedGenerators' import * as AppBskyFeedGetFeedSkeleton from './types/app/bsky/feed/getFeedSkeleton' import * as AppBskyFeedGetLikes from './types/app/bsky/feed/getLikes' +import * as AppBskyFeedGetListFeed from './types/app/bsky/feed/getListFeed' import * as AppBskyFeedGetPostThread from './types/app/bsky/feed/getPostThread' import * as AppBskyFeedGetPosts from './types/app/bsky/feed/getPosts' import * as AppBskyFeedGetRepostedBy from './types/app/bsky/feed/getRepostedBy' @@ -126,6 +127,7 @@ export const COM_ATPROTO_MODERATION = { } export const APP_BSKY_GRAPH = { DefsModlist: 'app.bsky.graph.defs#modlist', + DefsCuratelist: 'app.bsky.graph.defs#curatelist', } export function createServer(options?: XrpcOptions): Server { @@ -1122,6 +1124,17 @@ export class FeedNS { return this._server.xrpc.method(nsid, cfg) } + getListFeed( + cfg: ConfigOf< + AV, + AppBskyFeedGetListFeed.Handler>, + AppBskyFeedGetListFeed.HandlerReqCtx> + >, + ) { + const nsid = 'app.bsky.feed.getListFeed' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } + getPostThread( cfg: ConfigOf< AV, diff --git a/packages/pds/src/lexicon/lexicons.ts b/packages/pds/src/lexicon/lexicons.ts index 3e694cb9f4c..209a7bd283c 100644 --- a/packages/pds/src/lexicon/lexicons.ts +++ b/packages/pds/src/lexicon/lexicons.ts @@ -5177,6 +5177,59 @@ export const schemaDict = { }, }, }, + AppBskyFeedGetListFeed: { + lexicon: 1, + id: 'app.bsky.feed.getListFeed', + defs: { + main: { + type: 'query', + description: 'A view of a recent posts from actors in a list', + parameters: { + type: 'params', + required: ['list'], + properties: { + list: { + type: 'string', + format: 'at-uri', + }, + limit: { + type: 'integer', + minimum: 1, + maximum: 100, + default: 50, + }, + cursor: { + type: 'string', + }, + }, + }, + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['feed'], + properties: { + cursor: { + type: 'string', + }, + feed: { + type: 'array', + items: { + type: 'ref', + ref: 'lex:app.bsky.feed.defs#feedViewPost', + }, + }, + }, + }, + }, + errors: [ + { + name: 'UnknownList', + }, + ], + }, + }, + }, AppBskyFeedGetPostThread: { lexicon: 1, id: 'app.bsky.feed.getPostThread', @@ -5704,13 +5757,21 @@ export const schemaDict = { }, listPurpose: { type: 'string', - knownValues: ['app.bsky.graph.defs#modlist'], + knownValues: [ + 'app.bsky.graph.defs#modlist', + 'app.bsky.graph.defs#curatelist', + ], }, modlist: { type: 'token', description: 'A list of actors to apply an aggregate moderation action (mute/block) on', }, + curatelist: { + type: 'token', + description: + 'A list of actors used for curation purposes such as list feeds or interaction gating', + }, listViewerState: { type: 'object', properties: { @@ -6844,6 +6905,7 @@ export const ids = { AppBskyFeedGetFeedGenerators: 'app.bsky.feed.getFeedGenerators', AppBskyFeedGetFeedSkeleton: 'app.bsky.feed.getFeedSkeleton', AppBskyFeedGetLikes: 'app.bsky.feed.getLikes', + AppBskyFeedGetListFeed: 'app.bsky.feed.getListFeed', AppBskyFeedGetPostThread: 'app.bsky.feed.getPostThread', AppBskyFeedGetPosts: 'app.bsky.feed.getPosts', AppBskyFeedGetRepostedBy: 'app.bsky.feed.getRepostedBy', diff --git a/packages/pds/src/lexicon/types/app/bsky/feed/getListFeed.ts b/packages/pds/src/lexicon/types/app/bsky/feed/getListFeed.ts new file mode 100644 index 00000000000..e24c3f8ed22 --- /dev/null +++ b/packages/pds/src/lexicon/types/app/bsky/feed/getListFeed.ts @@ -0,0 +1,50 @@ +/** + * 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 AppBskyFeedDefs from './defs' + +export interface QueryParams { + list: string + limit: number + cursor?: string +} + +export type InputSchema = undefined + +export interface OutputSchema { + cursor?: string + feed: AppBskyFeedDefs.FeedViewPost[] + [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 + error?: 'UnknownList' +} + +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/defs.ts b/packages/pds/src/lexicon/types/app/bsky/graph/defs.ts index 63c05b5faa3..121d9db200a 100644 --- a/packages/pds/src/lexicon/types/app/bsky/graph/defs.ts +++ b/packages/pds/src/lexicon/types/app/bsky/graph/defs.ts @@ -74,10 +74,15 @@ export function validateListItemView(v: unknown): ValidationResult { return lexicons.validate('app.bsky.graph.defs#listItemView', v) } -export type ListPurpose = 'app.bsky.graph.defs#modlist' | (string & {}) +export type ListPurpose = + | 'app.bsky.graph.defs#modlist' + | 'app.bsky.graph.defs#curatelist' + | (string & {}) /** A list of actors to apply an aggregate moderation action (mute/block) on */ export const MODLIST = 'app.bsky.graph.defs#modlist' +/** A list of actors used for curation purposes such as list feeds or interaction gating */ +export const CURATELIST = 'app.bsky.graph.defs#curatelist' export interface ListViewerState { muted?: boolean From 59bcb5d697379cb1b6a85204135f0f8907b04a71 Mon Sep 17 00:00:00 2001 From: dholms Date: Wed, 6 Sep 2023 19:38:08 -0500 Subject: [PATCH 35/49] add route --- .../bsky/src/api/app/bsky/feed/getListFeed.ts | 138 ++++++++++++++++++ packages/bsky/src/api/index.ts | 2 + 2 files changed, 140 insertions(+) create mode 100644 packages/bsky/src/api/app/bsky/feed/getListFeed.ts diff --git a/packages/bsky/src/api/app/bsky/feed/getListFeed.ts b/packages/bsky/src/api/app/bsky/feed/getListFeed.ts new file mode 100644 index 00000000000..833fc33dd1b --- /dev/null +++ b/packages/bsky/src/api/app/bsky/feed/getListFeed.ts @@ -0,0 +1,138 @@ +import { Server } from '../../../../lexicon' +import { QueryParams } from '../../../../lexicon/types/app/bsky/feed/getListFeed' +import { FeedKeyset, getFeedDateThreshold } from '../util/feed' +import { paginate } from '../../../../db/pagination' +import AppContext from '../../../../context' +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 getListFeed = createPipeline( + skeleton, + hydration, + noBlocksOrMutes, + presentation, + ) + server.app.bsky.feed.getListFeed({ + auth: ctx.authOptionalVerifier, + handler: async ({ params, auth, res }) => { + const viewer = auth.credentials.did + const db = ctx.db.getReplica() + const actorService = ctx.services.actor(db) + const feedService = ctx.services.feed(db) + const graphService = ctx.services.graph(db) + + const [result, repoRev] = await Promise.all([ + getListFeed( + { ...params, viewer }, + { db, actorService, feedService, graphService }, + ), + actorService.getRepoRev(viewer), + ]) + + setRepoRev(res, repoRev) + + return { + encoding: 'application/json', + body: result, + } + }, + }) +} + +export const skeleton = async ( + params: Params, + ctx: Context, +): Promise => { + const { list, cursor, limit } = params + const { db } = ctx + const { ref } = db.db.dynamic + + const keyset = new FeedKeyset(ref('feed_item.sortAt'), ref('feed_item.cid')) + const sortFrom = keyset.unpack(cursor)?.primary + + let builder = db.db + .selectFrom('feed_item') + .innerJoin('list_item', 'list_item.subjectDid', 'feed_item.originatorDid') + .where('list_item.listUri', '=', list) + .innerJoin('post', 'post.uri', 'feed_item.postUri') + .where('feed_item.sortAt', '>', getFeedDateThreshold(sortFrom, 3)) + .selectAll('feed_item') + .select([ + 'post.replyRoot', + 'post.replyParent', + 'post.creator as postAuthorDid', + ]) + + builder = paginate(builder, { + limit, + cursor, + keyset, + tryIndex: true, + }) + const feedItems = await builder.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 noBlocksOrMutes = (state: HydrationState) => { + const { viewer } = state.params + if (!viewer) return state + 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 + 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/index.ts b/packages/bsky/src/api/index.ts index e2b1515e412..44152c4815d 100644 --- a/packages/bsky/src/api/index.ts +++ b/packages/bsky/src/api/index.ts @@ -10,6 +10,7 @@ import getFeedGenerator from './app/bsky/feed/getFeedGenerator' import getFeedGenerators from './app/bsky/feed/getFeedGenerators' import getFeedSkeleton from './app/bsky/feed/getFeedSkeleton' import getLikes from './app/bsky/feed/getLikes' +import getListFeed from './app/bsky/feed/getListFeed' import getPostThread from './app/bsky/feed/getPostThread' import getPosts from './app/bsky/feed/getPosts' import getActorLikes from './app/bsky/feed/getActorLikes' @@ -68,6 +69,7 @@ export default function (server: Server, ctx: AppContext) { getFeedGenerators(server, ctx) getFeedSkeleton(server, ctx) getLikes(server, ctx) + getListFeed(server, ctx) getPostThread(server, ctx) getPosts(server, ctx) getActorLikes(server, ctx) From 81ae2884fea9276f4639d8ecf2d43c428cddfb9a Mon Sep 17 00:00:00 2001 From: dholms Date: Wed, 6 Sep 2023 19:39:52 -0500 Subject: [PATCH 36/49] add proxy route --- .../app-view/api/app/bsky/feed/getListFeed.ts | 19 +++++++++++++++++++ .../api/app/bsky/feed/getSuggestedFeeds.ts | 2 +- .../pds/src/app-view/api/app/bsky/index.ts | 2 ++ 3 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 packages/pds/src/app-view/api/app/bsky/feed/getListFeed.ts diff --git a/packages/pds/src/app-view/api/app/bsky/feed/getListFeed.ts b/packages/pds/src/app-view/api/app/bsky/feed/getListFeed.ts new file mode 100644 index 00000000000..1ac1e983861 --- /dev/null +++ b/packages/pds/src/app-view/api/app/bsky/feed/getListFeed.ts @@ -0,0 +1,19 @@ +import { Server } from '../../../../../lexicon' +import AppContext from '../../../../../context' + +export default function (server: Server, ctx: AppContext) { + server.app.bsky.feed.getListFeed({ + auth: ctx.accessVerifier, + handler: async ({ auth, params }) => { + const requester = auth.credentials.did + const res = await ctx.appviewAgent.api.app.bsky.feed.getListFeed( + params, + await ctx.serviceAuthHeaders(requester), + ) + return { + encoding: 'application/json', + body: res.data, + } + }, + }) +} diff --git a/packages/pds/src/app-view/api/app/bsky/feed/getSuggestedFeeds.ts b/packages/pds/src/app-view/api/app/bsky/feed/getSuggestedFeeds.ts index ba5fb19767b..37f06390fb9 100644 --- a/packages/pds/src/app-view/api/app/bsky/feed/getSuggestedFeeds.ts +++ b/packages/pds/src/app-view/api/app/bsky/feed/getSuggestedFeeds.ts @@ -4,7 +4,7 @@ import AppContext from '../../../../../context' export default function (server: Server, ctx: AppContext) { server.app.bsky.feed.getSuggestedFeeds({ auth: ctx.accessVerifier, - handler: async ({ req, auth, params }) => { + handler: async ({ auth, params }) => { const requester = auth.credentials.did const res = await ctx.appviewAgent.api.app.bsky.feed.getSuggestedFeeds( params, 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..a09f5eb1e79 100644 --- a/packages/pds/src/app-view/api/app/bsky/index.ts +++ b/packages/pds/src/app-view/api/app/bsky/index.ts @@ -9,6 +9,7 @@ import getFeedGenerators from './feed/getFeedGenerators' import describeFeedGenerator from './feed/describeFeedGenerator' import getFeed from './feed/getFeed' import getLikes from './feed/getLikes' +import getListFeed from './feed/getListFeed' import getPostThread from './feed/getPostThread' import getPosts from './feed/getPosts' import getActorLikes from './feed/getActorLikes' @@ -45,6 +46,7 @@ export default function (server: Server, ctx: AppContext) { describeFeedGenerator(server, ctx) getFeed(server, ctx) getLikes(server, ctx) + getListFeed(server, ctx) getPostThread(server, ctx) getPosts(server, ctx) getActorLikes(server, ctx) From 69a09dafc0a833fff6794ae0a01860e3c2f550ba Mon Sep 17 00:00:00 2001 From: dholms Date: Thu, 7 Sep 2023 16:19:43 -0500 Subject: [PATCH 37/49] seed client helpers --- packages/bsky/tests/seeds/client.ts | 54 +++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/packages/bsky/tests/seeds/client.ts b/packages/bsky/tests/seeds/client.ts index ddd1acb9192..b0a04db8c5e 100644 --- a/packages/bsky/tests/seeds/client.ts +++ b/packages/bsky/tests/seeds/client.ts @@ -9,6 +9,7 @@ import { InputSchema as CreateReportInput } from '@atproto/api/src/client/types/ import { Record as PostRecord } from '@atproto/api/src/client/types/app/bsky/feed/post' import { Record as LikeRecord } from '@atproto/api/src/client/types/app/bsky/feed/like' import { Record as FollowRecord } from '@atproto/api/src/client/types/app/bsky/graph/follow' +import { Record as ListRecord } from '@atproto/api/src/client/types/app/bsky/graph/list' // Makes it simple to create data via the XRPC client, // and keeps track of all created data in memory for convenience. @@ -74,6 +75,10 @@ export class SeedClient { likes: Record> replies: Record reposts: Record + lists: Record< + string, + Record }> + > dids: Record constructor(public agent: AtpAgent, public adminAuth?: string) { @@ -84,6 +89,7 @@ export class SeedClient { this.likes = {} this.replies = {} this.reposts = {} + this.lists = {} this.dids = {} } @@ -319,6 +325,54 @@ export class SeedClient { return repost } + async createList(by: string, name: string, purpose: 'mod' | 'curate') { + const res = await this.agent.api.app.bsky.graph.list.create( + { repo: by }, + { + name, + purpose: + purpose === 'mod' + ? 'app.bsky.graph.defs#modlist' + : 'app.bsky.graph.defs#curatelist', + createdAt: new Date().toISOString(), + }, + this.getHeaders(by), + ) + this.lists[by] ??= {} + const ref = new RecordRef(res.uri, res.cid) + this.lists[by][ref.uriStr] = { + ref: ref, + items: {}, + } + return ref + } + + async addToList(by: string, subject: string, list: RecordRef) { + const res = await this.agent.api.app.bsky.graph.listitem.create( + { repo: by }, + { subject, list: list.uriStr, createdAt: new Date().toISOString() }, + this.getHeaders(by), + ) + const ref = new RecordRef(res.uri, res.cid) + const found = (this.lists[by] ?? {})[list.uriStr] + if (found) { + found.items[subject] = ref + } + return ref + } + + async rmFromList(by: string, subject: string, list: RecordRef) { + const foundList = (this.lists[by] ?? {})[list.uriStr] ?? {} + if (!foundList) return + const foundItem = foundList.items[subject] + if (!foundItem) return + await this.agent.api.app.bsky.graph.listitem.delete( + { repo: by, rkey: foundItem.uri.rkey }, + this.getHeaders(by), + ) + delete foundList.items[subject] + } + async takeModerationAction(opts: { action: TakeActionInput['action'] subject: TakeActionInput['subject'] From 20b029fcf4ef423fd54d23fd12096278a93e3870 Mon Sep 17 00:00:00 2001 From: dholms Date: Thu, 7 Sep 2023 16:56:41 -0500 Subject: [PATCH 38/49] tests --- .../bsky/src/api/app/bsky/feed/getListFeed.ts | 17 +- .../__snapshots__/list-feed.test.ts.snap | 721 ++++++++++++++++++ packages/bsky/tests/views/list-feed.test.ts | 115 +++ 3 files changed, 841 insertions(+), 12 deletions(-) create mode 100644 packages/bsky/tests/views/__snapshots__/list-feed.test.ts.snap create mode 100644 packages/bsky/tests/views/list-feed.test.ts diff --git a/packages/bsky/src/api/app/bsky/feed/getListFeed.ts b/packages/bsky/src/api/app/bsky/feed/getListFeed.ts index 833fc33dd1b..c3e87423d26 100644 --- a/packages/bsky/src/api/app/bsky/feed/getListFeed.ts +++ b/packages/bsky/src/api/app/bsky/feed/getListFeed.ts @@ -56,21 +56,14 @@ export const skeleton = async ( const { db } = ctx const { ref } = db.db.dynamic - const keyset = new FeedKeyset(ref('feed_item.sortAt'), ref('feed_item.cid')) + const keyset = new FeedKeyset(ref('post.sortAt'), ref('post.cid')) const sortFrom = keyset.unpack(cursor)?.primary - let builder = db.db - .selectFrom('feed_item') - .innerJoin('list_item', 'list_item.subjectDid', 'feed_item.originatorDid') + let builder = ctx.feedService + .selectPostQb() + .innerJoin('list_item', 'list_item.subjectDid', 'post.creator') .where('list_item.listUri', '=', list) - .innerJoin('post', 'post.uri', 'feed_item.postUri') - .where('feed_item.sortAt', '>', getFeedDateThreshold(sortFrom, 3)) - .selectAll('feed_item') - .select([ - 'post.replyRoot', - 'post.replyParent', - 'post.creator as postAuthorDid', - ]) + .where('post.sortAt', '>', getFeedDateThreshold(sortFrom, 3)) builder = paginate(builder, { limit, diff --git a/packages/bsky/tests/views/__snapshots__/list-feed.test.ts.snap b/packages/bsky/tests/views/__snapshots__/list-feed.test.ts.snap new file mode 100644 index 00000000000..9684522f4f0 --- /dev/null +++ b/packages/bsky/tests/views/__snapshots__/list-feed.test.ts.snap @@ -0,0 +1,721 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`pds author feed views fetches list feed 1`] = ` +Array [ + Object { + "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(4)", + "uri": "record(5)", + }, + "root": Object { + "cid": "cids(3)", + "uri": "record(4)", + }, + }, + "text": "thanks bob", + }, + "replyCount": 0, + "repostCount": 1, + "uri": "record(0)", + "viewer": Object {}, + }, + "reply": Object { + "parent": Object { + "$type": "app.bsky.feed.defs#postView", + "author": Object { + "avatar": "https://bsky.public.url/img/avatar/plain/user(3)/cids(1)@jpeg", + "did": "user(2)", + "displayName": "bobby", + "handle": "bob.test", + "labels": Array [], + "viewer": Object { + "blockedBy": false, + "followedBy": "record(7)", + "muted": false, + }, + }, + "cid": "cids(4)", + "embed": Object { + "$type": "app.bsky.embed.images#view", + "images": Array [ + Object { + "alt": "tests/image/fixtures/key-landscape-small.jpg", + "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(3)/cids(5)@jpeg", + "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(3)/cids(5)@jpeg", + }, + ], + }, + "indexedAt": "1970-01-01T00:00:00.000Z", + "labels": Array [ + Object { + "cid": "cids(4)", + "cts": "1970-01-01T00:00:00.000Z", + "neg": false, + "src": "did:example:labeler", + "uri": "record(5)", + "val": "test-label", + }, + Object { + "cid": "cids(4)", + "cts": "1970-01-01T00:00:00.000Z", + "neg": false, + "src": "did:example:labeler", + "uri": "record(5)", + "val": "test-label-2", + }, + ], + "likeCount": 0, + "record": Object { + "$type": "app.bsky.feed.post", + "createdAt": "1970-01-01T00:00:00.000Z", + "embed": Object { + "$type": "app.bsky.embed.images", + "images": Array [ + Object { + "alt": "tests/image/fixtures/key-landscape-small.jpg", + "image": Object { + "$type": "blob", + "mimeType": "image/jpeg", + "ref": Object { + "$link": "cids(5)", + }, + "size": 4114, + }, + }, + ], + }, + "reply": Object { + "parent": Object { + "cid": "cids(3)", + "uri": "record(4)", + }, + "root": Object { + "cid": "cids(3)", + "uri": "record(4)", + }, + }, + "text": "hear that label_me label_me_2", + }, + "replyCount": 1, + "repostCount": 0, + "uri": "record(5)", + "viewer": Object {}, + }, + "root": Object { + "$type": "app.bsky.feed.defs#postView", + "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(3)", + "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(4)", + "viewer": Object { + "like": "record(6)", + }, + }, + }, + }, + Object { + "post": Object { + "author": Object { + "avatar": "https://bsky.public.url/img/avatar/plain/user(3)/cids(1)@jpeg", + "did": "user(2)", + "displayName": "bobby", + "handle": "bob.test", + "labels": Array [], + "viewer": Object { + "blockedBy": false, + "followedBy": "record(7)", + "muted": false, + }, + }, + "cid": "cids(4)", + "embed": Object { + "$type": "app.bsky.embed.images#view", + "images": Array [ + Object { + "alt": "tests/image/fixtures/key-landscape-small.jpg", + "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(3)/cids(5)@jpeg", + "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(3)/cids(5)@jpeg", + }, + ], + }, + "indexedAt": "1970-01-01T00:00:00.000Z", + "labels": Array [ + Object { + "cid": "cids(4)", + "cts": "1970-01-01T00:00:00.000Z", + "neg": false, + "src": "did:example:labeler", + "uri": "record(5)", + "val": "test-label", + }, + Object { + "cid": "cids(4)", + "cts": "1970-01-01T00:00:00.000Z", + "neg": false, + "src": "did:example:labeler", + "uri": "record(5)", + "val": "test-label-2", + }, + ], + "likeCount": 0, + "record": Object { + "$type": "app.bsky.feed.post", + "createdAt": "1970-01-01T00:00:00.000Z", + "embed": Object { + "$type": "app.bsky.embed.images", + "images": Array [ + Object { + "alt": "tests/image/fixtures/key-landscape-small.jpg", + "image": Object { + "$type": "blob", + "mimeType": "image/jpeg", + "ref": Object { + "$link": "cids(5)", + }, + "size": 4114, + }, + }, + ], + }, + "reply": Object { + "parent": Object { + "cid": "cids(3)", + "uri": "record(4)", + }, + "root": Object { + "cid": "cids(3)", + "uri": "record(4)", + }, + }, + "text": "hear that label_me label_me_2", + }, + "replyCount": 1, + "repostCount": 0, + "uri": "record(5)", + "viewer": Object {}, + }, + "reply": Object { + "parent": Object { + "$type": "app.bsky.feed.defs#postView", + "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(3)", + "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(4)", + "viewer": Object { + "like": "record(6)", + }, + }, + "root": Object { + "$type": "app.bsky.feed.defs#postView", + "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(3)", + "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(4)", + "viewer": Object { + "like": "record(6)", + }, + }, + }, + }, + Object { + "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(6)", + "embed": Object { + "$type": "app.bsky.embed.record#view", + "record": Object { + "$type": "app.bsky.embed.record#viewRecord", + "author": Object { + "did": "user(4)", + "handle": "dan.test", + "labels": Array [], + "viewer": Object { + "blockedBy": false, + "muted": false, + }, + }, + "cid": "cids(7)", + "embeds": Array [ + Object { + "$type": "app.bsky.embed.record#view", + "record": Object { + "$type": "app.bsky.embed.record#viewRecord", + "author": Object { + "did": "user(5)", + "handle": "carol.test", + "labels": Array [], + "viewer": Object { + "blockedBy": false, + "muted": false, + }, + }, + "cid": "cids(8)", + "indexedAt": "1970-01-01T00:00:00.000Z", + "labels": Array [], + "uri": "record(10)", + "value": Object { + "$type": "app.bsky.feed.post", + "createdAt": "1970-01-01T00:00:00.000Z", + "embed": Object { + "$type": "app.bsky.embed.recordWithMedia", + "media": Object { + "$type": "app.bsky.embed.images", + "images": Array [ + Object { + "alt": "tests/image/fixtures/key-landscape-small.jpg", + "image": Object { + "$type": "blob", + "mimeType": "image/jpeg", + "ref": Object { + "$link": "cids(5)", + }, + "size": 4114, + }, + }, + Object { + "alt": "tests/image/fixtures/key-alt.jpg", + "image": Object { + "$type": "blob", + "mimeType": "image/jpeg", + "ref": Object { + "$link": "cids(9)", + }, + "size": 12736, + }, + }, + ], + }, + "record": Object { + "record": Object { + "cid": "cids(10)", + "uri": "record(11)", + }, + }, + }, + "text": "hi im carol", + }, + }, + }, + ], + "indexedAt": "1970-01-01T00:00:00.000Z", + "labels": Array [], + "uri": "record(9)", + "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(8)", + "uri": "record(10)", + }, + }, + "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(6)", + "cts": "1970-01-01T00:00:00.000Z", + "neg": false, + "src": "did:example:labeler", + "uri": "record(8)", + "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(7)", + "uri": "record(9)", + }, + }, + "text": "yoohoo label_me", + }, + "replyCount": 0, + "repostCount": 0, + "uri": "record(8)", + "viewer": Object { + "like": "record(12)", + }, + }, + }, + Object { + "post": Object { + "author": Object { + "avatar": "https://bsky.public.url/img/avatar/plain/user(3)/cids(1)@jpeg", + "did": "user(2)", + "displayName": "bobby", + "handle": "bob.test", + "labels": Array [], + "viewer": Object { + "blockedBy": false, + "followedBy": "record(7)", + "muted": false, + }, + }, + "cid": "cids(11)", + "indexedAt": "1970-01-01T00:00:00.000Z", + "labels": Array [], + "likeCount": 0, + "record": Object { + "$type": "app.bsky.feed.post", + "createdAt": "1970-01-01T00:00:00.000+00:00", + "text": "bobby boy here", + }, + "replyCount": 0, + "repostCount": 0, + "uri": "record(13)", + "viewer": Object {}, + }, + }, + Object { + "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(3)", + "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(4)", + "viewer": Object { + "like": "record(6)", + }, + }, + }, + Object { + "post": Object { + "author": Object { + "avatar": "https://bsky.public.url/img/avatar/plain/user(3)/cids(1)@jpeg", + "did": "user(2)", + "displayName": "bobby", + "handle": "bob.test", + "labels": Array [], + "viewer": Object { + "blockedBy": false, + "followedBy": "record(7)", + "muted": false, + }, + }, + "cid": "cids(10)", + "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", + "langs": Array [ + "en-US", + "i-klingon", + ], + "text": "bob back at it again!", + }, + "replyCount": 0, + "repostCount": 0, + "uri": "record(11)", + "viewer": Object {}, + }, + }, + Object { + "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(12)", + "indexedAt": "1970-01-01T00:00:00.000Z", + "labels": Array [ + Object { + "cid": "cids(12)", + "cts": "1970-01-01T00:00:00.000Z", + "neg": false, + "src": "user(0)", + "uri": "record(14)", + "val": "self-label", + }, + ], + "likeCount": 0, + "record": Object { + "$type": "app.bsky.feed.post", + "createdAt": "1970-01-01T00:00:00.000Z", + "labels": Object { + "$type": "com.atproto.label.defs#selfLabels", + "values": Array [ + Object { + "val": "self-label", + }, + ], + }, + "text": "hey there", + }, + "replyCount": 0, + "repostCount": 0, + "uri": "record(14)", + "viewer": Object {}, + }, + }, +] +`; diff --git a/packages/bsky/tests/views/list-feed.test.ts b/packages/bsky/tests/views/list-feed.test.ts new file mode 100644 index 00000000000..902076f5a33 --- /dev/null +++ b/packages/bsky/tests/views/list-feed.test.ts @@ -0,0 +1,115 @@ +import AtpAgent from '@atproto/api' +import { TestNetwork } from '@atproto/dev-env' +import { forSnapshot, paginateAll, stripViewerFromPost } from '../_util' +import { RecordRef, SeedClient } from '../seeds/client' +import basicSeed from '../seeds/basic' + +describe('pds author feed views', () => { + let network: TestNetwork + let agent: AtpAgent + let sc: SeedClient + + // account dids, for convenience + let alice: string + let bob: string + let carol: string + + let listRef: RecordRef + + beforeAll(async () => { + network = await TestNetwork.create({ + dbPostgresSchema: 'bsky_views_author_feed', + }) + agent = network.bsky.getClient() + const pdsAgent = network.pds.getClient() + sc = new SeedClient(pdsAgent) + await basicSeed(sc) + alice = sc.dids.alice + bob = sc.dids.bob + carol = sc.dids.carol + listRef = await sc.createList(alice, 'test list', 'curate') + await sc.addToList(alice, alice, listRef) + await sc.addToList(alice, bob, listRef) + await network.processAll() + }) + + afterAll(async () => { + await network.close() + }) + + it('fetches list feed', async () => { + const res = await agent.api.app.bsky.feed.getListFeed( + { list: listRef.uriStr }, + { headers: await network.serviceHeaders(carol) }, + ) + expect(forSnapshot(res.data.feed)).toMatchSnapshot() + + // all posts are from alice or bob + expect( + res.data.feed.every((row) => [alice, bob].includes(row.post.author.did)), + ).toBeTruthy() + }) + + it('paginates', async () => { + const results = (results) => results.flatMap((res) => res.feed) + const paginator = async (cursor?: string) => { + const res = await agent.api.app.bsky.feed.getListFeed( + { + list: listRef.uriStr, + cursor, + limit: 2, + }, + { headers: await network.serviceHeaders(carol) }, + ) + return res.data + } + + const paginatedAll = await paginateAll(paginator) + paginatedAll.forEach((res) => + expect(res.feed.length).toBeLessThanOrEqual(2), + ) + + const full = await agent.api.app.bsky.feed.getListFeed( + { list: listRef.uriStr }, + { headers: await network.serviceHeaders(carol) }, + ) + + expect(full.data.feed.length).toEqual(7) + expect(results(paginatedAll)).toEqual(results([full.data])) + }) + + it('fetches results unauthed', async () => { + const { data: authed } = await agent.api.app.bsky.feed.getListFeed( + { list: listRef.uriStr }, + { headers: await network.serviceHeaders(alice) }, + ) + const { data: unauthed } = await agent.api.app.bsky.feed.getListFeed({ + list: listRef.uriStr, + }) + expect(unauthed.feed.length).toBeGreaterThan(0) + expect(unauthed.feed).toEqual( + authed.feed.map((item) => { + const result = { + ...item, + post: stripViewerFromPost(item.post), + } + if (item.reply) { + result.reply = { + parent: stripViewerFromPost(item.reply.parent), + root: stripViewerFromPost(item.reply.root), + } + } + return result + }), + ) + }) + + it('works for empty lists', async () => { + const emptyList = await sc.createList(alice, 'empty list', 'curate') + const res = await agent.api.app.bsky.feed.getListFeed({ + list: emptyList.uriStr, + }) + + expect(res.data.feed.length).toEqual(0) + }) +}) From 9a3af5c4975124af8c024e0e18d777e76471b487 Mon Sep 17 00:00:00 2001 From: dholms Date: Thu, 7 Sep 2023 17:03:08 -0500 Subject: [PATCH 39/49] mutes and blocks --- packages/bsky/tests/views/blocks.test.ts | 23 ++++++++++++++++++++ packages/bsky/tests/views/mute-lists.test.ts | 14 ++++++++++++ packages/bsky/tests/views/mutes.test.ts | 14 ++++++++++++ 3 files changed, 51 insertions(+) diff --git a/packages/bsky/tests/views/blocks.test.ts b/packages/bsky/tests/views/blocks.test.ts index 127aac4fc6b..30b46e78ab5 100644 --- a/packages/bsky/tests/views/blocks.test.ts +++ b/packages/bsky/tests/views/blocks.test.ts @@ -163,6 +163,29 @@ describe('pds views with blocking', () => { ).toBeFalsy() }) + it('strips blocked users out of getListFeed', async () => { + const listRef = await sc.createList(alice, 'test list', 'curate') + await sc.addToList(alice, alice, listRef) + await sc.addToList(alice, carol, listRef) + await sc.addToList(alice, dan, listRef) + + const resCarol = await agent.api.app.bsky.feed.getListFeed( + { list: listRef.uriStr, 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.getListFeed( + { list: listRef.uriStr, limit: 100 }, + { headers: await network.serviceHeaders(dan) }, + ) + expect( + resDan.data.feed.some((post) => post.post.author.did === carol), + ).toBeFalsy() + }) + it('returns block status on getProfile', async () => { const resCarol = await agent.api.app.bsky.actor.getProfile( { actor: dan }, diff --git a/packages/bsky/tests/views/mute-lists.test.ts b/packages/bsky/tests/views/mute-lists.test.ts index fd2d02ed81f..a1800ad1143 100644 --- a/packages/bsky/tests/views/mute-lists.test.ts +++ b/packages/bsky/tests/views/mute-lists.test.ts @@ -127,6 +127,20 @@ describe('bsky views with mutes from mute lists', () => { ).toBe(false) }) + it('removes content from muted users on getListFeed', async () => { + const listRef = await sc.createList(bob, 'test list', 'curate') + await sc.addToList(alice, bob, listRef) + await sc.addToList(alice, carol, listRef) + await sc.addToList(alice, dan, listRef) + const res = await agent.api.app.bsky.feed.getListFeed( + { list: listRef.uriStr }, + { headers: await network.serviceHeaders(alice) }, + ) + expect( + res.data.feed.some((post) => [bob, carol].includes(post.post.author.did)), + ).toBe(false) + }) + it('returns mute status on getProfile', async () => { const res = await agent.api.app.bsky.actor.getProfile( { actor: carol }, diff --git a/packages/bsky/tests/views/mutes.test.ts b/packages/bsky/tests/views/mutes.test.ts index de3e13313f3..15be18a7b27 100644 --- a/packages/bsky/tests/views/mutes.test.ts +++ b/packages/bsky/tests/views/mutes.test.ts @@ -88,6 +88,20 @@ describe('mute views', () => { ).toBe(false) }) + it('removes content from muted users on getListFeed', async () => { + const listRef = await sc.createList(bob, 'test list', 'curate') + await sc.addToList(alice, bob, listRef) + await sc.addToList(alice, carol, listRef) + await sc.addToList(alice, dan, listRef) + const res = await agent.api.app.bsky.feed.getListFeed( + { list: listRef.uriStr }, + { headers: await network.serviceHeaders(alice) }, + ) + expect( + res.data.feed.some((post) => [bob, carol].includes(post.post.author.did)), + ).toBe(false) + }) + it('returns mute status on getProfile', async () => { const res = await agent.api.app.bsky.actor.getProfile( { actor: bob }, From f13c3e518258634f7dc841195ae66e1f6ada27dd Mon Sep 17 00:00:00 2001 From: dholms Date: Thu, 7 Sep 2023 17:12:39 -0500 Subject: [PATCH 40/49] proxy test --- packages/bsky/tests/views/list-feed.test.ts | 79 +++++++++++++++++++++ packages/pds/tests/proxied/views.test.ts | 37 +++++++++- packages/pds/tests/seeds/client.ts | 53 ++++++++++++++ 3 files changed, 168 insertions(+), 1 deletion(-) diff --git a/packages/bsky/tests/views/list-feed.test.ts b/packages/bsky/tests/views/list-feed.test.ts index 902076f5a33..33be9c541a1 100644 --- a/packages/bsky/tests/views/list-feed.test.ts +++ b/packages/bsky/tests/views/list-feed.test.ts @@ -3,6 +3,7 @@ import { TestNetwork } from '@atproto/dev-env' import { forSnapshot, paginateAll, stripViewerFromPost } from '../_util' import { RecordRef, SeedClient } from '../seeds/client' import basicSeed from '../seeds/basic' +import { TAKEDOWN } from '@atproto/api/src/client/types/com/atproto/admin/defs' describe('pds author feed views', () => { let network: TestNetwork @@ -112,4 +113,82 @@ describe('pds author feed views', () => { expect(res.data.feed.length).toEqual(0) }) + + it('blocks posts by actor takedown', async () => { + const actionRes = await agent.api.com.atproto.admin.takeModerationAction( + { + action: TAKEDOWN, + subject: { + $type: 'com.atproto.admin.defs#repoRef', + did: bob, + }, + createdBy: 'did:example:admin', + reason: 'Y', + }, + { + encoding: 'application/json', + headers: network.pds.adminAuthHeaders(), + }, + ) + + const res = await agent.api.app.bsky.feed.getListFeed({ + list: listRef.uriStr, + }) + const hasBob = res.data.feed.some((item) => item.post.author.did === bob) + expect(hasBob).toBe(false) + + // Cleanup + await agent.api.com.atproto.admin.reverseModerationAction( + { + id: actionRes.data.id, + createdBy: 'did:example:admin', + reason: 'Y', + }, + { + encoding: 'application/json', + headers: network.pds.adminAuthHeaders(), + }, + ) + }) + + it('blocks posts by record takedown.', async () => { + const postRef = sc.replies[bob][0].ref // Post and reply parent + const actionRes = await agent.api.com.atproto.admin.takeModerationAction( + { + action: TAKEDOWN, + subject: { + $type: 'com.atproto.repo.strongRef', + uri: postRef.uriStr, + cid: postRef.cidStr, + }, + createdBy: 'did:example:admin', + reason: 'Y', + }, + { + encoding: 'application/json', + headers: network.pds.adminAuthHeaders(), + }, + ) + + const res = await agent.api.app.bsky.feed.getListFeed({ + list: listRef.uriStr, + }) + const hasPost = res.data.feed.some( + (item) => item.post.uri === postRef.uriStr, + ) + expect(hasPost).toBe(false) + + // Cleanup + await agent.api.com.atproto.admin.reverseModerationAction( + { + id: actionRes.data.id, + createdBy: 'did:example:admin', + reason: 'Y', + }, + { + encoding: 'application/json', + headers: network.pds.adminAuthHeaders(), + }, + ) + }) }) diff --git a/packages/pds/tests/proxied/views.test.ts b/packages/pds/tests/proxied/views.test.ts index 066cc780059..1ecba4a7e14 100644 --- a/packages/pds/tests/proxied/views.test.ts +++ b/packages/pds/tests/proxied/views.test.ts @@ -21,11 +21,14 @@ describe('proxies view requests', () => { agent = network.pds.getClient() sc = new SeedClient(agent) await basicSeed(sc) - await network.processAll() alice = sc.dids.alice bob = sc.dids.bob carol = sc.dids.carol dan = sc.dids.dan + const listRef = await sc.createList(alice, 'test list', 'curate') + await sc.addToList(alice, alice, listRef) + await sc.addToList(alice, bob, listRef) + await network.processAll() }) afterAll(async () => { @@ -179,6 +182,38 @@ describe('proxies view requests', () => { expect([...pt1.data.feed, ...pt2.data.feed]).toEqual(res.data.feed) }) + it('feed.getListFeed', async () => { + const list = Object.values(sc.lists[alice])[0].ref.uriStr + const res = await agent.api.app.bsky.feed.getListFeed( + { + list, + }, + { + headers: { ...sc.getHeaders(alice), 'x-appview-proxy': 'true' }, + }, + ) + expect(forSnapshot(res.data)).toMatchSnapshot() + const pt1 = await agent.api.app.bsky.feed.getListFeed( + { + list, + limit: 1, + }, + { + headers: { ...sc.getHeaders(alice), 'x-appview-proxy': 'true' }, + }, + ) + const pt2 = await agent.api.app.bsky.feed.getListFeed( + { + list, + cursor: pt1.data.cursor, + }, + { + headers: { ...sc.getHeaders(alice), 'x-appview-proxy': 'true' }, + }, + ) + expect([...pt1.data.feed, ...pt2.data.feed]).toEqual(res.data.feed) + }) + it('feed.getLikes', async () => { const postUri = sc.posts[carol][0].ref.uriStr const res = await agent.api.app.bsky.feed.getLikes( diff --git a/packages/pds/tests/seeds/client.ts b/packages/pds/tests/seeds/client.ts index 2918de9c7dd..c52f499f6ab 100644 --- a/packages/pds/tests/seeds/client.ts +++ b/packages/pds/tests/seeds/client.ts @@ -77,6 +77,10 @@ export class SeedClient { likes: Record> replies: Record reposts: Record + lists: Record< + string, + Record }> + > dids: Record constructor(public agent: AtpAgent) { @@ -88,6 +92,7 @@ export class SeedClient { this.likes = {} this.replies = {} this.reposts = {} + this.lists = {} this.dids = {} } @@ -369,6 +374,54 @@ export class SeedClient { return repost } + async createList(by: string, name: string, purpose: 'mod' | 'curate') { + const res = await this.agent.api.app.bsky.graph.list.create( + { repo: by }, + { + name, + purpose: + purpose === 'mod' + ? 'app.bsky.graph.defs#modlist' + : 'app.bsky.graph.defs#curatelist', + createdAt: new Date().toISOString(), + }, + this.getHeaders(by), + ) + this.lists[by] ??= {} + const ref = new RecordRef(res.uri, res.cid) + this.lists[by][ref.uriStr] = { + ref: ref, + items: {}, + } + return ref + } + + async addToList(by: string, subject: string, list: RecordRef) { + const res = await this.agent.api.app.bsky.graph.listitem.create( + { repo: by }, + { subject, list: list.uriStr, createdAt: new Date().toISOString() }, + this.getHeaders(by), + ) + const ref = new RecordRef(res.uri, res.cid) + const found = (this.lists[by] ?? {})[list.uriStr] + if (found) { + found.items[subject] = ref + } + return ref + } + + async rmFromList(by: string, subject: string, list: RecordRef) { + const foundList = (this.lists[by] ?? {})[list.uriStr] ?? {} + if (!foundList) return + const foundItem = foundList.items[subject] + if (!foundItem) return + await this.agent.api.app.bsky.graph.listitem.delete( + { repo: by, rkey: foundItem.uri.rkey }, + this.getHeaders(by), + ) + delete foundList.items[subject] + } + async takeModerationAction(opts: { action: TakeActionInput['action'] subject: TakeActionInput['subject'] From b150c30ddee4df11376f05a628fcb6e59f51f956 Mon Sep 17 00:00:00 2001 From: dholms Date: Thu, 7 Sep 2023 20:42:47 -0500 Subject: [PATCH 41/49] snapshot --- .../proxied/__snapshots__/views.test.ts.snap | 774 ++++++++++++++++++ 1 file changed, 774 insertions(+) diff --git a/packages/pds/tests/proxied/__snapshots__/views.test.ts.snap b/packages/pds/tests/proxied/__snapshots__/views.test.ts.snap index 9a8396ca220..2b0331c1940 100644 --- a/packages/pds/tests/proxied/__snapshots__/views.test.ts.snap +++ b/packages/pds/tests/proxied/__snapshots__/views.test.ts.snap @@ -758,6 +758,780 @@ Object { } `; +exports[`proxies view requests feed.getListFeed 1`] = ` +Object { + "cursor": "0000000000000::bafycid", + "feed": Array [ + Object { + "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(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, + }, + }, + "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(4)", + "uri": "record(3)", + }, + "root": Object { + "cid": "cids(3)", + "uri": "record(2)", + }, + }, + "text": "thanks bob", + }, + "replyCount": 0, + "repostCount": 1, + "uri": "record(0)", + "viewer": Object {}, + }, + "reply": Object { + "parent": Object { + "$type": "app.bsky.feed.defs#postView", + "author": Object { + "avatar": "https://bsky.public.url/img/avatar/plain/user(3)/cids(1)@jpeg", + "did": "user(2)", + "displayName": "bobby", + "handle": "bob.test", + "labels": Array [ + Object { + "cid": "cids(5)", + "cts": "1970-01-01T00:00:00.000Z", + "neg": false, + "src": "user(2)", + "uri": "record(6)", + "val": "self-label-a", + }, + Object { + "cid": "cids(5)", + "cts": "1970-01-01T00:00:00.000Z", + "neg": false, + "src": "user(2)", + "uri": "record(6)", + "val": "self-label-b", + }, + ], + "viewer": Object { + "blockedBy": false, + "followedBy": "record(5)", + "following": "record(4)", + "muted": false, + }, + }, + "cid": "cids(4)", + "embed": Object { + "$type": "app.bsky.embed.images#view", + "images": Array [ + Object { + "alt": "tests/image/fixtures/key-landscape-small.jpg", + "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(3)/cids(6)@jpeg", + "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(3)/cids(6)@jpeg", + }, + ], + }, + "indexedAt": "1970-01-01T00:00:00.000Z", + "labels": Array [ + Object { + "cid": "cids(4)", + "cts": "1970-01-01T00:00:00.000Z", + "neg": false, + "src": "did:example:labeler", + "uri": "record(3)", + "val": "test-label", + }, + Object { + "cid": "cids(4)", + "cts": "1970-01-01T00:00:00.000Z", + "neg": false, + "src": "did:example:labeler", + "uri": "record(3)", + "val": "test-label-2", + }, + ], + "likeCount": 0, + "record": Object { + "$type": "app.bsky.feed.post", + "createdAt": "1970-01-01T00:00:00.000Z", + "embed": Object { + "$type": "app.bsky.embed.images", + "images": Array [ + Object { + "alt": "tests/image/fixtures/key-landscape-small.jpg", + "image": Object { + "$type": "blob", + "mimeType": "image/jpeg", + "ref": Object { + "$link": "cids(6)", + }, + "size": 4114, + }, + }, + ], + }, + "reply": Object { + "parent": Object { + "cid": "cids(3)", + "uri": "record(2)", + }, + "root": Object { + "cid": "cids(3)", + "uri": "record(2)", + }, + }, + "text": "hear that label_me label_me_2", + }, + "replyCount": 1, + "repostCount": 0, + "uri": "record(3)", + "viewer": Object {}, + }, + "root": Object { + "$type": "app.bsky.feed.defs#postView", + "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(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, + }, + }, + "cid": "cids(3)", + "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(2)", + "viewer": Object {}, + }, + }, + }, + Object { + "post": Object { + "author": Object { + "avatar": "https://bsky.public.url/img/avatar/plain/user(3)/cids(1)@jpeg", + "did": "user(2)", + "displayName": "bobby", + "handle": "bob.test", + "labels": Array [ + Object { + "cid": "cids(5)", + "cts": "1970-01-01T00:00:00.000Z", + "neg": false, + "src": "user(2)", + "uri": "record(6)", + "val": "self-label-a", + }, + Object { + "cid": "cids(5)", + "cts": "1970-01-01T00:00:00.000Z", + "neg": false, + "src": "user(2)", + "uri": "record(6)", + "val": "self-label-b", + }, + ], + "viewer": Object { + "blockedBy": false, + "followedBy": "record(5)", + "following": "record(4)", + "muted": false, + }, + }, + "cid": "cids(4)", + "embed": Object { + "$type": "app.bsky.embed.images#view", + "images": Array [ + Object { + "alt": "tests/image/fixtures/key-landscape-small.jpg", + "fullsize": "https://bsky.public.url/img/feed_fullsize/plain/user(3)/cids(6)@jpeg", + "thumb": "https://bsky.public.url/img/feed_thumbnail/plain/user(3)/cids(6)@jpeg", + }, + ], + }, + "indexedAt": "1970-01-01T00:00:00.000Z", + "labels": Array [ + Object { + "cid": "cids(4)", + "cts": "1970-01-01T00:00:00.000Z", + "neg": false, + "src": "did:example:labeler", + "uri": "record(3)", + "val": "test-label", + }, + Object { + "cid": "cids(4)", + "cts": "1970-01-01T00:00:00.000Z", + "neg": false, + "src": "did:example:labeler", + "uri": "record(3)", + "val": "test-label-2", + }, + ], + "likeCount": 0, + "record": Object { + "$type": "app.bsky.feed.post", + "createdAt": "1970-01-01T00:00:00.000Z", + "embed": Object { + "$type": "app.bsky.embed.images", + "images": Array [ + Object { + "alt": "tests/image/fixtures/key-landscape-small.jpg", + "image": Object { + "$type": "blob", + "mimeType": "image/jpeg", + "ref": Object { + "$link": "cids(6)", + }, + "size": 4114, + }, + }, + ], + }, + "reply": Object { + "parent": Object { + "cid": "cids(3)", + "uri": "record(2)", + }, + "root": Object { + "cid": "cids(3)", + "uri": "record(2)", + }, + }, + "text": "hear that label_me label_me_2", + }, + "replyCount": 1, + "repostCount": 0, + "uri": "record(3)", + "viewer": Object {}, + }, + "reply": Object { + "parent": Object { + "$type": "app.bsky.feed.defs#postView", + "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(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, + }, + }, + "cid": "cids(3)", + "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(2)", + "viewer": Object {}, + }, + "root": Object { + "$type": "app.bsky.feed.defs#postView", + "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(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, + }, + }, + "cid": "cids(3)", + "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(2)", + "viewer": Object {}, + }, + }, + }, + Object { + "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(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, + }, + }, + "cid": "cids(7)", + "embed": Object { + "$type": "app.bsky.embed.record#view", + "record": Object { + "$type": "app.bsky.embed.record#viewRecord", + "author": Object { + "did": "user(4)", + "handle": "dan.test", + "labels": Array [], + "viewer": Object { + "blockedBy": false, + "following": "record(9)", + "muted": false, + }, + }, + "cid": "cids(8)", + "embeds": Array [ + Object { + "$type": "app.bsky.embed.record#view", + "record": Object { + "$type": "app.bsky.embed.record#viewRecord", + "author": Object { + "did": "user(5)", + "handle": "carol.test", + "labels": Array [], + "viewer": Object { + "blockedBy": false, + "followedBy": "record(12)", + "following": "record(11)", + "muted": false, + }, + }, + "cid": "cids(9)", + "indexedAt": "1970-01-01T00:00:00.000Z", + "labels": Array [], + "uri": "record(10)", + "value": Object { + "$type": "app.bsky.feed.post", + "createdAt": "1970-01-01T00:00:00.000Z", + "embed": Object { + "$type": "app.bsky.embed.recordWithMedia", + "media": Object { + "$type": "app.bsky.embed.images", + "images": Array [ + Object { + "alt": "tests/image/fixtures/key-landscape-small.jpg", + "image": Object { + "$type": "blob", + "mimeType": "image/jpeg", + "ref": Object { + "$link": "cids(6)", + }, + "size": 4114, + }, + }, + Object { + "alt": "tests/image/fixtures/key-alt.jpg", + "image": Object { + "$type": "blob", + "mimeType": "image/jpeg", + "ref": Object { + "$link": "cids(10)", + }, + "size": 12736, + }, + }, + ], + }, + "record": Object { + "record": Object { + "cid": "cids(11)", + "uri": "record(13)", + }, + }, + }, + "text": "hi im carol", + }, + }, + }, + ], + "indexedAt": "1970-01-01T00:00:00.000Z", + "labels": Array [], + "uri": "record(8)", + "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(9)", + "uri": "record(10)", + }, + }, + "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(7)", + "cts": "1970-01-01T00:00:00.000Z", + "neg": false, + "src": "did:example:labeler", + "uri": "record(7)", + "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(8)", + "uri": "record(8)", + }, + }, + "text": "yoohoo label_me", + }, + "replyCount": 0, + "repostCount": 0, + "uri": "record(7)", + "viewer": Object {}, + }, + }, + Object { + "post": Object { + "author": Object { + "avatar": "https://bsky.public.url/img/avatar/plain/user(3)/cids(1)@jpeg", + "did": "user(2)", + "displayName": "bobby", + "handle": "bob.test", + "labels": Array [ + Object { + "cid": "cids(5)", + "cts": "1970-01-01T00:00:00.000Z", + "neg": false, + "src": "user(2)", + "uri": "record(6)", + "val": "self-label-a", + }, + Object { + "cid": "cids(5)", + "cts": "1970-01-01T00:00:00.000Z", + "neg": false, + "src": "user(2)", + "uri": "record(6)", + "val": "self-label-b", + }, + ], + "viewer": Object { + "blockedBy": false, + "followedBy": "record(5)", + "following": "record(4)", + "muted": false, + }, + }, + "cid": "cids(12)", + "indexedAt": "1970-01-01T00:00:00.000Z", + "labels": Array [], + "likeCount": 0, + "record": Object { + "$type": "app.bsky.feed.post", + "createdAt": "1970-01-01T00:00:00.000+00:00", + "text": "bobby boy here", + }, + "replyCount": 0, + "repostCount": 0, + "uri": "record(14)", + "viewer": Object {}, + }, + }, + Object { + "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(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, + }, + }, + "cid": "cids(3)", + "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(2)", + "viewer": Object {}, + }, + }, + Object { + "post": Object { + "author": Object { + "avatar": "https://bsky.public.url/img/avatar/plain/user(3)/cids(1)@jpeg", + "did": "user(2)", + "displayName": "bobby", + "handle": "bob.test", + "labels": Array [ + Object { + "cid": "cids(5)", + "cts": "1970-01-01T00:00:00.000Z", + "neg": false, + "src": "user(2)", + "uri": "record(6)", + "val": "self-label-a", + }, + Object { + "cid": "cids(5)", + "cts": "1970-01-01T00:00:00.000Z", + "neg": false, + "src": "user(2)", + "uri": "record(6)", + "val": "self-label-b", + }, + ], + "viewer": Object { + "blockedBy": false, + "followedBy": "record(5)", + "following": "record(4)", + "muted": false, + }, + }, + "cid": "cids(11)", + "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", + "langs": Array [ + "en-US", + "i-klingon", + ], + "text": "bob back at it again!", + }, + "replyCount": 0, + "repostCount": 0, + "uri": "record(13)", + "viewer": Object {}, + }, + }, + Object { + "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(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, + }, + }, + "cid": "cids(13)", + "indexedAt": "1970-01-01T00:00:00.000Z", + "labels": Array [ + Object { + "cid": "cids(13)", + "cts": "1970-01-01T00:00:00.000Z", + "neg": false, + "src": "user(0)", + "uri": "record(15)", + "val": "self-label", + }, + ], + "likeCount": 0, + "record": Object { + "$type": "app.bsky.feed.post", + "createdAt": "1970-01-01T00:00:00.000Z", + "labels": Object { + "$type": "com.atproto.label.defs#selfLabels", + "values": Array [ + Object { + "val": "self-label", + }, + ], + }, + "text": "hey there", + }, + "replyCount": 0, + "repostCount": 0, + "uri": "record(15)", + "viewer": Object {}, + }, + }, + ], +} +`; + exports[`proxies view requests feed.getPosts 1`] = ` Object { "posts": Array [ From 053536861d1d8718dcdd54485a18784d9408a767 Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Fri, 8 Sep 2023 18:31:21 -0400 Subject: [PATCH 42/49] hoist actors out of composeThread() --- .../src/api/app/bsky/feed/getPostThread.ts | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/packages/bsky/src/api/app/bsky/feed/getPostThread.ts b/packages/bsky/src/api/app/bsky/feed/getPostThread.ts index 5c3b4b178e9..2d10ff98006 100644 --- a/packages/bsky/src/api/app/bsky/feed/getPostThread.ts +++ b/packages/bsky/src/api/app/bsky/feed/getPostThread.ts @@ -20,7 +20,7 @@ import { import { Database } from '../../../../db' import { setRepoRev } from '../../../util' import { createPipeline, noRules } from '../../../../pipeline' -import { ActorService } from '../../../../services/actor' +import { ActorInfoMap, ActorService } from '../../../../services/actor' export default function (server: Server, ctx: AppContext) { const getPostThread = createPipeline( @@ -77,8 +77,14 @@ const hydration = async (state: SkeletonState, ctx: Context) => { } const presentation = (state: HydrationState, ctx: Context) => { - const { params } = state - const thread = composeThread(state.threadData, state, ctx) + 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') @@ -88,17 +94,13 @@ const presentation = (state: HydrationState, ctx: Context) => { const composeThread = ( threadData: PostThread, + actors: ActorInfoMap, state: HydrationState, ctx: Context, ) => { - const { feedService, actorService } = ctx - const { profiles, posts, embeds, blocks, labels, params } = state + const { feedService } = ctx + const { posts, embeds, blocks, labels } = state - const actors = actorService.views.profileBasicPresentation( - Object.keys(profiles), - state, - { viewer: params.viewer }, - ) const post = feedService.views.formatPostView( threadData.post.postUri, actors, @@ -141,14 +143,14 @@ const composeThread = ( notFound: true, } } else { - parent = composeThread(threadData.parent, state, ctx) + 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, state, ctx) + 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 From cb30cff1ce7afeefdfcb765207ac0d86838f7663 Mon Sep 17 00:00:00 2001 From: Devin Ivy Date: Fri, 8 Sep 2023 18:59:04 -0400 Subject: [PATCH 43/49] tidy --- lexicons/app/bsky/graph/getListBlocks.json | 13 +++++++++---- lexicons/app/bsky/graph/listblock.json | 4 ++-- .../tests/proxied/__snapshots__/views.test.ts.snap | 4 ++-- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/lexicons/app/bsky/graph/getListBlocks.json b/lexicons/app/bsky/graph/getListBlocks.json index e1c16b6eb6e..709d77aa68b 100644 --- a/lexicons/app/bsky/graph/getListBlocks.json +++ b/lexicons/app/bsky/graph/getListBlocks.json @@ -8,8 +8,13 @@ "parameters": { "type": "params", "properties": { - "limit": {"type": "integer", "minimum": 1, "maximum": 100, "default": 50}, - "cursor": {"type": "string"} + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + }, + "cursor": { "type": "string" } } }, "output": { @@ -18,10 +23,10 @@ "type": "object", "required": ["lists"], "properties": { - "cursor": {"type": "string"}, + "cursor": { "type": "string" }, "lists": { "type": "array", - "items": {"type": "ref", "ref": "app.bsky.graph.defs#listView"} + "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 index c763f83843e..b3a839c5316 100644 --- a/lexicons/app/bsky/graph/listblock.json +++ b/lexicons/app/bsky/graph/listblock.json @@ -10,8 +10,8 @@ "type": "object", "required": ["subject", "createdAt"], "properties": { - "subject": {"type": "string", "format": "at-uri"}, - "createdAt": {"type": "string", "format": "datetime"} + "subject": { "type": "string", "format": "at-uri" }, + "createdAt": { "type": "string", "format": "datetime" } } } } diff --git a/packages/pds/tests/proxied/__snapshots__/views.test.ts.snap b/packages/pds/tests/proxied/__snapshots__/views.test.ts.snap index 835f286c739..f1276469126 100644 --- a/packages/pds/tests/proxied/__snapshots__/views.test.ts.snap +++ b/packages/pds/tests/proxied/__snapshots__/views.test.ts.snap @@ -155,7 +155,7 @@ Object { "cts": "1970-01-01T00:00:00.000Z", "neg": false, "src": "did:example:labeler", - "uri": "user(0)", + "uri": "user(2)", "val": "repo-action-label", }, ], @@ -2232,7 +2232,7 @@ Object { "cts": "1970-01-01T00:00:00.000Z", "neg": false, "src": "did:example:labeler", - "uri": "user(2)", + "uri": "user(0)", "val": "repo-action-label", }, ], From 0c7778774b0a4c1f01347e5bc96b3a5a33d1a5dd Mon Sep 17 00:00:00 2001 From: dholms Date: Mon, 11 Sep 2023 15:52:17 -0500 Subject: [PATCH 44/49] tidy --- packages/bsky/src/api/app/bsky/feed/getListFeed.ts | 4 +--- packages/bsky/tests/views/list-feed.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/bsky/src/api/app/bsky/feed/getListFeed.ts b/packages/bsky/src/api/app/bsky/feed/getListFeed.ts index c3e87423d26..f166c8abb99 100644 --- a/packages/bsky/src/api/app/bsky/feed/getListFeed.ts +++ b/packages/bsky/src/api/app/bsky/feed/getListFeed.ts @@ -97,9 +97,7 @@ const noBlocksOrMutes = (state: HydrationState) => { 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]), + !state.bam.mute([viewer, item.postAuthorDid]), ) return state } diff --git a/packages/bsky/tests/views/list-feed.test.ts b/packages/bsky/tests/views/list-feed.test.ts index 33be9c541a1..c9d94f1a9d4 100644 --- a/packages/bsky/tests/views/list-feed.test.ts +++ b/packages/bsky/tests/views/list-feed.test.ts @@ -5,7 +5,7 @@ import { RecordRef, SeedClient } from '../seeds/client' import basicSeed from '../seeds/basic' import { TAKEDOWN } from '@atproto/api/src/client/types/com/atproto/admin/defs' -describe('pds author feed views', () => { +describe('list feed views', () => { let network: TestNetwork let agent: AtpAgent let sc: SeedClient @@ -19,7 +19,7 @@ describe('pds author feed views', () => { beforeAll(async () => { network = await TestNetwork.create({ - dbPostgresSchema: 'bsky_views_author_feed', + dbPostgresSchema: 'bsky_views_list_feed', }) agent = network.bsky.getClient() const pdsAgent = network.pds.getClient() From 51cd347c6bafd361fb3e21e7ee24d0b2fe26984b Mon Sep 17 00:00:00 2001 From: dholms Date: Mon, 11 Sep 2023 19:19:24 -0500 Subject: [PATCH 45/49] run ci on all prs --- .github/workflows/repo.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/repo.yaml b/.github/workflows/repo.yaml index e387c442321..dfffe3a94f1 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 }}' From 36c06068612d2e84eea4f66dcb95ea8941f24d75 Mon Sep 17 00:00:00 2001 From: dholms Date: Mon, 11 Sep 2023 19:20:37 -0500 Subject: [PATCH 46/49] format --- .github/workflows/repo.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/repo.yaml b/.github/workflows/repo.yaml index dfffe3a94f1..8380fff8a63 100644 --- a/.github/workflows/repo.yaml +++ b/.github/workflows/repo.yaml @@ -3,7 +3,7 @@ name: Test on: pull_request: branches: - - "*" + - '*' concurrency: group: '${{ github.workflow }}-${{ github.head_ref || github.ref }}' From 6b021af6101289f72dddaf2343c38df900d19943 Mon Sep 17 00:00:00 2001 From: dholms Date: Mon, 11 Sep 2023 20:11:16 -0500 Subject: [PATCH 47/49] format --- lexicons/app/bsky/feed/getListFeed.json | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/lexicons/app/bsky/feed/getListFeed.json b/lexicons/app/bsky/feed/getListFeed.json index 4cf36ea625a..f7b778bda28 100644 --- a/lexicons/app/bsky/feed/getListFeed.json +++ b/lexicons/app/bsky/feed/getListFeed.json @@ -9,9 +9,14 @@ "type": "params", "required": ["list"], "properties": { - "list": {"type": "string", "format": "at-uri"}, - "limit": {"type": "integer", "minimum": 1, "maximum": 100, "default": 50}, - "cursor": {"type": "string"} + "list": { "type": "string", "format": "at-uri" }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + }, + "cursor": { "type": "string" } } }, "output": { @@ -20,17 +25,18 @@ "type": "object", "required": ["feed"], "properties": { - "cursor": {"type": "string"}, + "cursor": { "type": "string" }, "feed": { "type": "array", - "items": {"type": "ref", "ref": "app.bsky.feed.defs#feedViewPost"} + "items": { + "type": "ref", + "ref": "app.bsky.feed.defs#feedViewPost" + } } } } }, - "errors": [ - {"name": "UnknownList"} - ] + "errors": [{ "name": "UnknownList" }] } } } From b7a1801697609638ee0f2640a114eb9b17f76a89 Mon Sep 17 00:00:00 2001 From: dholms Date: Mon, 11 Sep 2023 20:12:31 -0500 Subject: [PATCH 48/49] fix snap name --- packages/bsky/tests/views/__snapshots__/list-feed.test.ts.snap | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/bsky/tests/views/__snapshots__/list-feed.test.ts.snap b/packages/bsky/tests/views/__snapshots__/list-feed.test.ts.snap index 9684522f4f0..34d5712d303 100644 --- a/packages/bsky/tests/views/__snapshots__/list-feed.test.ts.snap +++ b/packages/bsky/tests/views/__snapshots__/list-feed.test.ts.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`pds author feed views fetches list feed 1`] = ` +exports[`list feed views fetches list feed 1`] = ` Array [ Object { "post": Object { From 1780558fe483b4ef71ee04c9ced263d57d29656f Mon Sep 17 00:00:00 2001 From: dholms Date: Tue, 12 Sep 2023 13:25:25 -0500 Subject: [PATCH 49/49] fix snapsh --- .../proxied/__snapshots__/views.test.ts.snap | 85 +++++++++---------- 1 file changed, 42 insertions(+), 43 deletions(-) diff --git a/packages/pds/tests/proxied/__snapshots__/views.test.ts.snap b/packages/pds/tests/proxied/__snapshots__/views.test.ts.snap index 174a96e5020..d6707292de4 100644 --- a/packages/pds/tests/proxied/__snapshots__/views.test.ts.snap +++ b/packages/pds/tests/proxied/__snapshots__/views.test.ts.snap @@ -1206,7 +1206,15 @@ Object { "author": Object { "did": "user(4)", "handle": "dan.test", - "labels": Array [], + "labels": Array [ + Object { + "cts": "1970-01-01T00:00:00.000Z", + "neg": false, + "src": "did:example:labeler", + "uri": "user(4)", + "val": "repo-action-label", + }, + ], "viewer": Object { "blockedBy": false, "following": "record(9)", @@ -1432,36 +1440,36 @@ Object { }, }, Object { - "actor": Object { - "avatar": "https://bsky.public.url/img/avatar/plain/user(1)/cids(0)@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(1)", - "cts": "1970-01-01T00:00:00.000Z", - "neg": false, - "src": "user(0)", - "uri": "record(2)", - "val": "self-label-a", - }, - Object { - "cid": "cids(1)", - "cts": "1970-01-01T00:00:00.000Z", - "neg": false, - "src": "user(0)", - "uri": "record(2)", - "val": "self-label-b", + "post": Object { + "author": Object { + "avatar": "https://bsky.public.url/img/avatar/plain/user(3)/cids(1)@jpeg", + "did": "user(2)", + "displayName": "bobby", + "handle": "bob.test", + "labels": Array [ + Object { + "cid": "cids(5)", + "cts": "1970-01-01T00:00:00.000Z", + "neg": false, + "src": "user(2)", + "uri": "record(6)", + "val": "self-label-a", + }, + Object { + "cid": "cids(5)", + "cts": "1970-01-01T00:00:00.000Z", + "neg": false, + "src": "user(2)", + "uri": "record(6)", + "val": "self-label-b", + }, + ], + "viewer": Object { + "blockedBy": false, + "followedBy": "record(5)", + "following": "record(4)", + "muted": false, }, - ], - "viewer": Object { - "blockedBy": false, - "followedBy": "record(1)", - "following": "record(0)", - "muted": false, }, "cid": "cids(11)", "indexedAt": "1970-01-01T00:00:00.000Z", @@ -1516,20 +1524,12 @@ Object { "indexedAt": "1970-01-01T00:00:00.000Z", "labels": Array [ Object { - "cid": "cids(2)", + "cid": "cids(13)", "cts": "1970-01-01T00:00:00.000Z", "neg": false, - "src": "user(2)", - "uri": "record(3)", - "val": "self-label-a", - }, - Object { - "cid": "cids(2)", - "cts": "1970-01-01T00:00:00.000Z", - "neg": false, - "src": "user(2)", - "uri": "record(3)", - "val": "self-label-b", + "src": "user(0)", + "uri": "record(15)", + "val": "self-label", }, ], "likeCount": 0, @@ -1553,7 +1553,6 @@ Object { }, }, ], - "uri": "record(4)", } `;