Files
bsky-social-app/src/lib/api/feed-manip.ts
T
Eric Bailey a51fc8e434 @atproto/api@next integration (#7344)
* Bump SDK

* Use consistent type in profile query

* Omit  from constraint for profile shadow

* Replace isRecord with isValidRecord in QuoteEmbed

* Omit type from constraint for old ProfileCard

* Omit type from constraint in profile queries where appropriate

* Use correct type for update profile mutation

* Conslidate and fix check for isValidRecord in Post.tsx

* Replace isRecord with isValidRecord in PostThreadItem

* Remove redundant cast in PostThreadFollowBtn

* Ignore errors in DebugMod screen

* Use matching type in ProfileFollows screen

* Use matching type in ProfileFollowers screen

* Migrate to isValidRecord in PostFeedItem

* Use matching type if PostRepostedBy

* Omit type from constraint in avatar props

* Use matching types in NotificationFeedItem

* Todo

* Use isValidRecord in NotfyFeedItem

* Improve MediaPreview types

* Migrate another isValidRecord in NotificationFeedItem

* Migrate to isValidView in queries/util

* Migrate to isValidRecord in threadgate/util

* Fix types in threadgates

* Fix up types in starter-packs queries

* Todo

* Specify exact types in search-posts

* Use internal type util to align types

* Ditto last

* Migrate postgate/index

* Specify exact types in post-thread

* Use correct type in post-quotes

* FIX potential bug in post-thread

* Use correct type in post-feed

* Add correct type guards to notifications/feed

* Migrate a guard in notifications/util

* Migrate guard in Wizard/State

* [@next] Profile handling, migrate `ProfileCard` (#7347)

* Introduce new utils for profiles, migrate old ProfileCard

* Rename, reorg

* Add parseEmbed utils

* Expand AnyProfileView to include chat profile view, update post shadow to reflect this

* Cast for perf reasons

* Tighten up types now that we have AnyProfileView

* Add fastIsType util

* Use `assertDid`

Co-authored-by: Matthieu Sieben <matthieusieben@users.noreply.github.com>

* Use util types

* Comment

* Use fastIsType where no validation was happening before

* suggestions (#7382)

* suggestions

* Revert unneeded changes

---------

Co-authored-by: Eric Bailey <git@esb.lol>

* Use new util

* Rename to dangerousIsType

* Convert object shape

* Use dangerous util

* Use dangerous util

* Use dangerous util, we can trust post records

* Use dangerous util

* Use AnyProfileVIew

* Convert object shape

* Clean up handling

* Patch moderateProfile to accept known profile views, to discuss

* Add AnyStarterPackView and related, implement in first usage

* Remove validation, fix type, fix ref

* Migrate over list-conversations

* Clarify intent behind precacheProfile and its unstable query cache

* Clean up unstable profile cache

* Fix types during label creation in PwiOptOut (#7346)

* Tighten types in queries/list

* Chat: use correct profile views

* Chat: fix log type check

* Chat: construct lexically correct shape, even though it's only internal usage

* Chat: use correct profile types

* Chat: fix type check in logs

* Starter: use correct profile types

* Starter: use correct profile types

* Starter: tighten types to match lex

* Any profile type will work in blocked-and-muted

* Use dangerous util

* Use dangerousIsType

* Update new ProfileCard to use AnyProfileView

* Use dangerousIsType

* Remove outdated todo

* Use correct profile type

* Use correct profile types

* Tighten up types

* Use dangerousIsType

* Chat: more type fixes

* Remove unused file

* Add a few  utils

* Remove unused file

* Ignore feedPost.__source

* Clean up types, leave validation in critical path

* Use dangerousIstype

* Use ANyProfileView

* Use isValidRecord

* Use dangerousIsType

* Fix types in ListCard

* Fix FeedInterstitials types

* Fix types in FeedCard

* Fix types in dms ReportDialog

* Fix types in SearchablePeopleList

* Fix bad type in composer opts

* Starter: ok these need to be loose too

* Clarify docs

Co-authored-by: Matthieu Sieben <matthieusieben@users.noreply.github.com>

* Less code

Co-authored-by: Matthieu Sieben <matthieusieben@users.noreply.github.com>

* Use package exports

Co-authored-by: Matthieu Sieben <matthieusieben@users.noreply.github.com>

* Use package exports

* Bump sdk, update $Typed imports

* Format

* Format

* Fix weird TS error

* Remove patch

* Beter name

* It's memo, can validate

* Tighten up parseEmbed, dogfood

* Bump sdk

* Use asPredicate

* Loosen types a bit

* use asPredicate

* Fix types

* Use asPredicate

* Use asPredicate

* Use asPredicate

* Clean up upsertProfile types

* Use asPredicate

* Use Un util

* Fix types

* Use new AnyProfileView

* Use dangerousIsType

* Use asPredicate

* Use asPredicate

* Add fallback content-type to pass typecheck

* Clean up upsertProfile types

* Align types

* Use dangerousIsType

* Use dangerousIsType

* Use asPredicate

* Align types

* Convert findLast

* Align types

* Just ignore type errors and use findLast

* Rename atproto -> bsky

* Add validate util

* Fix type error

* Loosen types

* Export post

* rename atp bsky

* Remove unused code

* minor changes

* Bump deps

* Fix types

* Tighten back up loose check

* Tighten back up loose check

* Fix small bug

* Update comment

* Revert change

---------

Co-authored-by: Matthieu Sieben <matthieusieben@users.noreply.github.com>
Co-authored-by: Matthieu Sieben <matthieu.sieben@gmail.com>
2025-02-18 08:54:25 -06:00

502 lines
14 KiB
TypeScript

import {
AppBskyActorDefs,
AppBskyEmbedRecord,
AppBskyEmbedRecordWithMedia,
AppBskyFeedDefs,
AppBskyFeedPost,
} from '@atproto/api'
import * as bsky from '#/types/bsky'
import {isPostInLanguage} from '../../locale/helpers'
import {FALLBACK_MARKER_POST} from './feed/home'
import {ReasonFeedSource} from './feed/types'
type FeedViewPost = AppBskyFeedDefs.FeedViewPost
export type FeedTunerFn = (
tuner: FeedTuner,
slices: FeedViewPostsSlice[],
dryRun: boolean,
) => FeedViewPostsSlice[]
type FeedSliceItem = {
post: AppBskyFeedDefs.PostView
record: AppBskyFeedPost.Record
parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
isParentBlocked: boolean
isParentNotFound: boolean
}
type AuthorContext = {
author: AppBskyActorDefs.ProfileViewBasic
parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
grandparentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
rootAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
}
export class FeedViewPostsSlice {
_reactKey: string
_feedPost: FeedViewPost
items: FeedSliceItem[]
isIncompleteThread: boolean
isFallbackMarker: boolean
isOrphan: boolean
rootUri: string
feedPostUri: string
constructor(feedPost: FeedViewPost) {
const {post, reply, reason} = feedPost
this.items = []
this.isIncompleteThread = false
this.isFallbackMarker = false
this.isOrphan = false
this.feedPostUri = post.uri
if (AppBskyFeedDefs.isPostView(reply?.root)) {
this.rootUri = reply.root.uri
} else {
this.rootUri = post.uri
}
this._feedPost = feedPost
this._reactKey = `slice-${post.uri}-${
feedPost.reason && 'indexedAt' in feedPost.reason
? feedPost.reason.indexedAt
: post.indexedAt
}`
if (feedPost.post.uri === FALLBACK_MARKER_POST.post.uri) {
this.isFallbackMarker = true
return
}
if (
!AppBskyFeedPost.isRecord(post.record) ||
!bsky.validate(post.record, AppBskyFeedPost.validateRecord)
) {
return
}
const parent = reply?.parent
const isParentBlocked = AppBskyFeedDefs.isBlockedPost(parent)
const isParentNotFound = AppBskyFeedDefs.isNotFoundPost(parent)
let parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
if (AppBskyFeedDefs.isPostView(parent)) {
parentAuthor = parent.author
}
this.items.push({
post,
record: post.record,
parentAuthor,
isParentBlocked,
isParentNotFound,
})
if (!reply) {
if (post.record.reply) {
// This reply wasn't properly hydrated by the AppView.
this.isOrphan = true
this.items[0].isParentNotFound = true
}
return
}
if (reason) {
return
}
if (
!AppBskyFeedDefs.isPostView(parent) ||
!AppBskyFeedPost.isRecord(parent.record) ||
!bsky.validate(parent.record, AppBskyFeedPost.validateRecord)
) {
this.isOrphan = true
return
}
const root = reply.root
const rootIsView =
AppBskyFeedDefs.isPostView(root) ||
AppBskyFeedDefs.isBlockedPost(root) ||
AppBskyFeedDefs.isNotFoundPost(root)
/*
* If the parent is also the root, we just so happen to have the data we
* need to compute if the parent's parent (grandparent) is blocked. This
* doesn't always happen, of course, but we can take advantage of it when
* it does.
*/
const grandparent =
rootIsView && parent.record.reply?.parent.uri === root.uri
? root
: undefined
const grandparentAuthor = reply.grandparentAuthor
const isGrandparentBlocked = Boolean(
grandparent && AppBskyFeedDefs.isBlockedPost(grandparent),
)
const isGrandparentNotFound = Boolean(
grandparent && AppBskyFeedDefs.isNotFoundPost(grandparent),
)
this.items.unshift({
post: parent,
record: parent.record,
parentAuthor: grandparentAuthor,
isParentBlocked: isGrandparentBlocked,
isParentNotFound: isGrandparentNotFound,
})
if (isGrandparentBlocked) {
this.isOrphan = true
// Keep going, it might still have a root, and we need this for thread
// de-deduping
}
if (
!AppBskyFeedDefs.isPostView(root) ||
!AppBskyFeedPost.isRecord(root.record) ||
!bsky.validate(root.record, AppBskyFeedPost.validateRecord)
) {
this.isOrphan = true
return
}
if (root.uri === parent.uri) {
return
}
this.items.unshift({
post: root,
record: root.record,
isParentBlocked: false,
isParentNotFound: false,
parentAuthor: undefined,
})
if (parent.record.reply?.parent.uri !== root.uri) {
this.isIncompleteThread = true
}
}
get isQuotePost() {
const embed = this._feedPost.post.embed
return (
AppBskyEmbedRecord.isView(embed) ||
AppBskyEmbedRecordWithMedia.isView(embed)
)
}
get isReply() {
return (
AppBskyFeedPost.isRecord(this._feedPost.post.record) &&
!!this._feedPost.post.record.reply
)
}
get reason() {
return '__source' in this._feedPost
? (this._feedPost.__source as ReasonFeedSource)
: this._feedPost.reason
}
get feedContext() {
return this._feedPost.feedContext
}
get isRepost() {
const reason = this._feedPost.reason
return AppBskyFeedDefs.isReasonRepost(reason)
}
get likeCount() {
return this._feedPost.post.likeCount ?? 0
}
containsUri(uri: string) {
return !!this.items.find(item => item.post.uri === uri)
}
getAuthors(): AuthorContext {
const feedPost = this._feedPost
let author: AppBskyActorDefs.ProfileViewBasic = feedPost.post.author
let parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
let grandparentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
let rootAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
if (feedPost.reply) {
if (AppBskyFeedDefs.isPostView(feedPost.reply.parent)) {
parentAuthor = feedPost.reply.parent.author
}
if (feedPost.reply.grandparentAuthor) {
grandparentAuthor = feedPost.reply.grandparentAuthor
}
if (AppBskyFeedDefs.isPostView(feedPost.reply.root)) {
rootAuthor = feedPost.reply.root.author
}
}
return {
author,
parentAuthor,
grandparentAuthor,
rootAuthor,
}
}
}
export class FeedTuner {
seenKeys: Set<string> = new Set()
seenUris: Set<string> = new Set()
seenRootUris: Set<string> = new Set()
constructor(public tunerFns: FeedTunerFn[]) {}
tune(
feed: FeedViewPost[],
{dryRun}: {dryRun: boolean} = {
dryRun: false,
},
): FeedViewPostsSlice[] {
let slices: FeedViewPostsSlice[] = feed
.map(item => new FeedViewPostsSlice(item))
.filter(s => s.items.length > 0 || s.isFallbackMarker)
// run the custom tuners
for (const tunerFn of this.tunerFns) {
slices = tunerFn(this, slices.slice(), dryRun)
}
slices = slices.filter(slice => {
if (this.seenKeys.has(slice._reactKey)) {
return false
}
// Some feeds, like Following, dedupe by thread, so you only see the most recent reply.
// However, we don't want per-thread dedupe for author feeds (where we need to show every post)
// or for feedgens (where we want to let the feed serve multiple replies if it chooses to).
// To avoid showing the same context (root and/or parent) more than once, we do last resort
// per-post deduplication. It hides already seen posts as long as this doesn't break the thread.
for (let i = 0; i < slice.items.length; i++) {
const item = slice.items[i]
if (this.seenUris.has(item.post.uri)) {
if (i === 0) {
// Omit contiguous seen leading items.
// For example, [A -> B -> C], [A -> D -> E], [A -> D -> F]
// would turn into [A -> B -> C], [D -> E], [F].
slice.items.splice(0, 1)
i--
}
if (i === slice.items.length - 1) {
// If the last item in the slice was already seen, omit the whole slice.
// This means we'd miss its parents, but the user can "show more" to see them.
// For example, [A ... E -> F], [A ... D -> E], [A ... C -> D], [A -> B -> C]
// would get collapsed into [A ... E -> F], with B/C/D considered seen.
return false
}
} else {
if (!dryRun) {
// Reposting a reply elevates it to top-level, so its parent/root won't be displayed.
// Disable in-thread dedupe for this case since we don't want to miss them later.
const disableDedupe = slice.isReply && slice.isRepost
if (!disableDedupe) {
this.seenUris.add(item.post.uri)
}
}
}
}
if (!dryRun) {
this.seenKeys.add(slice._reactKey)
}
return true
})
return slices
}
static removeReplies(
tuner: FeedTuner,
slices: FeedViewPostsSlice[],
_dryRun: boolean,
) {
for (let i = 0; i < slices.length; i++) {
const slice = slices[i]
if (
slice.isReply &&
!slice.isRepost &&
// This is not perfect but it's close as we can get to
// detecting threads without having to peek ahead.
!areSameAuthor(slice.getAuthors())
) {
slices.splice(i, 1)
i--
}
}
return slices
}
static removeReposts(
tuner: FeedTuner,
slices: FeedViewPostsSlice[],
_dryRun: boolean,
) {
for (let i = 0; i < slices.length; i++) {
if (slices[i].isRepost) {
slices.splice(i, 1)
i--
}
}
return slices
}
static removeQuotePosts(
tuner: FeedTuner,
slices: FeedViewPostsSlice[],
_dryRun: boolean,
) {
for (let i = 0; i < slices.length; i++) {
if (slices[i].isQuotePost) {
slices.splice(i, 1)
i--
}
}
return slices
}
static removeOrphans(
tuner: FeedTuner,
slices: FeedViewPostsSlice[],
_dryRun: boolean,
) {
for (let i = 0; i < slices.length; i++) {
if (slices[i].isOrphan) {
slices.splice(i, 1)
i--
}
}
return slices
}
static dedupThreads(
tuner: FeedTuner,
slices: FeedViewPostsSlice[],
dryRun: boolean,
): FeedViewPostsSlice[] {
for (let i = 0; i < slices.length; i++) {
const rootUri = slices[i].rootUri
if (!slices[i].isRepost && tuner.seenRootUris.has(rootUri)) {
slices.splice(i, 1)
i--
} else {
if (!dryRun) {
tuner.seenRootUris.add(rootUri)
}
}
}
return slices
}
static followedRepliesOnly({userDid}: {userDid: string}) {
return (
tuner: FeedTuner,
slices: FeedViewPostsSlice[],
_dryRun: boolean,
): FeedViewPostsSlice[] => {
for (let i = 0; i < slices.length; i++) {
const slice = slices[i]
if (
slice.isReply &&
!slice.isRepost &&
!shouldDisplayReplyInFollowing(slice.getAuthors(), userDid)
) {
slices.splice(i, 1)
i--
}
}
return slices
}
}
/**
* This function filters a list of FeedViewPostsSlice items based on whether they contain text in a
* preferred language.
* @param {string[]} preferredLangsCode2 - An array of preferred language codes in ISO 639-1 or ISO 639-2 format.
* @returns A function that takes in a `FeedTuner` and an array of `FeedViewPostsSlice` objects and
* returns an array of `FeedViewPostsSlice` objects.
*/
static preferredLangOnly(preferredLangsCode2: string[]) {
return (
tuner: FeedTuner,
slices: FeedViewPostsSlice[],
_dryRun: boolean,
): FeedViewPostsSlice[] => {
// early return if no languages have been specified
if (!preferredLangsCode2.length || preferredLangsCode2.length === 0) {
return slices
}
const candidateSlices = slices.filter(slice => {
for (const item of slice.items) {
if (isPostInLanguage(item.post, preferredLangsCode2)) {
return true
}
}
// if item does not fit preferred language, remove it
return false
})
// if the language filter cleared out the entire page, return the original set
// so that something always shows
if (candidateSlices.length === 0) {
return slices
}
return candidateSlices
}
}
}
function areSameAuthor(authors: AuthorContext): boolean {
const {author, parentAuthor, grandparentAuthor, rootAuthor} = authors
const authorDid = author.did
if (parentAuthor && parentAuthor.did !== authorDid) {
return false
}
if (grandparentAuthor && grandparentAuthor.did !== authorDid) {
return false
}
if (rootAuthor && rootAuthor.did !== authorDid) {
return false
}
return true
}
function shouldDisplayReplyInFollowing(
authors: AuthorContext,
userDid: string,
): boolean {
const {author, parentAuthor, grandparentAuthor, rootAuthor} = authors
if (!isSelfOrFollowing(author, userDid)) {
// Only show replies from self or people you follow.
return false
}
if (
(!parentAuthor || parentAuthor.did === author.did) &&
(!rootAuthor || rootAuthor.did === author.did) &&
(!grandparentAuthor || grandparentAuthor.did === author.did)
) {
// Always show self-threads.
return true
}
// From this point on we need at least one more reason to show it.
if (
parentAuthor &&
parentAuthor.did !== author.did &&
isSelfOrFollowing(parentAuthor, userDid)
) {
return true
}
if (
grandparentAuthor &&
grandparentAuthor.did !== author.did &&
isSelfOrFollowing(grandparentAuthor, userDid)
) {
return true
}
if (
rootAuthor &&
rootAuthor.did !== author.did &&
isSelfOrFollowing(rootAuthor, userDid)
) {
return true
}
return false
}
function isSelfOrFollowing(
profile: AppBskyActorDefs.ProfileViewBasic,
userDid: string,
) {
return Boolean(profile.did === userDid || profile.viewer?.following)
}