diff --git a/lexicons/app/bsky/feed/getListFeed.json b/lexicons/app/bsky/feed/getListFeed.json new file mode 100644 index 00000000000..f7b778bda28 --- /dev/null +++ b/lexicons/app/bsky/feed/getListFeed.json @@ -0,0 +1,42 @@ +{ + "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" }] + } + } +} diff --git a/lexicons/app/bsky/graph/defs.json b/lexicons/app/bsky/graph/defs.json index 44cf55875b4..894cfadc0ef 100644 --- a/lexicons/app/bsky/graph/defs.json +++ b/lexicons/app/bsky/graph/defs.json @@ -47,12 +47,19 @@ }, "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": { diff --git a/packages/api/src/client/index.ts b/packages/api/src/client/index.ts index 761097aad7c..8a32bef870a 100644 --- a/packages/api/src/client/index.ts +++ b/packages/api/src/client/index.ts @@ -94,6 +94,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' @@ -218,6 +219,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 { @@ -1309,6 +1312,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 f3c93c5e805..f1fd2519d74 100644 --- a/packages/api/src/client/lexicons.ts +++ b/packages/api/src/client/lexicons.ts @@ -5160,6 +5160,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', @@ -5687,13 +5740,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: { @@ -6862,6 +6923,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/api/app/bsky/feed/getListFeed.ts b/packages/bsky/src/api/app/bsky/feed/getListFeed.ts new file mode 100644 index 00000000000..f166c8abb99 --- /dev/null +++ b/packages/bsky/src/api/app/bsky/feed/getListFeed.ts @@ -0,0 +1,129 @@ +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('post.sortAt'), ref('post.cid')) + const sortFrom = keyset.unpack(cursor)?.primary + + let builder = ctx.feedService + .selectPostQb() + .innerJoin('list_item', 'list_item.subjectDid', 'post.creator') + .where('list_item.listUri', '=', list) + .where('post.sortAt', '>', getFeedDateThreshold(sortFrom, 3)) + + 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.mute([viewer, item.postAuthorDid]), + ) + return state +} + +const presentation = (state: HydrationState, ctx: Context) => { + const { feedService } = ctx + const { feedItems, cursor, params } = state + const feed = feedService.views.formatFeed(feedItems, state, { + viewer: params.viewer, + }) + return { feed, cursor } +} + +type Context = { + db: Database + actorService: ActorService + feedService: FeedService + graphService: GraphService +} + +type Params = QueryParams & { viewer: string | null } + +type SkeletonState = { + params: Params + feedItems: FeedRow[] + cursor?: string +} + +type HydrationState = SkeletonState & FeedHydrationState diff --git a/packages/bsky/src/api/index.ts b/packages/bsky/src/api/index.ts index 1928cda01d2..3768ed4da0b 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' @@ -70,6 +71,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) diff --git a/packages/bsky/src/lexicon/index.ts b/packages/bsky/src/lexicon/index.ts index 93435056503..52635132470 100644 --- a/packages/bsky/src/lexicon/index.ts +++ b/packages/bsky/src/lexicon/index.ts @@ -83,6 +83,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 { @@ -1101,6 +1103,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 f3c93c5e805..f1fd2519d74 100644 --- a/packages/bsky/src/lexicon/lexicons.ts +++ b/packages/bsky/src/lexicon/lexicons.ts @@ -5160,6 +5160,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', @@ -5687,13 +5740,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: { @@ -6862,6 +6923,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/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'] 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..34d5712d303 --- /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[`list 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/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/list-feed.test.ts b/packages/bsky/tests/views/list-feed.test.ts new file mode 100644 index 00000000000..c9d94f1a9d4 --- /dev/null +++ b/packages/bsky/tests/views/list-feed.test.ts @@ -0,0 +1,194 @@ +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' +import { TAKEDOWN } from '@atproto/api/src/client/types/com/atproto/admin/defs' + +describe('list 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_list_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) + }) + + 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/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 }, 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 5cffb90653d..96b0b5de06c 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' @@ -47,6 +48,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) diff --git a/packages/pds/src/lexicon/index.ts b/packages/pds/src/lexicon/index.ts index 93435056503..52635132470 100644 --- a/packages/pds/src/lexicon/index.ts +++ b/packages/pds/src/lexicon/index.ts @@ -83,6 +83,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 { @@ -1101,6 +1103,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 f3c93c5e805..f1fd2519d74 100644 --- a/packages/pds/src/lexicon/lexicons.ts +++ b/packages/pds/src/lexicon/lexicons.ts @@ -5160,6 +5160,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', @@ -5687,13 +5740,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: { @@ -6862,6 +6923,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 diff --git a/packages/pds/tests/proxied/__snapshots__/views.test.ts.snap b/packages/pds/tests/proxied/__snapshots__/views.test.ts.snap index 1e7b75c9cc8..d6707292de4 100644 --- a/packages/pds/tests/proxied/__snapshots__/views.test.ts.snap +++ b/packages/pds/tests/proxied/__snapshots__/views.test.ts.snap @@ -774,6 +774,788 @@ 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 [ + 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)", + "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 [ diff --git a/packages/pds/tests/proxied/views.test.ts b/packages/pds/tests/proxied/views.test.ts index 84079af1c44..7b81ee8bec3 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']