Refactor feeds & post threads ()

* factor out feed items & mystate

* propose feed items rewordk

* pr feedback

* revamping getTimeline

* feed service & cleanup

* bugfixin

* update timeline snapshots

* update getAuthFeed snapshot

* bugfixin

* revamp getPostThread

* fix up sync & votes tests

* bug in post thread

* fix replyCount

* fix ordering

* move uriImgBuilder to createServices
This commit is contained in:
Daniel Holmgren 2022-12-20 20:38:37 -06:00 committed by GitHub
parent 5be13ac3e3
commit 30ab0d341b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 4742 additions and 5147 deletions

@ -0,0 +1,39 @@
{
"lexicon": 1,
"id": "app.bsky.feed.feedViewPost",
"defs": {
"main": {
"type": "object",
"required": ["post"],
"properties": {
"post": {"type": "ref", "ref": "app.bsky.feed.post#view"},
"reply": {"type": "ref", "ref": "#replyRef"},
"reason": {"type": "union", "refs": ["#reasonTrend", "#reasonRepost"]}
}
},
"replyRef": {
"type": "object",
"required": ["root", "parent"],
"properties": {
"root": {"type": "ref", "ref": "app.bsky.feed.post#view"},
"parent": {"type": "ref", "ref": "app.bsky.feed.post#view"}
}
},
"reasonTrend": {
"type": "object",
"required": ["by", "indexedAt"],
"properties": {
"by": {"type": "ref", "ref": "app.bsky.actor.ref#withInfo"},
"indexedAt": {"type": "datetime"}
}
},
"reasonRepost": {
"type": "object",
"required": ["by", "indexedAt"],
"properties": {
"by": {"type": "ref", "ref": "app.bsky.actor.ref#withInfo"},
"indexedAt": {"type": "datetime"}
}
}
}
}

@ -23,44 +23,11 @@
"cursor": {"type": "string"},
"feed": {
"type": "array",
"items": {"type": "ref", "ref": "#feedItem"}
"items": {"type": "ref", "ref": "app.bsky.feed.feedViewPost"}
}
}
}
}
},
"feedItem": {
"type": "object",
"required": ["uri", "cid", "author", "record", "replyCount", "repostCount", "upvoteCount", "downvoteCount", "indexedAt"],
"properties": {
"uri": {"type": "string"},
"cid": {"type": "string"},
"author": {"type": "ref", "ref": "app.bsky.actor.ref#withInfo"},
"trendedBy": {"type": "ref", "ref": "app.bsky.actor.ref#withInfo"},
"repostedBy": {"type": "ref", "ref": "app.bsky.actor.ref#withInfo"},
"record": {"type": "unknown"},
"embed": {
"type": "union",
"refs": [
"app.bsky.embed.images#presented",
"app.bsky.embed.external#presented"
]
},
"replyCount": {"type": "integer"},
"repostCount": {"type": "integer"},
"upvoteCount": {"type": "integer"},
"downvoteCount": {"type": "integer"},
"indexedAt": {"type": "datetime"},
"myState": {"type": "ref", "ref": "#myState"}
}
},
"myState": {
"type": "object",
"properties": {
"repost": {"type": "string"},
"upvote": {"type": "string"},
"downvote": {"type": "string"}
}
}
}
}

@ -20,7 +20,7 @@
"properties": {
"thread": {
"type": "union",
"refs": ["#post", "#notFoundPost"]
"refs": ["#threadViewPost", "#notFoundPost"]
}
}
}
@ -29,32 +29,16 @@
{"name": "NotFound"}
]
},
"post": {
"threadViewPost": {
"type": "object",
"required": ["uri", "cid", "author", "record", "replyCount", "repostCount", "upvoteCount", "downvoteCount", "indexedAt"],
"required": ["post"],
"properties": {
"uri": {"type": "string"},
"cid": {"type": "string"},
"author": {"type": "ref", "ref": "app.bsky.actor.ref#withInfo"},
"record": {"type": "unknown"},
"embed": {
"type": "union",
"refs": [
"app.bsky.embed.images#presented",
"app.bsky.embed.external#presented"
]
},
"parent": {"type": "union", "refs": ["#post", "#notFoundPost"]},
"replyCount": {"type": "integer"},
"post": {"type": "ref", "ref": "app.bsky.feed.post#view"},
"parent": {"type": "union", "refs": ["#threadViewPost", "#notFoundPost"]},
"replies": {
"type": "array",
"items": {"type": "union", "refs": ["#post", "#notFoundPost"]}
},
"repostCount": {"type": "integer"},
"upvoteCount": {"type": "integer"},
"downvoteCount": {"type": "integer"},
"indexedAt": {"type": "datetime"},
"myState": {"type": "ref", "ref": "#myState"}
"items": {"type": "union", "refs": ["#threadViewPost", "#notFoundPost"]}
}
}
},
"notFoundPost": {
@ -64,14 +48,6 @@
"uri": {"type": "string"},
"notFound": {"type": "boolean", "const": true}
}
},
"myState": {
"type": "object",
"properties": {
"repost": {"type": "string"},
"upvote": {"type": "string"},
"downvote": {"type": "string"}
}
}
}
}

@ -22,44 +22,11 @@
"cursor": {"type": "string"},
"feed": {
"type": "array",
"items": {"type": "ref", "ref": "#feedItem"}
"items": {"type": "ref", "ref": "app.bsky.feed.feedViewPost"}
}
}
}
}
},
"feedItem": {
"type": "object",
"required": ["uri", "cid", "author", "record", "replyCount", "repostCount", "upvoteCount", "downvoteCount", "indexedAt"],
"properties": {
"uri": {"type": "string"},
"cid": {"type": "string"},
"author": {"type": "ref", "ref": "app.bsky.actor.ref#withInfo"},
"trendedBy": {"type": "ref", "ref": "app.bsky.actor.ref#withInfo"},
"repostedBy": {"type": "ref", "ref": "app.bsky.actor.ref#withInfo"},
"record": {"type": "unknown"},
"embed": {
"type": "union",
"refs": [
"app.bsky.embed.images#presented",
"app.bsky.embed.external#presented"
]
},
"replyCount": {"type": "integer"},
"repostCount": {"type": "integer"},
"upvoteCount": {"type": "integer"},
"downvoteCount": {"type": "integer"},
"indexedAt": {"type": "datetime"},
"myState": {"type": "ref", "ref": "#myState"}
}
},
"myState": {
"type": "object",
"properties": {
"repost": {"type": "string"},
"upvote": {"type": "string"},
"downvote": {"type": "string"}
}
}
}
}

@ -48,6 +48,37 @@
"start": {"type": "integer", "minimum": 0},
"end": {"type": "integer", "minimum": 0}
}
},
"view": {
"type": "object",
"required": ["uri", "cid", "author", "record", "replyCount", "repostCount", "upvoteCount", "downvoteCount", "indexedAt", "viewer"],
"properties": {
"uri": {"type": "string"},
"cid": {"type": "string"},
"author": {"type": "ref", "ref": "app.bsky.actor.ref#withInfo"},
"record": {"type": "unknown"},
"embed": {
"type": "union",
"refs": [
"app.bsky.embed.images#presented",
"app.bsky.embed.external#presented"
]
},
"replyCount": {"type": "integer"},
"repostCount": {"type": "integer"},
"upvoteCount": {"type": "integer"},
"downvoteCount": {"type": "integer"},
"indexedAt": {"type": "datetime"},
"viewer": {"type": "ref", "ref": "#viewerState"}
}
},
"viewerState": {
"type": "object",
"properties": {
"repost": {"type": "string"},
"upvote": {"type": "string"},
"downvote": {"type": "string"}
}
}
}
}

@ -40,6 +40,7 @@ import * as AppBskyActorSearchTypeahead from './types/app/bsky/actor/searchTypea
import * as AppBskyActorUpdateProfile from './types/app/bsky/actor/updateProfile'
import * as AppBskyEmbedExternal from './types/app/bsky/embed/external'
import * as AppBskyEmbedImages from './types/app/bsky/embed/images'
import * as AppBskyFeedFeedViewPost from './types/app/bsky/feed/feedViewPost'
import * as AppBskyFeedGetAuthorFeed from './types/app/bsky/feed/getAuthorFeed'
import * as AppBskyFeedGetPostThread from './types/app/bsky/feed/getPostThread'
import * as AppBskyFeedGetRepostedBy from './types/app/bsky/feed/getRepostedBy'
@ -102,6 +103,7 @@ export * as AppBskyActorSearchTypeahead from './types/app/bsky/actor/searchTypea
export * as AppBskyActorUpdateProfile from './types/app/bsky/actor/updateProfile'
export * as AppBskyEmbedExternal from './types/app/bsky/embed/external'
export * as AppBskyEmbedImages from './types/app/bsky/embed/images'
export * as AppBskyFeedFeedViewPost from './types/app/bsky/feed/feedViewPost'
export * as AppBskyFeedGetAuthorFeed from './types/app/bsky/feed/getAuthorFeed'
export * as AppBskyFeedGetPostThread from './types/app/bsky/feed/getPostThread'
export * as AppBskyFeedGetRepostedBy from './types/app/bsky/feed/getRepostedBy'

