diff options
author | Paul Frazee <pfrazee@gmail.com> | 2023-09-18 11:44:29 -0700 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-09-18 11:44:29 -0700 |
commit | ea885339cf3a5cba4aa82fbe5e0176052c3b68e1 (patch) | |
tree | a02b0581c42a1a0aae4442a75391c99a1719ec3e /src/lib/api | |
parent | 3118e3e93338c62d2466699b9f339544d3273823 (diff) | |
download | voidsky-ea885339cf3a5cba4aa82fbe5e0176052c3b68e1.tar.zst |
Feed UI update working branch [WIP] (#1420)
* Feeds navigation on right side of desktop (#1403) * Remove home feed header on desktop * Add feeds to right sidebar * Add simple non-moving header to desktop * Improve loading state of custom feed header * Remove log Co-authored-by: Eric Bailey <git@esb.lol> * Remove dead comment --------- Co-authored-by: Eric Bailey <git@esb.lol> * Redesign feeds tab (#1439) * consolidate saved feeds and discover into one screen * Add hoverStyle behavior to <Link> * More UI work on SavedFeeds * Replace satellite icon with a hashtag * Tune My Feeds mobile ui * Handle no results in my feeds * Remove old DiscoverFeeds screen * Remove multifeed * Remove DiscoverFeeds from router * Improve loading placeholders * Small fixes * Fix types * Fix overflow issue on firefox * Add icons prompting to open feeds --------- Co-authored-by: Paul Frazee <pfrazee@gmail.com> * Merge feed prototype [WIP] (#1398) * POC WIP for the mergefeed * Add feed API wrapper and move mergefeed into it * Show feed source in mergefeed * Add lodash.random dep * Improve mergefeed sampling and reliability * Tune source ui element * Improve mergefeed edge condition handling * Remove in-place update of feeds for performance * Fix link on native * Fix bad ref * Improve variety in mergefeed sampling * Fix types * Fix rebase error * Add missing source field (got dropped in merge) * Update find more link * Simplify the right hand feeds nav * Bring back load latest button on desktop & unify impl * Add 'From' to source * Add simple headers to desktop home & notifications * Fix thread view jumping around horizontally * Add unread indicators to desktop headers * Add home feed preference for enabling the mergefeed * Add a preference for showing replies among followed users only (#1448) * Add a preference for showing replies among followed users only * Simplify the reply filter UI * Fix typo * Simplified custom feed header * Add soft reset to custom feed screen * Drop all the in-post translate links except when expanded (#1455) * Update mobile feed settings links to match desktop * Fixes to feeds screen loading states * Bolder active state of feeds tab on mobile web * Fix dark mode issue --------- Co-authored-by: Eric Bailey <git@esb.lol> Co-authored-by: Ansh <anshnanda10@gmail.com>
Diffstat (limited to 'src/lib/api')
-rw-r--r-- | src/lib/api/feed-manip.ts | 47 | ||||
-rw-r--r-- | src/lib/api/feed/author.ts | 45 | ||||
-rw-r--r-- | src/lib/api/feed/custom.ts | 52 | ||||
-rw-r--r-- | src/lib/api/feed/following.ts | 37 | ||||
-rw-r--r-- | src/lib/api/feed/likes.ts | 45 | ||||
-rw-r--r-- | src/lib/api/feed/merge.ts | 236 | ||||
-rw-r--r-- | src/lib/api/feed/types.ts | 17 |
7 files changed, 474 insertions, 5 deletions
diff --git a/src/lib/api/feed-manip.ts b/src/lib/api/feed-manip.ts index 149859ea9..ef57fc4f2 100644 --- a/src/lib/api/feed-manip.ts +++ b/src/lib/api/feed-manip.ts @@ -4,6 +4,7 @@ import { AppBskyEmbedRecordWithMedia, AppBskyEmbedRecord, } from '@atproto/api' +import {FeedSourceInfo} from './feed/types' import {isPostInLanguage} from '../../locale/helpers' type FeedViewPost = AppBskyFeedDefs.FeedViewPost @@ -64,6 +65,11 @@ export class FeedViewPostsSlice { ) } + get source(): FeedSourceInfo | undefined { + return this.items.find(item => '__source' in item && !!item.__source) + ?.__source as FeedSourceInfo + } + containsUri(uri: string) { return !!this.items.find(item => item.post.uri === uri) } @@ -91,6 +97,23 @@ export class FeedViewPostsSlice { } } } + + isFollowingAllAuthors(userDid: string) { + const item = this.rootItem + if (item.post.author.did === userDid) { + return true + } + if (AppBskyFeedDefs.isPostView(item.reply?.parent)) { + const parent = item.reply?.parent + if (parent?.author.did === userDid) { + return true + } + return ( + parent?.author.viewer?.following && item.post.author.viewer?.following + ) + } + return false + } } export class FeedTuner { @@ -222,20 +245,34 @@ export class FeedTuner { return slices } - static likedRepliesOnly({repliesThreshold}: {repliesThreshold: number}) { + static thresholdRepliesOnly({ + userDid, + minLikes, + followedOnly, + }: { + userDid: string + minLikes: number + followedOnly: boolean + }) { return ( tuner: FeedTuner, slices: FeedViewPostsSlice[], ): FeedViewPostsSlice[] => { - // remove any replies without at least repliesThreshold likes + // remove any replies without at least minLikes likes for (let i = slices.length - 1; i >= 0; i--) { - if (slices[i].isFullThread || !slices[i].isReply) { + const slice = slices[i] + if (slice.isFullThread || !slice.isReply) { continue } - const item = slices[i].rootItem + const item = slice.rootItem const isRepost = Boolean(item.reason) - if (!isRepost && (item.post.likeCount || 0) < repliesThreshold) { + if (isRepost) { + continue + } + if ((item.post.likeCount || 0) < minLikes) { + slices.splice(i, 1) + } else if (followedOnly && !slice.isFollowingAllAuthors(userDid)) { slices.splice(i, 1) } } diff --git a/src/lib/api/feed/author.ts b/src/lib/api/feed/author.ts new file mode 100644 index 000000000..1ae925123 --- /dev/null +++ b/src/lib/api/feed/author.ts @@ -0,0 +1,45 @@ +import { + AppBskyFeedDefs, + AppBskyFeedGetAuthorFeed as GetAuthorFeed, +} from '@atproto/api' +import {RootStoreModel} from 'state/index' +import {FeedAPI, FeedAPIResponse} from './types' + +export class AuthorFeedAPI implements FeedAPI { + cursor: string | undefined + + constructor( + public rootStore: RootStoreModel, + public params: GetAuthorFeed.QueryParams, + ) {} + + reset() { + this.cursor = undefined + } + + async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> { + const res = await this.rootStore.agent.getAuthorFeed({ + ...this.params, + limit: 1, + }) + return res.data.feed[0] + } + + async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> { + const res = await this.rootStore.agent.getAuthorFeed({ + ...this.params, + cursor: this.cursor, + limit, + }) + if (res.success) { + this.cursor = res.data.cursor + return { + cursor: res.data.cursor, + feed: res.data.feed, + } + } + return { + feed: [], + } + } +} diff --git a/src/lib/api/feed/custom.ts b/src/lib/api/feed/custom.ts new file mode 100644 index 000000000..d05d5acd6 --- /dev/null +++ b/src/lib/api/feed/custom.ts @@ -0,0 +1,52 @@ +import { + AppBskyFeedDefs, + AppBskyFeedGetFeed as GetCustomFeed, +} from '@atproto/api' +import {RootStoreModel} from 'state/index' +import {FeedAPI, FeedAPIResponse} from './types' + +export class CustomFeedAPI implements FeedAPI { + cursor: string | undefined + + constructor( + public rootStore: RootStoreModel, + public params: GetCustomFeed.QueryParams, + ) {} + + reset() { + this.cursor = undefined + } + + async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> { + const res = await this.rootStore.agent.app.bsky.feed.getFeed({ + ...this.params, + limit: 1, + }) + return res.data.feed[0] + } + + async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> { + const res = await this.rootStore.agent.app.bsky.feed.getFeed({ + ...this.params, + cursor: this.cursor, + limit, + }) + if (res.success) { + this.cursor = res.data.cursor + // NOTE + // some custom feeds fail to enforce the pagination limit + // so we manually truncate here + // -prf + if (res.data.feed.length > limit) { + res.data.feed = res.data.feed.slice(0, limit) + } + return { + cursor: res.data.cursor, + feed: res.data.feed, + } + } + return { + feed: [], + } + } +} diff --git a/src/lib/api/feed/following.ts b/src/lib/api/feed/following.ts new file mode 100644 index 000000000..f14807a57 --- /dev/null +++ b/src/lib/api/feed/following.ts @@ -0,0 +1,37 @@ +import {AppBskyFeedDefs} from '@atproto/api' +import {RootStoreModel} from 'state/index' +import {FeedAPI, FeedAPIResponse} from './types' + +export class FollowingFeedAPI implements FeedAPI { + cursor: string | undefined + + constructor(public rootStore: RootStoreModel) {} + + reset() { + this.cursor = undefined + } + + async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> { + const res = await this.rootStore.agent.getTimeline({ + limit: 1, + }) + return res.data.feed[0] + } + + async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> { + const res = await this.rootStore.agent.getTimeline({ + cursor: this.cursor, + limit, + }) + if (res.success) { + this.cursor = res.data.cursor + return { + cursor: res.data.cursor, + feed: res.data.feed, + } + } + return { + feed: [], + } + } +} diff --git a/src/lib/api/feed/likes.ts b/src/lib/api/feed/likes.ts new file mode 100644 index 000000000..e9bb14b0b --- /dev/null +++ b/src/lib/api/feed/likes.ts @@ -0,0 +1,45 @@ +import { + AppBskyFeedDefs, + AppBskyFeedGetActorLikes as GetActorLikes, +} from '@atproto/api' +import {RootStoreModel} from 'state/index' +import {FeedAPI, FeedAPIResponse} from './types' + +export class LikesFeedAPI implements FeedAPI { + cursor: string | undefined + + constructor( + public rootStore: RootStoreModel, + public params: GetActorLikes.QueryParams, + ) {} + + reset() { + this.cursor = undefined + } + + async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> { + const res = await this.rootStore.agent.getActorLikes({ + ...this.params, + limit: 1, + }) + return res.data.feed[0] + } + + async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> { + const res = await this.rootStore.agent.getActorLikes({ + ...this.params, + cursor: this.cursor, + limit, + }) + if (res.success) { + this.cursor = res.data.cursor + return { + cursor: res.data.cursor, + feed: res.data.feed, + } + } + return { + feed: [], + } + } +} diff --git a/src/lib/api/feed/merge.ts b/src/lib/api/feed/merge.ts new file mode 100644 index 000000000..51a619589 --- /dev/null +++ b/src/lib/api/feed/merge.ts @@ -0,0 +1,236 @@ +import {AppBskyFeedDefs, AppBskyFeedGetTimeline} from '@atproto/api' +import shuffle from 'lodash.shuffle' +import {RootStoreModel} from 'state/index' +import {timeout} from 'lib/async/timeout' +import {bundleAsync} from 'lib/async/bundle' +import {feedUriToHref} from 'lib/strings/url-helpers' +import {FeedAPI, FeedAPIResponse, FeedSourceInfo} from './types' + +const REQUEST_WAIT_MS = 500 // 500ms +const POST_AGE_CUTOFF = 60e3 * 60 * 24 // 24hours + +export class MergeFeedAPI implements FeedAPI { + following: MergeFeedSource_Following + customFeeds: MergeFeedSource_Custom[] = [] + feedCursor = 0 + itemCursor = 0 + sampleCursor = 0 + + constructor(public rootStore: RootStoreModel) { + this.following = new MergeFeedSource_Following(this.rootStore) + } + + reset() { + this.following = new MergeFeedSource_Following(this.rootStore) + this.customFeeds = [] // just empty the array, they will be captured in _fetchNext() + this.feedCursor = 0 + this.itemCursor = 0 + this.sampleCursor = 0 + } + + async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> { + const res = await this.rootStore.agent.getTimeline({ + limit: 1, + }) + return res.data.feed[0] + } + + async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> { + // we capture here to ensure the data has loaded + this._captureFeedsIfNeeded() + + const promises = [] + + // always keep following topped up + if (this.following.numReady < limit) { + promises.push(this.following.fetchNext(30)) + } + + // pick the next feeds to sample from + const feeds = this.customFeeds.slice(this.feedCursor, this.feedCursor + 3) + this.feedCursor += 3 + if (this.feedCursor > this.customFeeds.length) { + this.feedCursor = 0 + } + + // top up the feeds + for (const feed of feeds) { + if (feed.numReady < 5) { + promises.push(feed.fetchNext(10)) + } + } + + // wait for requests (all capped at a fixed timeout) + await Promise.all(promises) + + // assemble a response by sampling from feeds with content + const posts: AppBskyFeedDefs.FeedViewPost[] = [] + while (posts.length < limit) { + let slice = this.sampleItem() + if (slice[0]) { + posts.push(slice[0]) + } else { + break + } + } + + return { + cursor: posts.length ? 'fake' : undefined, + feed: posts, + } + } + + sampleItem() { + const i = this.itemCursor++ + const candidateFeeds = this.customFeeds.filter(f => f.numReady > 0) + const canSample = candidateFeeds.length > 0 + const hasFollows = this.following.numReady > 0 + + // this condition establishes the frequency that custom feeds are woven into follows + const shouldSample = + i >= 15 && candidateFeeds.length >= 2 && (i % 4 === 0 || i % 5 === 0) + + if (!canSample && !hasFollows) { + // no data available + return [] + } + if (shouldSample || !hasFollows) { + // time to sample, or the user isnt following anybody + return candidateFeeds[this.sampleCursor++ % candidateFeeds.length].take(1) + } + // not time to sample + return this.following.take(1) + } + + _captureFeedsIfNeeded() { + if (!this.rootStore.preferences.homeFeedMergeFeedEnabled) { + return + } + if (this.customFeeds.length === 0) { + this.customFeeds = shuffle( + this.rootStore.me.savedFeeds.all.map( + feed => + new MergeFeedSource_Custom( + this.rootStore, + feed.uri, + feed.displayName, + ), + ), + ) + } + } +} + +class MergeFeedSource { + sourceInfo: FeedSourceInfo | undefined + cursor: string | undefined = undefined + queue: AppBskyFeedDefs.FeedViewPost[] = [] + hasMore = true + + constructor(public rootStore: RootStoreModel) {} + + get numReady() { + return this.queue.length + } + + get needsFetch() { + return this.hasMore && this.queue.length === 0 + } + + reset() { + this.cursor = undefined + this.queue = [] + this.hasMore = true + } + + take(n: number): AppBskyFeedDefs.FeedViewPost[] { + return this.queue.splice(0, n) + } + + async fetchNext(n: number) { + await Promise.race([this._fetchNextInner(n), timeout(REQUEST_WAIT_MS)]) + } + + _fetchNextInner = bundleAsync(async (n: number) => { + const res = await this._getFeed(this.cursor, n) + if (res.success) { + this.cursor = res.data.cursor + if (res.data.feed.length) { + this.queue = this.queue.concat(res.data.feed) + } else { + this.hasMore = false + } + } else { + this.hasMore = false + } + }) + + protected _getFeed( + _cursor: string | undefined, + _limit: number, + ): Promise<AppBskyFeedGetTimeline.Response> { + throw new Error('Must be overridden') + } +} + +class MergeFeedSource_Following extends MergeFeedSource { + async fetchNext(n: number) { + return this._fetchNextInner(n) + } + + protected async _getFeed( + cursor: string | undefined, + limit: number, + ): Promise<AppBskyFeedGetTimeline.Response> { + const res = await this.rootStore.agent.getTimeline({cursor, limit}) + // filter out mutes pre-emptively to ensure better mixing + res.data.feed = res.data.feed.filter( + post => !post.post.author.viewer?.muted, + ) + return res + } +} + +class MergeFeedSource_Custom extends MergeFeedSource { + minDate: Date + + constructor( + public rootStore: RootStoreModel, + public feedUri: string, + public feedDisplayName: string, + ) { + super(rootStore) + this.sourceInfo = { + displayName: feedDisplayName, + uri: feedUriToHref(feedUri), + } + this.minDate = new Date(Date.now() - POST_AGE_CUTOFF) + } + + protected async _getFeed( + cursor: string | undefined, + limit: number, + ): Promise<AppBskyFeedGetTimeline.Response> { + const res = await this.rootStore.agent.app.bsky.feed.getFeed({ + cursor, + limit, + feed: this.feedUri, + }) + // NOTE + // some custom feeds fail to enforce the pagination limit + // so we manually truncate here + // -prf + if (limit && res.data.feed.length > limit) { + res.data.feed = res.data.feed.slice(0, limit) + } + // filter out older posts + res.data.feed = res.data.feed.filter( + post => new Date(post.post.indexedAt) > this.minDate, + ) + // attach source info + for (const post of res.data.feed) { + post.__source = this.sourceInfo + } + return res + } +} diff --git a/src/lib/api/feed/types.ts b/src/lib/api/feed/types.ts new file mode 100644 index 000000000..006344334 --- /dev/null +++ b/src/lib/api/feed/types.ts @@ -0,0 +1,17 @@ +import {AppBskyFeedDefs} from '@atproto/api' + +export interface FeedAPIResponse { + cursor?: string + feed: AppBskyFeedDefs.FeedViewPost[] +} + +export interface FeedAPI { + reset(): void + peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> + fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> +} + +export interface FeedSourceInfo { + uri: string + displayName: string +} |