@ -1566,6 +1566,73 @@ export const schemaDict = {
},
},
},
AppBskyFeedFeedViewPost: {
lexicon: 1,
id: 'app.bsky.feed.feedViewPost',
defs: {
main: {
type: 'object',
required: ['post'],
properties: {
post: {
type: 'ref',
ref: 'lex:app.bsky.feed.post#view',
},
reply: {
type: 'ref',
ref: 'lex:app.bsky.feed.feedViewPost#replyRef',
},
reason: {
type: 'union',
refs: [
'lex:app.bsky.feed.feedViewPost#reasonTrend',
'lex:app.bsky.feed.feedViewPost#reasonRepost',
],
},
},
},
replyRef: {
type: 'object',
required: ['root', 'parent'],
properties: {
root: {
type: 'ref',
ref: 'lex:app.bsky.feed.post#view',
},
parent: {
type: 'ref',
ref: 'lex:app.bsky.feed.post#view',
},
},
},
reasonTrend: {
type: 'object',
required: ['by', 'indexedAt'],
properties: {
by: {
type: 'ref',
ref: 'lex:app.bsky.actor.ref#withInfo',
},
indexedAt: {
type: 'datetime',
},
},
},
reasonRepost: {
type: 'object',
required: ['by', 'indexedAt'],
properties: {
by: {
type: 'ref',
ref: 'lex:app.bsky.actor.ref#withInfo',
},
indexedAt: {
type: 'datetime',
},
},
},
},
},
AppBskyFeedGetAuthorFeed: {
lexicon: 1,
id: 'app.bsky.feed.getAuthorFeed',
@ -1604,90 +1671,13 @@ export const schemaDict = {
type: 'array',
items: {
type: 'ref',
ref: 'lex:app.bsky.feed.getAuthorFeed#feedItem',
ref: 'lex:app.bsky.feed.feedViewPost',
},
},
},
},
},
},
feedItem: {
type: 'object',
required: [
'uri',
'cid',
'author',
'record',
'replyCount',
'repostCount',
'upvoteCount',
'downvoteCount',
'indexedAt',
],
properties: {
uri: {
type: 'string',
},
cid: {
type: 'string',
},
author: {
type: 'ref',
ref: 'lex:app.bsky.actor.ref#withInfo',
},
trendedBy: {
type: 'ref',
ref: 'lex:app.bsky.actor.ref#withInfo',
},
repostedBy: {
type: 'ref',
ref: 'lex:app.bsky.actor.ref#withInfo',
},
record: {
type: 'unknown',
},
embed: {
type: 'union',
refs: [
'lex:app.bsky.embed.images#presented',
'lex:app.bsky.embed.external#presented',
],
},
replyCount: {
type: 'integer',
},
repostCount: {
type: 'integer',
},
upvoteCount: {
type: 'integer',
},
downvoteCount: {
type: 'integer',
},
indexedAt: {
type: 'datetime',
},
myState: {
type: 'ref',
ref: 'lex:app.bsky.feed.getAuthorFeed#myState',
},
},
},
myState: {
type: 'object',
properties: {
repost: {
type: 'string',
},
upvote: {
type: 'string',
},
downvote: {
type: 'string',
},
},
},
},
},
AppBskyFeedGetPostThread: {
@ -1717,7 +1707,7 @@ export const schemaDict = {
thread: {
type: 'union',
refs: [
'lex:app.bsky.feed.getPostThread#post',
'lex:app.bsky.feed.getPostThread#threadViewPost',
'lex:app.bsky.feed.getPostThread#notFoundPost',
],
},
@ -1730,76 +1720,31 @@ export const schemaDict = {
},
],
},
post: {
threadViewPost: {
type: 'object',
required: [
'uri',
'cid',
'author',
'record',
'replyCount',
'repostCount',
'upvoteCount',
'downvoteCount',
'indexedAt',
],
required: ['post'],
properties: {
uri: {
type: 'string',
},
cid: {
type: 'string',
},
author: {
post: {
type: 'ref',
ref: 'lex:app.bsky.actor.ref#withInfo',
},
record: {
type: 'unknown',
},
embed: {
type: 'union',
refs: [
'lex:app.bsky.embed.images#presented',
'lex:app.bsky.embed.external#presented',
],
ref: 'lex:app.bsky.feed.post#view',
},
parent: {
type: 'union',
refs: [
'lex:app.bsky.feed.getPostThread#post',
'lex:app.bsky.feed.getPostThread#threadViewPost',
'lex:app.bsky.feed.getPostThread#notFoundPost',
],
},
replyCount: {
type: 'integer',
},
replies: {
type: 'array',
items: {
type: 'union',
refs: [
'lex:app.bsky.feed.getPostThread#post',
'lex:app.bsky.feed.getPostThread#threadViewPost',
'lex:app.bsky.feed.getPostThread#notFoundPost',
],
},
},
repostCount: {
type: 'integer',
},
upvoteCount: {
type: 'integer',
},
downvoteCount: {
type: 'integer',
},
indexedAt: {
type: 'datetime',
},
myState: {
type: 'ref',
ref: 'lex:app.bsky.feed.getPostThread#myState',
},
},
},
notFoundPost: {
@ -1815,20 +1760,6 @@ export const schemaDict = {
},
},
},
myState: {
type: 'object',
properties: {
repost: {
type: 'string',
},
upvote: {
type: 'string',
},
downvote: {
type: 'string',
},
},
},
},
},
AppBskyFeedGetRepostedBy: {
@ -1952,90 +1883,13 @@ export const schemaDict = {
type: 'array',
items: {
type: 'ref',
ref: 'lex:app.bsky.feed.getTimeline#feedItem',
ref: 'lex:app.bsky.feed.feedViewPost',
},
},
},
},
},
},
feedItem: {
type: 'object',
required: [
'uri',
'cid',
'author',
'record',
'replyCount',
'repostCount',
'upvoteCount',
'downvoteCount',
'indexedAt',
],
properties: {
uri: {
type: 'string',
},
cid: {
type: 'string',
},
author: {
type: 'ref',
ref: 'lex:app.bsky.actor.ref#withInfo',
},
trendedBy: {
type: 'ref',
ref: 'lex:app.bsky.actor.ref#withInfo',
},
repostedBy: {
type: 'ref',
ref: 'lex:app.bsky.actor.ref#withInfo',
},
record: {
type: 'unknown',
},
embed: {
type: 'union',
refs: [
'lex:app.bsky.embed.images#presented',
'lex:app.bsky.embed.external#presented',
],
},
replyCount: {
type: 'integer',
},
repostCount: {
type: 'integer',
},
upvoteCount: {
type: 'integer',
},
downvoteCount: {
type: 'integer',
},
indexedAt: {
type: 'datetime',
},
myState: {
type: 'ref',
ref: 'lex:app.bsky.feed.getTimeline#myState',
},
},
},
myState: {
type: 'object',
properties: {
repost: {
type: 'string',
},
upvote: {
type: 'string',
},
downvote: {
type: 'string',
},
},
},
},
},
AppBskyFeedGetVotes: {
@ -2202,6 +2056,76 @@ export const schemaDict = {
},
},
},
view: {
type: 'object',
required: [
'uri',
'cid',
'author',
'record',
'replyCount',
'repostCount',
'upvoteCount',
'downvoteCount',
'indexedAt',
'viewer',
],
properties: {
uri: {
type: 'string',
},
cid: {
type: 'string',
},
author: {
type: 'ref',
ref: 'lex:app.bsky.actor.ref#withInfo',
},
record: {
type: 'unknown',
},
embed: {
type: 'union',
refs: [
'lex:app.bsky.embed.images#presented',
'lex:app.bsky.embed.external#presented',
],
},
replyCount: {
type: 'integer',
},
repostCount: {
type: 'integer',
},
upvoteCount: {
type: 'integer',
},
downvoteCount: {
type: 'integer',
},
indexedAt: {
type: 'datetime',
},
viewer: {
type: 'ref',
ref: 'lex:app.bsky.feed.post#viewerState',
},
},
},
viewerState: {
type: 'object',
properties: {
repost: {
type: 'string',
},
upvote: {
type: 'string',
},
downvote: {
type: 'string',
},
},
},
},
},
AppBskyFeedRepost: {
@ -3086,6 +3010,7 @@ export const ids = {
AppBskyActorUpdateProfile: 'app.bsky.actor.updateProfile',
AppBskyEmbedExternal: 'app.bsky.embed.external',
AppBskyEmbedImages: 'app.bsky.embed.images',
AppBskyFeedFeedViewPost: 'app.bsky.feed.feedViewPost',
AppBskyFeedGetAuthorFeed: 'app.bsky.feed.getAuthorFeed',
AppBskyFeedGetPostThread: 'app.bsky.feed.getPostThread',
AppBskyFeedGetRepostedBy: 'app.bsky.feed.getRepostedBy',

@ -0,0 +1,30 @@
/**
* GENERATED CODE - DO NOT MODIFY
*/
import * as AppBskyFeedPost from './post'
import * as AppBskyActorRef from '../actor/ref'
export interface Main {
post: AppBskyFeedPost.View
reply?: ReplyRef
reason?: ReasonTrend | ReasonRepost | { $type: string; [k: string]: unknown }
[k: string]: unknown
}
export interface ReplyRef {
root: AppBskyFeedPost.View
parent: AppBskyFeedPost.View
[k: string]: unknown
}
export interface ReasonTrend {
by: AppBskyActorRef.WithInfo
indexedAt: string
[k: string]: unknown
}
export interface ReasonRepost {
by: AppBskyActorRef.WithInfo
indexedAt: string
[k: string]: unknown
}

@ -2,9 +2,7 @@
* GENERATED CODE - DO NOT MODIFY
*/
import { Headers, XRPCError } from '@atproto/xrpc'
import * as AppBskyActorRef from '../actor/ref'
import * as AppBskyEmbedImages from '../embed/images'
import * as AppBskyEmbedExternal from '../embed/external'
import * as AppBskyFeedFeedViewPost from './feedViewPost'
export interface QueryParams {
author: string
@ -16,7 +14,7 @@ export type InputSchema = undefined
export interface OutputSchema {
cursor?: string
feed: FeedItem[]
feed: AppBskyFeedFeedViewPost.Main[]
[k: string]: unknown
}
@ -35,30 +33,3 @@ export function toKnownErr(e: any) {
}
return e
}
export interface FeedItem {
uri: string
cid: string
author: AppBskyActorRef.WithInfo
trendedBy?: AppBskyActorRef.WithInfo
repostedBy?: AppBskyActorRef.WithInfo
record: {}
embed?:
| AppBskyEmbedImages.Presented
| AppBskyEmbedExternal.Presented
| { $type: string; [k: string]: unknown }
replyCount: number
repostCount: number
upvoteCount: number
downvoteCount: number
indexedAt: string
myState?: MyState
[k: string]: unknown
}
export interface MyState {
repost?: string
upvote?: string
downvote?: string
[k: string]: unknown
}

@ -2,9 +2,7 @@
* GENERATED CODE - DO NOT MODIFY
*/
import { Headers, XRPCError } from '@atproto/xrpc'
import * as AppBskyActorRef from '../actor/ref'
import * as AppBskyEmbedImages from '../embed/images'
import * as AppBskyEmbedExternal from '../embed/external'
import * as AppBskyFeedPost from './post'
export interface QueryParams {
uri: string
@ -14,7 +12,10 @@ export interface QueryParams {
export type InputSchema = undefined
export interface OutputSchema {
thread: Post | NotFoundPost | { $type: string; [k: string]: unknown }
thread:
| ThreadViewPost
| NotFoundPost
| { $type: string; [k: string]: unknown }
[k: string]: unknown
}
@ -41,23 +42,17 @@ export function toKnownErr(e: any) {
return e
}
export interface Post {
uri: string
cid: string
author: AppBskyActorRef.WithInfo
record: {}
embed?:
| AppBskyEmbedImages.Presented
| AppBskyEmbedExternal.Presented
export interface ThreadViewPost {
post: AppBskyFeedPost.View
parent?:
| ThreadViewPost
| NotFoundPost
| { $type: string; [k: string]: unknown }
parent?: Post | NotFoundPost | { $type: string; [k: string]: unknown }
replyCount: number
replies?: (Post | NotFoundPost | { $type: string; [k: string]: unknown })[]
repostCount: number
upvoteCount: number
downvoteCount: number
indexedAt: string
myState?: MyState
replies?: (
| ThreadViewPost
| NotFoundPost
| { $type: string; [k: string]: unknown }
)[]
[k: string]: unknown
}
@ -66,10 +61,3 @@ export interface NotFoundPost {
notFound: true
[k: string]: unknown
}
export interface MyState {
repost?: string
upvote?: string
downvote?: string
[k: string]: unknown
}

@ -2,9 +2,7 @@
* GENERATED CODE - DO NOT MODIFY
*/
import { Headers, XRPCError } from '@atproto/xrpc'
import * as AppBskyActorRef from '../actor/ref'
import * as AppBskyEmbedImages from '../embed/images'
import * as AppBskyEmbedExternal from '../embed/external'
import * as AppBskyFeedFeedViewPost from './feedViewPost'
export interface QueryParams {
algorithm?: string
@ -16,7 +14,7 @@ export type InputSchema = undefined
export interface OutputSchema {
cursor?: string
feed: FeedItem[]
feed: AppBskyFeedFeedViewPost.Main[]
[k: string]: unknown
}
@ -35,30 +33,3 @@ export function toKnownErr(e: any) {
}
return e
}
export interface FeedItem {
uri: string
cid: string
author: AppBskyActorRef.WithInfo
trendedBy?: AppBskyActorRef.WithInfo
repostedBy?: AppBskyActorRef.WithInfo
record: {}
embed?:
| AppBskyEmbedImages.Presented
| AppBskyEmbedExternal.Presented
| { $type: string; [k: string]: unknown }
replyCount: number
repostCount: number
upvoteCount: number
downvoteCount: number
indexedAt: string
myState?: MyState
[k: string]: unknown
}
export interface MyState {
repost?: string
upvote?: string
downvote?: string
[k: string]: unknown
}

@ -4,6 +4,7 @@
import * as AppBskyEmbedImages from '../embed/images'
import * as AppBskyEmbedExternal from '../embed/external'
import * as ComAtprotoRepoStrongRef from '../../../com/atproto/repo/strongRef'
import * as AppBskyActorRef from '../actor/ref'
export interface Record {
text: string
@ -36,3 +37,28 @@ export interface TextSlice {
end: number
[k: string]: unknown
}
export interface View {
uri: string
cid: string
author: AppBskyActorRef.WithInfo
record: {}
embed?:
| AppBskyEmbedImages.Presented
| AppBskyEmbedExternal.Presented
| { $type: string; [k: string]: unknown }
replyCount: number
repostCount: number
upvoteCount: number
downvoteCount: number
indexedAt: string
viewer: ViewerState
[k: string]: unknown
}
export interface ViewerState {
repost?: string
upvote?: string
downvote?: string
[k: string]: unknown
}

@ -1,9 +1,8 @@
import { sql } from 'kysely'
import { Server } from '../../../../lexicon'
import { FeedItemType, FeedKeyset, composeFeed } from '../util/feed'
import { countAll } from '../../../../db/util'
import { FeedKeyset, composeFeed } from '../util/feed'
import { paginate } from '../../../../db/pagination'
import AppContext from '../../../../context'
import { FeedRow } from '../../../../services/feed'
export default function (server: Server, ctx: AppContext) {
server.app.bsky.feed.getAuthorFeed({
@ -14,6 +13,8 @@ export default function (server: Server, ctx: AppContext) {
const db = ctx.db.db
const { ref } = db.dynamic
const feedService = ctx.services.feed(ctx.db)
const userLookupCol = author.startsWith('did:')
? 'did_handle.did'
: 'did_handle.handle'
@ -22,145 +23,42 @@ export default function (server: Server, ctx: AppContext) {
.selectAll()
.where(userLookupCol, '=', author)
const postsQb = db
.selectFrom('post')
const postsQb = feedService
.selectPostQb()
.whereExists(
userQb.whereRef('did_handle.did', '=', ref('post.creator')),
)
.select([
sql<FeedItemType>`${'post'}`.as('type'),
'uri as postUri',
'cid as postCid',
'creator as originatorDid',
'indexedAt as cursor',
])
const repostsQb = db
.selectFrom('repost')
const repostsQb = feedService
.selectRepostQb()
.whereExists(
userQb.whereRef('did_handle.did', '=', ref('repost.creator')),
)
.select([
sql<FeedItemType>`${'repost'}`.as('type'),
'subject as postUri',
'subjectCid as postCid',
'creator as originatorDid',
'indexedAt as cursor',
])
const trendsQb = db
.selectFrom('trend')
const trendsQb = feedService
.selectTrendQb()
.whereExists(
userQb.whereRef('did_handle.did', '=', ref('trend.creator')),
)
.select([
sql<FeedItemType>`${'trend'}`.as('type'),
'subject as postUri',
'subjectCid as postCid',
'creator as originatorDid',
'indexedAt as cursor',
])
let feedItemsQb = db
.selectFrom(postsQb.union(repostsQb).union(trendsQb).as('feed_items'))
.innerJoin('post', 'post.uri', 'postUri')
.innerJoin('ipld_block', 'ipld_block.cid', 'post.cid')
.innerJoin('did_handle as author', 'author.did', 'post.creator')
.leftJoin(
'profile as author_profile',
'author_profile.creator',
'author.did',
)
.innerJoin(
'did_handle as originator',
'originator.did',
'originatorDid',
)
.leftJoin(
'profile as originator_profile',
'originator_profile.creator',
'originatorDid',
)
.select([
'type',
'postUri',
'postCid',
'cursor',
'ipld_block.content as recordBytes',
'ipld_block.indexedAt as indexedAt',
'author.did as authorDid',
'author.declarationCid as authorDeclarationCid',
'author.actorType as authorActorType',
'author.handle as authorHandle',
'author.actorType as authorActorType',
'author_profile.displayName as authorDisplayName',
'author_profile.avatarCid as authorAvatarCid',
'originator.did as originatorDid',
'originator.declarationCid as originatorDeclarationCid',
'originator.actorType as originatorActorType',
'originator.handle as originatorHandle',
'originator.actorType as originatorActorType',
'originator_profile.displayName as originatorDisplayName',
'originator_profile.avatarCid as originatorAvatarCid',
db
.selectFrom('vote')
.whereRef('subject', '=', ref('postUri'))
.where('direction', '=', 'up')
.select(countAll.as('count'))
.as('upvoteCount'),
db
.selectFrom('vote')
.whereRef('subject', '=', ref('postUri'))
.where('direction', '=', 'down')
.select(countAll.as('count'))
.as('downvoteCount'),
db
.selectFrom('repost')
.whereRef('subject', '=', ref('postUri'))
.select(countAll.as('count'))
.as('repostCount'),
db
.selectFrom('post')
.whereRef('replyParent', '=', ref('postUri'))
.select(countAll.as('count'))
.as('replyCount'),
db
.selectFrom('repost')
.where('creator', '=', requester)
.whereRef('subject', '=', ref('postUri'))
.select('uri')
.as('requesterRepost'),
db
.selectFrom('vote')
.where('creator', '=', requester)
.whereRef('subject', '=', ref('postUri'))
.where('direction', '=', 'up')
.select('uri')
.as('requesterUpvote'),
db
.selectFrom('vote')
.where('creator', '=', requester)
.whereRef('subject', '=', ref('postUri'))
.where('direction', '=', 'down')
.select('uri')
.as('requesterDownvote'),
])
const keyset = new FeedKeyset(ref('cursor'), ref('postCid'))
let feedItemsQb = db
.selectFrom(postsQb.union(repostsQb).union(trendsQb).as('feed_items'))
.selectAll()
feedItemsQb = paginate(feedItemsQb, {
limit,
before,
keyset,
})
const queryRes = await feedItemsQb.execute()
const feed = await composeFeed(db, ctx.imgUriBuilder, queryRes)
const feedItems: FeedRow[] = await feedItemsQb.execute()
const feed = await composeFeed(feedService, feedItems, requester)
return {
encoding: 'application/json',
body: {
feed,
cursor: keyset.packFromResult(queryRes),
cursor: keyset.packFromResult(feedItems),
},
}
},

@ -1,13 +1,19 @@
import { InvalidRequestError } from '@atproto/xrpc-server'
import * as common from '@atproto/common'
import { Server } from '../../../../lexicon'
import * as GetPostThread from '../../../../lexicon/types/app/bsky/feed/getPostThread'
import DatabaseSchema from '../../../../db/database-schema'
import { countAll } from '../../../../db/util'
import { getDeclaration } from '../util'
import { ImageUriBuilder } from '../../../../image/uri'
import { embedsForPosts, FeedEmbeds } from '../util/embeds'
import AppContext from '../../../../context'
import {
ActorViewMap,
FeedEmbeds,
FeedRow,
FeedService,
PostInfoMap,
} from '../../../../services/feed'
import { InvalidRequestError } from '@atproto/xrpc-server'
export type PostThread = {
post: FeedRow
parent?: PostThread | ParentNotFoundError
replies?: PostThread[]
}
export default function (server: Server, ctx: AppContext) {
server.app.bsky.feed.getPostThread({
@ -15,36 +21,27 @@ export default function (server: Server, ctx: AppContext) {
handler: async ({ params, auth }) => {
const { uri, depth = 6 } = params
const requester = auth.credentials.did
const { db, imgUriBuilder } = ctx
const queryRes = await postInfoBuilder(db.db, requester)
.where('post.uri', '=', uri)
.executeTakeFirst()
const feedService = ctx.services.feed(ctx.db)
if (!queryRes) {
const threadData = await getThreadData(feedService, uri, depth)
if (!threadData) {
throw new InvalidRequestError(`Post not found: ${uri}`, 'NotFound')
}
const relevant = getRelevantIds(threadData)
const [actors, posts, embeds] = await Promise.all([
feedService.getActorViews(Array.from(relevant.dids)),
feedService.getPostViews(Array.from(relevant.uris), requester),
feedService.embedsForPosts(Array.from(relevant.uris)),
])
const embeds = await embedsForPosts(db.db, imgUriBuilder, [queryRes.uri])
const thread = rowToPost(imgUriBuilder, embeds, queryRes)
if (depth > 0) {
thread.replies = await getReplies(
db.db,
imgUriBuilder,
thread,
depth - 1,
requester,
)
}
if (queryRes.parent !== null) {
thread.parent = await getAncestors(
db.db,
imgUriBuilder,
queryRes.parent,
requester,
)
}
const thread = composeThread(
threadData,
feedService,
posts,
actors,
embeds,
)
return {
encoding: 'application/json',
body: { thread },
@ -53,169 +50,134 @@ export default function (server: Server, ctx: AppContext) {
})
}
const getAncestors = async (
db: DatabaseSchema,
imgUriBuilder: ImageUriBuilder,
parentUri: string,
requester: string,
): Promise<GetPostThread.Post | GetPostThread.NotFoundPost> => {
const parentRes = await postInfoBuilder(db, requester)
.where('post.uri', '=', parentUri)
.executeTakeFirst()
if (!parentRes) {
return {
$type: 'app.bsky.feed.getPostThread#notFoundPost',
uri: parentUri,
notFound: true,
const composeThread = (
threadData: PostThread,
feedService: FeedService,
posts: PostInfoMap,
actors: ActorViewMap,
embeds: FeedEmbeds,
) => {
const post = feedService.formatPostView(
threadData.post.postUri,
actors,
posts,
embeds,
)
let parent
if (threadData.parent) {
if (threadData.parent instanceof ParentNotFoundError) {
parent = {
$type: 'app.bsky.feed.getPostThread#notFoundPost',
uri: threadData.parent.uri,
notFound: true,
}
} else {
parent = composeThread(
threadData.parent,
feedService,
posts,
actors,
embeds,
)
}
}
const embeds = await embedsForPosts(db, imgUriBuilder, [parentRes.uri])
const parentObj = rowToPost(imgUriBuilder, embeds, parentRes)
if (parentRes.parent !== null) {
parentObj.parent = await getAncestors(
db,
imgUriBuilder,
parentRes.parent,
requester,
let replies
if (threadData.replies) {
replies = threadData.replies.map((reply) =>
composeThread(reply, feedService, posts, actors, embeds),
)
}
return parentObj
return {
$type: 'app.bksy.feed.getPostThread#threadViewPost',
post,
parent,
replies,
}
}
const getReplies = async (
db: DatabaseSchema,
imgUriBuilder: ImageUriBuilder,
parent: GetPostThread.Post,
const getRelevantIds = (
thread: PostThread,
): { dids: Set<string>; uris: Set<string> } => {
const dids = new Set<string>()
const uris = new Set<string>()
if (thread.parent && !(thread.parent instanceof ParentNotFoundError)) {
const fromParent = getRelevantIds(thread.parent)
fromParent.dids.forEach((did) => dids.add(did))
fromParent.uris.forEach((uri) => uris.add(uri))
}
if (thread.replies) {
for (const reply of thread.replies) {
const fromChild = getRelevantIds(reply)
fromChild.dids.forEach((did) => dids.add(did))
fromChild.uris.forEach((uri) => uris.add(uri))
}
}
dids.add(thread.post.authorDid)
uris.add(thread.post.postUri)
return { dids, uris }
}
const getThreadData = async (
feedService: FeedService,
uri: string,
depth: number,
requester: string,
): Promise<GetPostThread.Post[]> => {
const res = await postInfoBuilder(db, requester)
.where('post.replyParent', '=', parent.uri)
): Promise<PostThread | null> => {
const post = await feedService
.selectPostQb()
.where('post.uri', '=', uri)
.executeTakeFirst()
if (!post) return null
return {
post,
parent: post.replyParent
? await getParentData(feedService, post.replyParent)
: undefined,
replies: await getChildrenData(feedService, uri, depth),
}
}
const getParentData = async (
feedService: FeedService,
uri: string,
): Promise<PostThread | ParentNotFoundError> => {
const post = await feedService
.selectPostQb()
.where('post.uri', '=', uri)
.executeTakeFirst()
if (!post) return new ParentNotFoundError(uri)
return {
post,
parent: post.replyParent
? await getParentData(feedService, post.replyParent)
: undefined,
replies: [],
}
}
const getChildrenData = async (
feedService: FeedService,
uri: string,
depth: number,
): Promise<PostThread[] | undefined> => {
if (depth === 0) return undefined
const children = await feedService
.selectPostQb()
.where('post.replyParent', '=', uri)
.orderBy('post.createdAt', 'desc')
.execute()
const postUris = res.map((row) => row.uri)
const embeds = await embedsForPosts(db, imgUriBuilder, postUris)
const got = await Promise.all(
res.map(async (row) => {
const post = rowToPost(imgUriBuilder, embeds, row, parent)
if (depth > 0) {
post.replies = await getReplies(
db,
imgUriBuilder,
post,
depth - 1,
requester,
)
}
return post
}),
return Promise.all(
children.map(async (row) => ({
post: row,
replies: await getChildrenData(feedService, row.postUri, depth - 1),
})),
)
return got
}
// selects all the needed info about a post, just need to supply the `where` clause
// @TODO break this query up, share parts with home/author feeds
const postInfoBuilder = (db: DatabaseSchema, requester: string) => {
const { ref } = db.dynamic
return db
.selectFrom('post')
.innerJoin('ipld_block', 'ipld_block.cid', 'post.cid')
.innerJoin('did_handle as author', 'author.did', 'post.creator')
.leftJoin(
'profile as author_profile',
'author.did',
'author_profile.creator',
)
.select([
'post.uri as uri',
'post.cid as cid',
'post.replyParent as parent',
'author.did as authorDid',
'author.declarationCid as authorDeclarationCid',
'author.actorType as authorActorType',
'author.handle as authorHandle',
'author_profile.displayName as authorDisplayName',
'author_profile.avatarCid as authorAvatarCid',
'ipld_block.content as recordBytes',
'ipld_block.indexedAt as indexedAt',
db
.selectFrom('vote')
.whereRef('subject', '=', ref('post.uri'))
.where('direction', '=', 'up')
.select(countAll.as('count'))
.as('upvoteCount'),
db
.selectFrom('vote')
.whereRef('subject', '=', ref('post.uri'))
.where('direction', '=', 'down')
.select(countAll.as('count'))
.as('downvoteCount'),
db
.selectFrom('repost')
.select(countAll.as('count'))
.whereRef('subject', '=', ref('post.uri'))
.as('repostCount'),
db
.selectFrom('post as reply')
.select(countAll.as('count'))
.whereRef('replyParent', '=', ref('post.uri'))
.as('replyCount'),
db
.selectFrom('repost')
.select('uri')
.where('creator', '=', requester)
.whereRef('subject', '=', ref('post.uri'))
.as('requesterRepost'),
db
.selectFrom('vote')
.where('creator', '=', requester)
.whereRef('subject', '=', ref('post.uri'))
.where('direction', '=', 'up')
.select('uri')
.as('requesterUpvote'),
db
.selectFrom('vote')
.where('creator', '=', requester)
.whereRef('subject', '=', ref('post.uri'))
.where('direction', '=', 'down')
.select('uri')
.as('requesterDownvote'),
])
}
// converts the raw SQL output to a Post object
// unfortunately not type-checked yet, so change with caution!
const rowToPost = (
imgUriBuilder: ImageUriBuilder,
embeds: FeedEmbeds,
row: any,
parent?: GetPostThread.Post,
): GetPostThread.Post => {
return {
$type: 'app.bsky.feed.getPostThread#post',
uri: row.uri,
cid: row.cid,
author: {
did: row.authorDid,
declaration: getDeclaration('author', row),
handle: row.authorHandle,
displayName: row.authorDisplayName || undefined,
avatar: row.authorAvatarCid
? imgUriBuilder.getCommonSignedUri('avatar', row.authorAvatarCid)
: undefined,
},
record: common.ipldBytesToRecord(row.recordBytes),
embed: embeds[row.uri],
parent: parent ? { ...parent } : undefined,
replyCount: row.replyCount || 0,
upvoteCount: row.upvoteCount || 0,
downvoteCount: row.downvoteCount || 0,
repostCount: row.repostCount || 0,
indexedAt: row.indexedAt,
myState: {
repost: row.requesterRepost || undefined,
upvote: row.requesterUpvote || undefined,
downvote: row.requesterDownvote || undefined,
},
class ParentNotFoundError extends Error {
constructor(public uri: string) {
super(`Parent not found: ${uri}`)
}
}

@ -1,16 +1,9 @@
import { sql } from 'kysely'
import { InvalidRequestError } from '@atproto/xrpc-server'
import { Server } from '../../../../lexicon'
import { isEnum } from '../util'
import {
FeedAlgorithm,
FeedItemType,
FeedKeyset,
composeFeed,
} from '../util/feed'
import { countAll } from '../../../../db/util'
import { FeedAlgorithm, FeedKeyset, composeFeed } from '../util/feed'
import { paginate } from '../../../../db/pagination'
import AppContext from '../../../../context'
import { FeedRow } from '../../../../services/feed'
export default function (server: Server, ctx: AppContext) {
server.app.bsky.feed.getTimeline({
@ -21,167 +14,49 @@ export default function (server: Server, ctx: AppContext) {
const { ref } = db.dynamic
const requester = auth.credentials.did
let feedAlgorithm: FeedAlgorithm
if (isEnum(FeedAlgorithm, algorithm)) {
feedAlgorithm = algorithm
} else if (algorithm === undefined) {
feedAlgorithm = FeedAlgorithm.ReverseChronological
} else {
if (algorithm && algorithm !== FeedAlgorithm.ReverseChronological) {
throw new InvalidRequestError(`Unsupported algorithm: ${algorithm}`)
}
let postsQb = db
.selectFrom('post')
.select([
sql<FeedItemType>`${'post'}`.as('type'),
'uri as postUri',
'cid as postCid',
'creator as originatorDid',
'indexedAt as cursor',
])
const feedService = ctx.services.feed(ctx.db)
let repostsQb = db
.selectFrom('repost')
.select([
sql<FeedItemType>`${'repost'}`.as('type'),
'subject as postUri',
'subjectCid as postCid',
'creator as originatorDid',
'indexedAt as cursor',
])
const followingIdsSubquery = db
.selectFrom('follow')
.select('follow.subjectDid')
.where('follow.creator', '=', requester)
let trendsQb = db
.selectFrom('trend')
.select([
sql<FeedItemType>`${'trend'}`.as('type'),
'subject as postUri',
'subjectCid as postCid',
'creator as originatorDid',
'indexedAt as cursor',
])
const postsQb = feedService
.selectPostQb()
.where('creator', '=', requester)
.orWhere('creator', 'in', followingIdsSubquery)
if (feedAlgorithm === FeedAlgorithm.Firehose) {
// All posts
} else if (feedAlgorithm === FeedAlgorithm.ReverseChronological) {
// Followee's posts/reposts/trends, and requester's posts
const followingIdsSubquery = db
.selectFrom('follow')
.select('follow.subjectDid')
.where('follow.creator', '=', requester)
repostsQb = repostsQb
.where('creator', '=', requester)
.orWhere('creator', 'in', followingIdsSubquery)
trendsQb = trendsQb
.where('creator', '=', requester)
.orWhere('creator', 'in', followingIdsSubquery)
postsQb = postsQb
.where('creator', '=', requester)
.orWhere('creator', 'in', followingIdsSubquery)
} else {
const exhaustiveCheck: never = feedAlgorithm
throw new Error(`Unhandled case: ${exhaustiveCheck}`)
}
const repostsQb = feedService
.selectRepostQb()
.where('repost.creator', '=', requester)
.orWhere('repost.creator', 'in', followingIdsSubquery)
let feedItemsQb = db
.selectFrom(postsQb.union(repostsQb).union(trendsQb).as('feed_items'))
.innerJoin('post', 'post.uri', 'postUri')
.innerJoin('ipld_block', 'ipld_block.cid', 'post.cid')
.innerJoin('did_handle as author', 'author.did', 'post.creator')
.leftJoin(
'profile as author_profile',
'author_profile.creator',
'author.did',
)
.innerJoin(
'did_handle as originator',
'originator.did',
'originatorDid',
)
.leftJoin(
'profile as originator_profile',
'originator_profile.creator',
'originatorDid',
)
.select([
'type',
'postUri',
'postCid',
'cursor',
'ipld_block.content as recordBytes',
'ipld_block.indexedAt as indexedAt',
'author.did as authorDid',
'author.declarationCid as authorDeclarationCid',
'author.actorType as authorActorType',
'author.handle as authorHandle',
'author.actorType as authorActorType',
'author_profile.displayName as authorDisplayName',
'author_profile.avatarCid as authorAvatarCid',
'originator.did as originatorDid',
'originator.declarationCid as originatorDeclarationCid',
'originator.actorType as originatorActorType',
'originator.handle as originatorHandle',
'originator.actorType as originatorActorType',
'originator_profile.displayName as originatorDisplayName',
'originator_profile.avatarCid as originatorAvatarCid',
db
.selectFrom('vote')
.whereRef('subject', '=', ref('postUri'))
.where('direction', '=', 'up')
.select(countAll.as('count'))
.as('upvoteCount'),
db
.selectFrom('vote')
.whereRef('subject', '=', ref('postUri'))
.where('direction', '=', 'down')
.select(countAll.as('count'))
.as('downvoteCount'),
db
.selectFrom('repost')
.whereRef('subject', '=', ref('postUri'))
.select(countAll.as('count'))
.as('repostCount'),
db
.selectFrom('post')
.whereRef('replyParent', '=', ref('postUri'))
.select(countAll.as('count'))
.as('replyCount'),
db
.selectFrom('repost')
.where('creator', '=', requester)
.whereRef('subject', '=', ref('postUri'))
.select('uri')
.as('requesterRepost'),
db
.selectFrom('vote')
.where('creator', '=', requester)
.whereRef('subject', '=', ref('postUri'))
.where('direction', '=', 'up')
.select('uri')
.as('requesterUpvote'),
db
.selectFrom('vote')
.where('creator', '=', requester)
.whereRef('subject', '=', ref('postUri'))
.where('direction', '=', 'down')
.select('uri')
.as('requesterDownvote'),
])
const trendsQb = feedService
.selectTrendQb()
.where('trend.creator', '=', requester)
.orWhere('trend.creator', 'in', followingIdsSubquery)
const keyset = new FeedKeyset(ref('cursor'), ref('postCid'))
let feedItemsQb = db
.selectFrom(postsQb.union(repostsQb).union(trendsQb).as('feed_items'))
.selectAll()
feedItemsQb = paginate(feedItemsQb, {
limit,
before,
keyset,
})
const queryRes = await feedItemsQb.execute()
const feed = await composeFeed(db, ctx.imgUriBuilder, queryRes)
const feedItems: FeedRow[] = await feedItemsQb.execute()
const feed = await composeFeed(feedService, feedItems, requester)
return {
encoding: 'application/json',
body: {
feed,
cursor: keyset.packFromResult(queryRes),
cursor: keyset.packFromResult(feedItems),
},
}
},

@ -1,61 +0,0 @@
import { Presented as PresentedImage } from '../../../../lexicon/types/app/bsky/embed/images'
import { Presented as PresentedExternal } from '../../../../lexicon/types/app/bsky/embed/external'
import { ImageUriBuilder } from '../../../../image/uri'
import DatabaseSchema from '../../../../db/database-schema'
export type FeedEmbeds = {
[uri: string]: PresentedImage | PresentedExternal
}
export const embedsForPosts = async (
db: DatabaseSchema,
imgUriBuilder: ImageUriBuilder,
postUris: string[],
): Promise<FeedEmbeds> => {
if (postUris.length < 1) {
return {}
}
const imgPromise = db
.selectFrom('post_embed_image')
.selectAll()
.where('postUri', 'in', postUris)
.orderBy('postUri')
.orderBy('position')
.execute()
const extPromise = db
.selectFrom('post_embed_external')
.selectAll()
.where('postUri', 'in', postUris)
.execute()
const [images, externals] = await Promise.all([imgPromise, extPromise])
const imgEmbeds = images.reduce((acc, cur) => {
if (!acc[cur.postUri]) {
acc[cur.postUri] = {
$type: 'app.bsky.embed.images#presented',
images: [],
}
}
acc[cur.postUri].images.push({
thumb: imgUriBuilder.getCommonSignedUri('feed_thumbnail', cur.imageCid),
fullsize: imgUriBuilder.getCommonSignedUri('feed_fullsize', cur.imageCid),
alt: cur.alt,
})
return acc
}, {} as { [uri: string]: PresentedImage })
return externals.reduce((acc, cur) => {
if (!acc[cur.postUri]) {
acc[cur.postUri] = {
$type: 'app.bsky.embed.external#presented',
external: {
uri: cur.uri,
title: cur.title,
description: cur.description,
thumb: cur.thumbCid
? imgUriBuilder.getCommonSignedUri('feed_thumbnail', cur.thumbCid)
: undefined,
},
}
}
return acc
}, imgEmbeds as FeedEmbeds)
}

@ -1,113 +1,80 @@
import * as common from '@atproto/common'
import { getDeclaration } from '.'
import { AtUri } from '@atproto/uri'
import { TimeCidKeyset } from '../../../../db/pagination'
import * as GetAuthorFeed from '../../../../lexicon/types/app/bsky/feed/getAuthorFeed'
import * as GetTimeline from '../../../../lexicon/types/app/bsky/feed/getTimeline'
import { CID } from 'multiformats/cid'
import { ImageUriBuilder } from '../../../../image/uri'
import { embedsForPosts, FeedEmbeds } from './embeds'
import DatabaseSchema from '../../../../db/database-schema'
import { Main as FeedViewPost } from '../../../../lexicon/types/app/bsky/feed/feedViewPost'
import { FeedRow, FeedService } from '../../../../services/feed'
// Present post and repost results into FeedItems
// Present post and repost results into FeedViewPosts
// Including links to embedded media
export const composeFeed = async (
db: DatabaseSchema,
imgUriBuilder: ImageUriBuilder,
feedService: FeedService,
rows: FeedRow[],
): Promise<FeedItem[]> => {
const feedUris = rows.map((row) => row.postUri)
const embeds = await embedsForPosts(db, imgUriBuilder, feedUris)
return rows.map(rowToFeedItem(imgUriBuilder, embeds))
}
requester: string,
): Promise<FeedViewPost[]> => {
const actorDids = new Set<string>()
const postUris = new Set<string>()
for (const row of rows) {
actorDids.add(row.originatorDid)
actorDids.add(row.authorDid)
postUris.add(row.postUri)
if (row.replyParent) {
postUris.add(row.replyParent)
actorDids.add(new AtUri(row.replyParent).host)
}
if (row.replyRoot) {
postUris.add(row.replyRoot)
actorDids.add(new AtUri(row.replyRoot).host)
}
}
const [actors, posts, embeds] = await Promise.all([
feedService.getActorViews(Array.from(actorDids)),
feedService.getPostViews(Array.from(postUris), requester),
feedService.embedsForPosts(Array.from(postUris)),
])
export const rowToFeedItem =
(imgUriBuilder: ImageUriBuilder, embeds: FeedEmbeds) =>
(row: FeedRow): FeedItem => ({
uri: row.postUri,
cid: row.postCid,
author: rowToAuthor(imgUriBuilder, row),
trendedBy:
row.type === 'trend' ? rowToOriginator(imgUriBuilder, row) : undefined,
repostedBy:
row.type === 'repost' ? rowToOriginator(imgUriBuilder, row) : undefined,
record: common.ipldBytesToRecord(row.recordBytes),
embed: embeds[row.postUri],
replyCount: row.replyCount,
repostCount: row.repostCount,
upvoteCount: row.upvoteCount,
downvoteCount: row.downvoteCount,
indexedAt: row.indexedAt,
myState: {
repost: row.requesterRepost ?? undefined,
upvote: row.requesterUpvote ?? undefined,
downvote: row.requesterDownvote ?? undefined,
},
})
const feed: FeedViewPost[] = []
for (const row of rows) {
const post = feedService.formatPostView(row.postUri, actors, posts, embeds)
const originator = actors[row.originatorDid]
if (post && originator) {
let reasonType: string | undefined
if (row.type === 'trend') {
reasonType = 'app.bsky.feed.feedViewPost#reasonTrend'
} else if (row.type === 'repost') {
reasonType = 'app.bsky.feed.feedViewPost#reasonRepost'
}
const replyParent = row.replyParent
? feedService.formatPostView(row.replyParent, actors, posts, embeds)
: undefined
const replyRoot = row.replyRoot
? feedService.formatPostView(row.replyRoot, actors, posts, embeds)
: undefined
const rowToAuthor = (imgUriBuilder: ImageUriBuilder, row: FeedRow) => ({
did: row.authorDid,
declaration: getDeclaration('author', row),
handle: row.authorHandle,
displayName: row.authorDisplayName ?? undefined,
avatar: row.authorAvatarCid
? imgUriBuilder.getCommonSignedUri('avatar', row.authorAvatarCid)
: undefined,
})
const rowToOriginator = (imgUriBuilder: ImageUriBuilder, row: FeedRow) => ({
did: row.originatorDid,
declaration: getDeclaration('originator', row),
handle: row.originatorHandle,
displayName: row.originatorDisplayName ?? undefined,
avatar: row.originatorAvatarCid
? imgUriBuilder.getSignedUri({
cid: CID.parse(row.originatorAvatarCid),
format: 'jpeg',
fit: 'cover',
height: 250,
width: 250,
min: true,
feed.push({
post,
reason: reasonType
? {
$type: reasonType,
by: actors[row.originatorDid],
indexedAt: row.cursor,
}
: undefined,
reply:
replyRoot && replyParent
? {
root: replyRoot,
parent: replyParent,
}
: undefined,
})
: undefined,
})
}
}
return feed
}
export enum FeedAlgorithm {
Firehose = 'firehose',
ReverseChronological = 'reverse-chronological',
}
type FeedItem = GetAuthorFeed.FeedItem & GetTimeline.FeedItem
export type FeedItemType = 'post' | 'repost' | 'trend'
type FeedRow = {
type: FeedItemType
postUri: string
postCid: string
cursor: string
recordBytes: Uint8Array
indexedAt: string
authorDid: string
authorDeclarationCid: string
authorActorType: string
authorHandle: string
authorDisplayName: string | null
authorAvatarCid: string | null
originatorDid: string
originatorDeclarationCid: string
originatorActorType: string
originatorHandle: string
originatorDisplayName: string | null
originatorAvatarCid: string | null
upvoteCount: number
downvoteCount: number
repostCount: number
replyCount: number
requesterRepost: string | null
requesterUpvote: string | null
requesterDownvote: string | null
}
export class FeedKeyset extends TimeCidKeyset<FeedRow> {
labelResult(result: FeedRow) {
return { primary: result.cursor, secondary: result.postCid }

@ -62,8 +62,6 @@ export class PDS {
const messageQueue = new SqlMessageQueue('pds', db)
streamConsumers.listen(messageQueue, blobstore, auth, keypair)
const services = createServices(messageQueue, blobstore)
const mailTransport =
config.emailSmtpUrl !== undefined
? createTransport(config.emailSmtpUrl)
@ -94,6 +92,8 @@ export class PDS {
config.imgUriKey,
)
const services = createServices({ messageQueue, blobstore, imgUriBuilder })
const ctx = new AppContext({
db,
blobstore,

@ -1566,6 +1566,73 @@ export const schemaDict = {
},
},
},
AppBskyFeedFeedViewPost: {
lexicon: 1,
id: 'app.bsky.feed.feedViewPost',
defs: {
main: {
type: 'object',
required: ['post'],
properties: {
post: {
type: 'ref',
ref: 'lex:app.bsky.feed.post#view',
},
reply: {
type: 'ref',
ref: 'lex:app.bsky.feed.feedViewPost#replyRef',
},
reason: {
type: 'union',
refs: [
'lex:app.bsky.feed.feedViewPost#reasonTrend',
'lex:app.bsky.feed.feedViewPost#reasonRepost',
],
},
},
},
replyRef: {
type: 'object',
required: ['root', 'parent'],
properties: {
root: {
type: 'ref',
ref: 'lex:app.bsky.feed.post#view',
},
parent: {
type: 'ref',
ref: 'lex:app.bsky.feed.post#view',
},
},
},
reasonTrend: {
type: 'object',
required: ['by', 'indexedAt'],
properties: {
by: {
type: 'ref',
ref: 'lex:app.bsky.actor.ref#withInfo',
},
indexedAt: {
type: 'datetime',
},
},
},
reasonRepost: {
type: 'object',
required: ['by', 'indexedAt'],
properties: {
by: {
type: 'ref',
ref: 'lex:app.bsky.actor.ref#withInfo',
},
indexedAt: {
type: 'datetime',
},
},
},
},
},
AppBskyFeedGetAuthorFeed: {
lexicon: 1,
id: 'app.bsky.feed.getAuthorFeed',
@ -1604,90 +1671,13 @@ export const schemaDict = {
type: 'array',
items: {
type: 'ref',
ref: 'lex:app.bsky.feed.getAuthorFeed#feedItem',
ref: 'lex:app.bsky.feed.feedViewPost',
},
},
},
},
},
},
feedItem: {
type: 'object',
required: [
'uri',
'cid',
'author',
'record',
'replyCount',
'repostCount',
'upvoteCount',
'downvoteCount',
'indexedAt',
],
properties: {
uri: {
type: 'string',
},
cid: {
type: 'string',
},
author: {
type: 'ref',
ref: 'lex:app.bsky.actor.ref#withInfo',
},
trendedBy: {
type: 'ref',
ref: 'lex:app.bsky.actor.ref#withInfo',
},
repostedBy: {
type: 'ref',
ref: 'lex:app.bsky.actor.ref#withInfo',
},
record: {
type: 'unknown',
},
embed: {
type: 'union',
refs: [
'lex:app.bsky.embed.images#presented',
'lex:app.bsky.embed.external#presented',
],
},
replyCount: {
type: 'integer',
},
repostCount: {
type: 'integer',
},
upvoteCount: {
type: 'integer',
},
downvoteCount: {
type: 'integer',
},
indexedAt: {
type: 'datetime',
},
myState: {
type: 'ref',
ref: 'lex:app.bsky.feed.getAuthorFeed#myState',
},
},
},
myState: {
type: 'object',
properties: {
repost: {
type: 'string',
},
upvote: {
type: 'string',
},
downvote: {
type: 'string',
},
},
},
},
},
AppBskyFeedGetPostThread: {
@ -1717,7 +1707,7 @@ export const schemaDict = {
thread: {
type: 'union',
refs: [
'lex:app.bsky.feed.getPostThread#post',
'lex:app.bsky.feed.getPostThread#threadViewPost',
'lex:app.bsky.feed.getPostThread#notFoundPost',
],
},
@ -1730,76 +1720,31 @@ export const schemaDict = {
},
],
},
post: {
threadViewPost: {
type: 'object',
required: [
'uri',
'cid',
'author',
'record',
'replyCount',
'repostCount',
'upvoteCount',
'downvoteCount',
'indexedAt',
],
required: ['post'],
properties: {
uri: {
type: 'string',
},
cid: {
type: 'string',
},
author: {
post: {
type: 'ref',
ref: 'lex:app.bsky.actor.ref#withInfo',
},
record: {
type: 'unknown',
},
embed: {
type: 'union',
refs: [
'lex:app.bsky.embed.images#presented',
'lex:app.bsky.embed.external#presented',
],
ref: 'lex:app.bsky.feed.post#view',
},
parent: {
type: 'union',
refs: [
'lex:app.bsky.feed.getPostThread#post',
'lex:app.bsky.feed.getPostThread#threadViewPost',
'lex:app.bsky.feed.getPostThread#notFoundPost',
],
},
replyCount: {
type: 'integer',
},
replies: {
type: 'array',
items: {
type: 'union',
refs: [
'lex:app.bsky.feed.getPostThread#post',
'lex:app.bsky.feed.getPostThread#threadViewPost',
'lex:app.bsky.feed.getPostThread#notFoundPost',
],
},
},
repostCount: {
type: 'integer',
},
upvoteCount: {
type: 'integer',
},
downvoteCount: {
type: 'integer',
},
indexedAt: {
type: 'datetime',
},
myState: {
type: 'ref',
ref: 'lex:app.bsky.feed.getPostThread#myState',
},
},
},
notFoundPost: {
@ -1815,20 +1760,6 @@ export const schemaDict = {
},
},
},
myState: {
type: 'object',
properties: {
repost: {
type: 'string',
},
upvote: {
type: 'string',
},
downvote: {
type: 'string',
},
},
},
},
},
AppBskyFeedGetRepostedBy: {
@ -1952,90 +1883,13 @@ export const schemaDict = {
type: 'array',
items: {
type: 'ref',
ref: 'lex:app.bsky.feed.getTimeline#feedItem',
ref: 'lex:app.bsky.feed.feedViewPost',
},
},
},
},
},
},
feedItem: {
type: 'object',
required: [
'uri',
'cid',
'author',
'record',
'replyCount',
'repostCount',
'upvoteCount',
'downvoteCount',
'indexedAt',
],
properties: {
uri: {
type: 'string',
},
cid: {
type: 'string',
},
author: {
type: 'ref',
ref: 'lex:app.bsky.actor.ref#withInfo',
},
trendedBy: {
type: 'ref',
ref: 'lex:app.bsky.actor.ref#withInfo',
},
repostedBy: {
type: 'ref',
ref: 'lex:app.bsky.actor.ref#withInfo',
},
record: {
type: 'unknown',
},
embed: {
type: 'union',
refs: [
'lex:app.bsky.embed.images#presented',
'lex:app.bsky.embed.external#presented',
],
},
replyCount: {
type: 'integer',
},
repostCount: {
type: 'integer',
},
upvoteCount: {
type: 'integer',
},
downvoteCount: {
type: 'integer',
},
indexedAt: {
type: 'datetime',
},
myState: {
type: 'ref',
ref: 'lex:app.bsky.feed.getTimeline#myState',
},
},
},
myState: {
type: 'object',
properties: {
repost: {
type: 'string',
},
upvote: {
type: 'string',
},
downvote: {
type: 'string',
},
},
},
},
},
AppBskyFeedGetVotes: {
@ -2202,6 +2056,76 @@ export const schemaDict = {
},
},
},
view: {
type: 'object',
required: [
'uri',
'cid',
'author',
'record',
'replyCount',
'repostCount',
'upvoteCount',
'downvoteCount',
'indexedAt',
'viewer',
],
properties: {
uri: {
type: 'string',
},
cid: {
type: 'string',
},
author: {
type: 'ref',
ref: 'lex:app.bsky.actor.ref#withInfo',
},
record: {
type: 'unknown',
},
embed: {
type: 'union',
refs: [
'lex:app.bsky.embed.images#presented',
'lex:app.bsky.embed.external#presented',
],
},
replyCount: {
type: 'integer',
},
repostCount: {
type: 'integer',
},
upvoteCount: {
type: 'integer',
},
downvoteCount: {
type: 'integer',
},
indexedAt: {
type: 'datetime',
},
viewer: {
type: 'ref',
ref: 'lex:app.bsky.feed.post#viewerState',
},
},
},
viewerState: {
type: 'object',
properties: {
repost: {
type: 'string',
},
upvote: {
type: 'string',
},
downvote: {
type: 'string',
},
},
},
},
},
AppBskyFeedRepost: {
@ -3086,6 +3010,7 @@ export const ids = {
AppBskyActorUpdateProfile: 'app.bsky.actor.updateProfile',
AppBskyEmbedExternal: 'app.bsky.embed.external',
AppBskyEmbedImages: 'app.bsky.embed.images',
AppBskyFeedFeedViewPost: 'app.bsky.feed.feedViewPost',
AppBskyFeedGetAuthorFeed: 'app.bsky.feed.getAuthorFeed',
AppBskyFeedGetPostThread: 'app.bsky.feed.getPostThread',
AppBskyFeedGetRepostedBy: 'app.bsky.feed.getRepostedBy',

@ -0,0 +1,30 @@
/**
* GENERATED CODE - DO NOT MODIFY
*/
import * as AppBskyFeedPost from './post'
import * as AppBskyActorRef from '../actor/ref'
export interface Main {
post: AppBskyFeedPost.View
reply?: ReplyRef
reason?: ReasonTrend | ReasonRepost | { $type: string; [k: string]: unknown }
[k: string]: unknown
}
export interface ReplyRef {
root: AppBskyFeedPost.View
parent: AppBskyFeedPost.View
[k: string]: unknown
}
export interface ReasonTrend {
by: AppBskyActorRef.WithInfo
indexedAt: string
[k: string]: unknown
}
export interface ReasonRepost {
by: AppBskyActorRef.WithInfo
indexedAt: string
[k: string]: unknown
}

@ -3,9 +3,7 @@
*/
import express from 'express'
import { HandlerAuth } from '@atproto/xrpc-server'
import * as AppBskyActorRef from '../actor/ref'
import * as AppBskyEmbedImages from '../embed/images'
import * as AppBskyEmbedExternal from '../embed/external'
import * as AppBskyFeedFeedViewPost from './feedViewPost'
export interface QueryParams {
author: string
@ -17,7 +15,7 @@ export type InputSchema = undefined
export interface OutputSchema {
cursor?: string
feed: FeedItem[]
feed: AppBskyFeedFeedViewPost.Main[]
[k: string]: unknown
}
@ -41,30 +39,3 @@ export type Handler<HA extends HandlerAuth = never> = (ctx: {
req: express.Request
res: express.Response
}) => Promise<HandlerOutput> | HandlerOutput
export interface FeedItem {
uri: string
cid: string
author: AppBskyActorRef.WithInfo
trendedBy?: AppBskyActorRef.WithInfo
repostedBy?: AppBskyActorRef.WithInfo
record: {}
embed?:
| AppBskyEmbedImages.Presented
| AppBskyEmbedExternal.Presented
| { $type: string; [k: string]: unknown }
replyCount: number
repostCount: number
upvoteCount: number
downvoteCount: number
indexedAt: string
myState?: MyState
[k: string]: unknown
}
export interface MyState {
repost?: string
upvote?: string
downvote?: string
[k: string]: unknown
}

@ -3,9 +3,7 @@
*/
import express from 'express'
import { HandlerAuth } from '@atproto/xrpc-server'
import * as AppBskyActorRef from '../actor/ref'
import * as AppBskyEmbedImages from '../embed/images'
import * as AppBskyEmbedExternal from '../embed/external'
import * as AppBskyFeedPost from './post'
export interface QueryParams {
uri: string
@ -15,7 +13,10 @@ export interface QueryParams {
export type InputSchema = undefined
export interface OutputSchema {
thread: Post | NotFoundPost | { $type: string; [k: string]: unknown }
thread:
| ThreadViewPost
| NotFoundPost
| { $type: string; [k: string]: unknown }
[k: string]: unknown
}
@ -41,23 +42,17 @@ export type Handler<HA extends HandlerAuth = never> = (ctx: {
res: express.Response
}) => Promise<HandlerOutput> | HandlerOutput
export interface Post {
uri: string
cid: string
author: AppBskyActorRef.WithInfo
record: {}
embed?:
| AppBskyEmbedImages.Presented
| AppBskyEmbedExternal.Presented
export interface ThreadViewPost {
post: AppBskyFeedPost.View
parent?:
| ThreadViewPost
| NotFoundPost
| { $type: string; [k: string]: unknown }
parent?: Post | NotFoundPost | { $type: string; [k: string]: unknown }
replyCount: number
replies?: (Post | NotFoundPost | { $type: string; [k: string]: unknown })[]
repostCount: number
upvoteCount: number
downvoteCount: number
indexedAt: string
myState?: MyState
replies?: (
| ThreadViewPost
| NotFoundPost
| { $type: string; [k: string]: unknown }
)[]
[k: string]: unknown
}
@ -66,10 +61,3 @@ export interface NotFoundPost {
notFound: true
[k: string]: unknown
}
export interface MyState {
repost?: string
upvote?: string
downvote?: string
[k: string]: unknown
}

@ -3,9 +3,7 @@
*/
import express from 'express'
import { HandlerAuth } from '@atproto/xrpc-server'
import * as AppBskyActorRef from '../actor/ref'
import * as AppBskyEmbedImages from '../embed/images'
import * as AppBskyEmbedExternal from '../embed/external'
import * as AppBskyFeedFeedViewPost from './feedViewPost'
export interface QueryParams {
algorithm?: string
@ -17,7 +15,7 @@ export type InputSchema = undefined
export interface OutputSchema {
cursor?: string
feed: FeedItem[]
feed: AppBskyFeedFeedViewPost.Main[]
[k: string]: unknown
}
@ -41,30 +39,3 @@ export type Handler<HA extends HandlerAuth = never> = (ctx: {
req: express.Request
res: express.Response
}) => Promise<HandlerOutput> | HandlerOutput
export interface FeedItem {
uri: string
cid: string
author: AppBskyActorRef.WithInfo
trendedBy?: AppBskyActorRef.WithInfo
repostedBy?: AppBskyActorRef.WithInfo
record: {}
embed?:
| AppBskyEmbedImages.Presented
| AppBskyEmbedExternal.Presented
| { $type: string; [k: string]: unknown }
replyCount: number
repostCount: number
upvoteCount: number
downvoteCount: number
indexedAt: string
myState?: MyState
[k: string]: unknown
}
export interface MyState {
repost?: string
upvote?: string
downvote?: string
[k: string]: unknown
}

@ -4,6 +4,7 @@
import * as AppBskyEmbedImages from '../embed/images'
import * as AppBskyEmbedExternal from '../embed/external'
import * as ComAtprotoRepoStrongRef from '../../../com/atproto/repo/strongRef'
import * as AppBskyActorRef from '../actor/ref'
export interface Record {
text: string
@ -36,3 +37,28 @@ export interface TextSlice {
end: number
[k: string]: unknown
}
export interface View {
uri: string
cid: string
author: AppBskyActorRef.WithInfo
record: {}
embed?:
| AppBskyEmbedImages.Presented
| AppBskyEmbedExternal.Presented
| { $type: string; [k: string]: unknown }
replyCount: number
repostCount: number
upvoteCount: number
downvoteCount: number
indexedAt: string
viewer: ViewerState
[k: string]: unknown
}
export interface ViewerState {
repost?: string
upvote?: string
downvote?: string
[k: string]: unknown
}

@ -0,0 +1,255 @@
import { sql } from 'kysely'
import * as common from '@atproto/common'
import Database from '../../db'
import { countAll } from '../../db/util'
import { ImageUriBuilder } from '../../image/uri'
import { Presented as PresentedImage } from '../../lexicon/types/app/bsky/embed/images'
import { View as PostView } from '../../lexicon/types/app/bsky/feed/post'
import { ActorViewMap, FeedEmbeds, PostInfoMap, FeedItemType } from './types'
export * from './types'
export class FeedService {
constructor(public db: Database, public imgUriBuilder: ImageUriBuilder) {}
static creator(imgUriBuilder: ImageUriBuilder) {
return (db: Database) => new FeedService(db, imgUriBuilder)
}
selectPostQb() {
return this.db.db
.selectFrom('post')
.select([
sql<FeedItemType>`${'post'}`.as('type'),
'uri as postUri',
'cid as postCid',
'creator as originatorDid',
'creator as authorDid',
'replyParent as replyParent',
'replyRoot as replyRoot',
'indexedAt as cursor',
])
}
selectRepostQb() {
return this.db.db
.selectFrom('repost')
.innerJoin('post', 'post.uri', 'repost.subject')
.select([
sql<FeedItemType>`${'repost'}`.as('type'),
'post.uri as postUri',
'post.cid as postCid',
'repost.creator as originatorDid',
'post.creator as authorDid',
'post.replyParent as replyParent',
'post.replyRoot as replyRoot',
'repost.indexedAt as cursor',
])
}
selectTrendQb() {
return this.db.db
.selectFrom('trend')
.innerJoin('post', 'post.uri', 'trend.subject')
.select([
sql<FeedItemType>`${'trend'}`.as('type'),
'post.uri as postUri',
'post.cid as postCid',
'trend.creator as originatorDid',
'post.creator as authorDid',
'post.replyParent as replyParent',
'post.replyRoot as replyRoot',
'trend.indexedAt as cursor',
])
}
async getActorViews(dids: string[]): Promise<ActorViewMap> {
if (dids.length < 1) return {}
const actors = await this.db.db
.selectFrom('did_handle as actor')
.where('actor.did', 'in', dids)
.leftJoin('profile', 'profile.creator', 'actor.did')
.select([
'actor.did as did',
'actor.declarationCid as declarationCid',
'actor.actorType as actorType',
'actor.handle as handle',
'profile.displayName as displayName',
'profile.avatarCid as avatarCid',
])
.execute()
return actors.reduce((acc, cur) => {
return {
...acc,
[cur.did]: {
did: cur.did,
declaration: {
cid: cur.declarationCid,
actorType: cur.actorType,
},
handle: cur.handle,
displayName: cur.displayName ?? undefined,
avatar: cur.avatarCid
? this.imgUriBuilder.getCommonSignedUri('avatar', cur.avatarCid)
: undefined,
},
}
}, {} as ActorViewMap)
}
async getPostViews(
postUris: string[],
requester: string,
): Promise<PostInfoMap> {
if (postUris.length < 1) return {}
const db = this.db.db
const { ref } = db.dynamic
const posts = await db
.selectFrom('post')
.where('post.uri', 'in', postUris)
.innerJoin('ipld_block', 'ipld_block.cid', 'post.cid')
.select([
'post.uri as uri',
'post.cid as cid',
'post.creator as creator',
'ipld_block.content as recordBytes',
'ipld_block.indexedAt as indexedAt',
db
.selectFrom('vote')
.whereRef('subject', '=', ref('post.uri'))
.where('direction', '=', 'up')
.select(countAll.as('count'))
.as('upvoteCount'),
db
.selectFrom('vote')
.whereRef('subject', '=', ref('post.uri'))
.where('direction', '=', 'down')
.select(countAll.as('count'))
.as('downvoteCount'),
db
.selectFrom('repost')
.whereRef('subject', '=', ref('post.uri'))
.select(countAll.as('count'))
.as('repostCount'),
db
.selectFrom('post as reply')
.whereRef('reply.replyParent', '=', ref('post.uri'))
.select(countAll.as('count'))
.as('replyCount'),
db
.selectFrom('repost')
.where('creator', '=', requester)
.whereRef('subject', '=', ref('post.uri'))
.select('uri')
.as('requesterRepost'),
db
.selectFrom('vote')
.where('creator', '=', requester)
.whereRef('subject', '=', ref('post.uri'))
.where('direction', '=', 'up')
.select('uri')
.as('requesterUpvote'),
db
.selectFrom('vote')
.where('creator', '=', requester)
.whereRef('subject', '=', ref('post.uri'))
.where('direction', '=', 'down')
.select('uri')
.as('requesterDownvote'),
])
.execute()
return posts.reduce(
(acc, cur) => ({
...acc,
[cur.uri]: cur,
}),
{} as PostInfoMap,
)
}
async embedsForPosts(uris: string[]): Promise<FeedEmbeds> {
if (uris.length < 1) {
return {}
}
const imgPromise = this.db.db
.selectFrom('post_embed_image')
.selectAll()
.where('postUri', 'in', uris)
.orderBy('postUri')
.orderBy('position')
.execute()
const extPromise = this.db.db
.selectFrom('post_embed_external')
.selectAll()
.where('postUri', 'in', uris)
.execute()
const [images, externals] = await Promise.all([imgPromise, extPromise])
const imgEmbeds = images.reduce((acc, cur) => {
if (!acc[cur.postUri]) {
acc[cur.postUri] = {
$type: 'app.bsky.embed.images#presented',
images: [],
}
}
acc[cur.postUri].images.push({
thumb: this.imgUriBuilder.getCommonSignedUri(
'feed_thumbnail',
cur.imageCid,
),
fullsize: this.imgUriBuilder.getCommonSignedUri(
'feed_fullsize',
cur.imageCid,
),
alt: cur.alt,
})
return acc
}, {} as { [uri: string]: PresentedImage })
return externals.reduce((acc, cur) => {
if (!acc[cur.postUri]) {
acc[cur.postUri] = {
$type: 'app.bsky.embed.external#presented',
external: {
uri: cur.uri,
title: cur.title,
description: cur.description,
thumb: cur.thumbCid
? this.imgUriBuilder.getCommonSignedUri(
'feed_thumbnail',
cur.thumbCid,
)
: undefined,
},
}
}
return acc
}, imgEmbeds as FeedEmbeds)
}
formatPostView(
uri: string,
actors: ActorViewMap,
posts: PostInfoMap,
embeds: FeedEmbeds,
): PostView | undefined {
const post = posts[uri]
const author = actors[post.creator]
if (!post || !author) return undefined
return {
uri: post.uri,
cid: post.cid,
author: author,
record: common.ipldBytesToRecord(post.recordBytes),
embed: embeds[uri],
replyCount: post.replyCount,
repostCount: post.repostCount,
upvoteCount: post.upvoteCount,
downvoteCount: post.downvoteCount,
indexedAt: post.indexedAt,
viewer: {
repost: post.requesterRepost ?? undefined,
upvote: post.requesterUpvote ?? undefined,
downvote: post.requesterDownvote ?? undefined,
},
}
}
}

@ -0,0 +1,48 @@
import { Presented as PresentedImage } from '../../lexicon/types/app/bsky/embed/images'
import { Presented as PresentedExternal } from '../../lexicon/types/app/bsky/embed/external'
export type FeedEmbeds = {
[uri: string]: PresentedImage | PresentedExternal
}
export type PostInfo = {
uri: string
cid: string
creator: string
recordBytes: Uint8Array
indexedAt: string
upvoteCount: number
downvoteCount: number
repostCount: number
replyCount: number
requesterRepost: string | null
requesterUpvote: string | null
requesterDownvote: string | null
}
export type PostInfoMap = { [uri: string]: PostInfo }
export type ActorView = {
did: string
declaration: {
cid: string
actorType: string
}
handle: string
displayName?: string
avatar?: string
}
export type ActorViewMap = { [did: string]: ActorView }
export type FeedItemType = 'post' | 'repost' | 'trend'
export type FeedRow = {
type: FeedItemType
postUri: string
postCid: string
originatorDid: string
authorDid: string
replyParent: string | null
replyRoot: string | null
cursor: string
}

@ -1,18 +1,23 @@
import { BlobStore } from '@atproto/repo'
import Database from '../db'
import { MessageQueue } from '../event-stream/types'
import { ImageUriBuilder } from '../image/uri'
import { ActorService } from './actor'
import { AuthService } from './auth'
import { FeedService } from './feed'
import { RecordService } from './record'
import { RepoService } from './repo'
export function createServices(
messageQueue: MessageQueue,
blobstore: BlobStore,
): Services {
export function createServices(resources: {
messageQueue: MessageQueue
blobstore: BlobStore
imgUriBuilder: ImageUriBuilder
}): Services {
const { messageQueue, blobstore, imgUriBuilder } = resources
return {
actor: ActorService.creator(),
auth: AuthService.creator(),
feed: FeedService.creator(imgUriBuilder),
record: RecordService.creator(messageQueue),
repo: RepoService.creator(messageQueue, blobstore),
}
@ -21,6 +26,7 @@ export function createServices(
export type Services = {
actor: FromDb<ActorService>
auth: FromDb<AuthService>
feed: FromDb<FeedService>
record: FromDb<RecordService>
repo: FromDb<RepoService>
}

@ -8,8 +8,7 @@ import { randomStr } from '@atproto/crypto'
import { CID } from 'multiformats/cid'
import * as uint8arrays from 'uint8arrays'
import { PDS, ServerConfig, Database, MemoryBlobStore } from '../src/index'
import * as GetAuthorFeed from '../src/lexicon/types/app/bsky/feed/getAuthorFeed'
import * as GetTimeline from '../src/lexicon/types/app/bsky/feed/getTimeline'
import { Main as FeedViewPost } from '../src/lexicon/types/app/bsky/feed/feedViewPost'
import DiskBlobStore from '../src/storage/disk-blobstore'
import AppContext from '../src/context'
@ -158,10 +157,13 @@ export const forSnapshot = (obj: unknown) => {
// Feed testing utils
type FeedItem = GetAuthorFeed.FeedItem & GetTimeline.FeedItem
export const getOriginator = (item: FeedItem) =>
item.repostedBy ? item.repostedBy.did : item.author.did
export const getOriginator = (item: FeedViewPost) => {
if (!item.reason) {
return item.post.author.did
} else {
return (item.reason.by as { [did: string]: string }).did
}
}
// Useful for remapping ids in snapshot testing, to make snapshots deterministic.
// E.g. you may use this to map this:

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

@ -78,10 +78,11 @@ describe('pds author feed views', () => {
},
)
aliceForCarol.data.feed.forEach(({ uri, myState }) => {
expect(myState?.upvote).toEqual(sc.votes.up[carol]?.[uri]?.toString())
expect(myState?.downvote).toEqual(sc.votes.down[carol]?.[uri]?.toString())
expect(myState?.repost).toEqual(sc.reposts[carol][uri]?.toString())
aliceForCarol.data.feed.forEach((postView) => {
const { viewer, uri } = postView.post
expect(viewer?.upvote).toEqual(sc.votes.up[carol]?.[uri]?.toString())
expect(viewer?.downvote).toEqual(sc.votes.down[carol]?.[uri]?.toString())
expect(viewer?.repost).toEqual(sc.reposts[carol][uri]?.toString())
})
expect(forSnapshot(aliceForCarol.data.feed)).toMatchSnapshot()

@ -1,5 +1,5 @@
import AtpApi, { ServiceClient as AtpServiceClient } from '@atproto/api'
import * as Timeline from '../../src/lexicon/types/app/bsky/feed/getTimeline'
import { Main as FeedViewPost } from '../../src/lexicon/types/app/bsky/feed/feedViewPost'
import {
runTestServer,
forSnapshot,
@ -41,7 +41,7 @@ describe('timeline views', () => {
})
it("fetches authenticated user's home feed w/ reverse-chronological algorithm", async () => {
const expectOriginatorFollowedBy = (did) => (item: Timeline.FeedItem) => {
const expectOriginatorFollowedBy = (did) => (item: FeedViewPost) => {
const originator = getOriginator(item)
// The user expects to see posts & reposts from themselves and follows
if (did !== originator) {
@ -90,26 +90,6 @@ describe('timeline views', () => {
danTL.data.feed.forEach(expectOriginatorFollowedBy(dan))
})
it("fetches authenticated user's home feed w/ firehose algorithm", async () => {
const aliceTL = await client.app.bsky.feed.getTimeline(
{ algorithm: FeedAlgorithm.Firehose },
{
headers: sc.getHeaders(alice),
},
)
expect(forSnapshot(aliceTL.data.feed)).toMatchSnapshot()
const carolTL = await client.app.bsky.feed.getTimeline(
{ algorithm: FeedAlgorithm.Firehose },
{
headers: sc.getHeaders(carol),
},
)
expect(forSnapshot(carolTL.data.feed)).toMatchSnapshot()
})
it("fetches authenticated user's home feed w/ default algorithm", async () => {
const defaultTL = await client.app.bsky.feed.getTimeline(
{},
@ -155,34 +135,4 @@ describe('timeline views', () => {
expect(full.data.feed.length).toEqual(7)
expect(results(paginatedAll)).toEqual(results([full.data]))
})
it('paginates firehose feed', async () => {
const results = (results) => results.flatMap((res) => res.feed)
const paginator = async (cursor?: string) => {
const res = await client.app.bsky.feed.getTimeline(
{
algorithm: FeedAlgorithm.Firehose,
before: cursor,
limit: 5,
},
{ headers: sc.getHeaders(alice) },
)
return res.data
}
const paginatedAll = await paginateAll(paginator)
paginatedAll.forEach((res) =>
expect(res.feed.length).toBeLessThanOrEqual(5),
)
const full = await client.app.bsky.feed.getTimeline(
{
algorithm: FeedAlgorithm.Firehose,
},
{ headers: sc.getHeaders(alice) },
)
expect(full.data.feed.length).toEqual(15)
expect(results(paginatedAll)).toEqual(results([full.data]))
})
})

@ -161,10 +161,14 @@ describe('pds vote views', () => {
}
post = await getPost()
console.log(post.thread)
expect(
(post.thread as AppBskyFeedGetPostThread.Post).downvoteCount,
(post.thread.post as AppBskyFeedGetPostThread.ThreadViewPost)
.downvoteCount,
).toEqual(0)
expect((post.thread as AppBskyFeedGetPostThread.Post).myState).toEqual({})
expect(
(post.thread.post as AppBskyFeedGetPostThread.ThreadViewPost).viewer,
).toEqual({})
// Upvote
const { data: upvoted } = await client.app.bsky.feed.setVote(
@ -181,14 +185,16 @@ describe('pds vote views', () => {
expect(upvoted.upvote).not.toBeUndefined()
expect(upvoted.downvote).toBeUndefined()
expect(
(post.thread as AppBskyFeedGetPostThread.Post).upvoteCount,
(post.thread.post as AppBskyFeedGetPostThread.ThreadViewPost)
.upvoteCount,
).toEqual(1)
expect(
(post.thread as AppBskyFeedGetPostThread.Post).downvoteCount,
(post.thread.post as AppBskyFeedGetPostThread.ThreadViewPost)
.downvoteCount,
).toEqual(0)
expect((post.thread as AppBskyFeedGetPostThread.Post).myState).toEqual(
upvoted,
)
expect(
(post.thread.post as AppBskyFeedGetPostThread.ThreadViewPost).viewer,
).toEqual(upvoted)
// Downvote
const { data: downvoted } = await client.app.bsky.feed.setVote(
@ -205,14 +211,16 @@ describe('pds vote views', () => {
expect(downvoted.upvote).toBeUndefined()
expect(downvoted.downvote).not.toBeUndefined()
expect(
(post.thread as AppBskyFeedGetPostThread.Post).upvoteCount,
(post.thread.post as AppBskyFeedGetPostThread.ThreadViewPost)
.upvoteCount,
).toEqual(0)
expect(
(post.thread as AppBskyFeedGetPostThread.Post).downvoteCount,
(post.thread.post as AppBskyFeedGetPostThread.ThreadViewPost)
.downvoteCount,
).toEqual(1)
expect((post.thread as AppBskyFeedGetPostThread.Post).myState).toEqual(
downvoted,
)
expect(
(post.thread.post as AppBskyFeedGetPostThread.ThreadViewPost).viewer,
).toEqual(downvoted)
// No vote
const { data: novoted } = await client.app.bsky.feed.setVote(
@ -229,14 +237,16 @@ describe('pds vote views', () => {
expect(novoted.upvote).toBeUndefined()
expect(novoted.downvote).toBeUndefined()
expect(
(post.thread as AppBskyFeedGetPostThread.Post).upvoteCount,
(post.thread.post as AppBskyFeedGetPostThread.ThreadViewPost)
.upvoteCount,
).toEqual(0)
expect(
(post.thread as AppBskyFeedGetPostThread.Post).downvoteCount,
(post.thread.post as AppBskyFeedGetPostThread.ThreadViewPost)
.downvoteCount,
).toEqual(0)
expect((post.thread as AppBskyFeedGetPostThread.Post).myState).toEqual(
novoted,
)
expect(
(post.thread.post as AppBskyFeedGetPostThread.ThreadViewPost).viewer,
).toEqual(novoted)
})
it('no-ops when already in correct state', async () => {