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 | |
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')
-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 | ||||
-rw-r--r-- | src/lib/icons.tsx | 64 | ||||
-rw-r--r-- | src/lib/routes/types.ts | 1 | ||||
-rw-r--r-- | src/lib/strings/url-helpers.ts | 9 |
10 files changed, 491 insertions, 62 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 +} diff --git a/src/lib/icons.tsx b/src/lib/icons.tsx index 233f8a473..fef7be2f3 100644 --- a/src/lib/icons.tsx +++ b/src/lib/icons.tsx @@ -1,6 +1,6 @@ import React from 'react' import {StyleProp, TextStyle, ViewStyle} from 'react-native' -import Svg, {Path, Rect, Line, Ellipse, Circle} from 'react-native-svg' +import Svg, {Path, Rect, Line, Ellipse} from 'react-native-svg' export function GridIcon({ style, @@ -884,45 +884,7 @@ export function HandIcon({ ) } -export function SatelliteDishIconSolid({ - style, - size, - strokeWidth = 1.5, -}: { - style?: StyleProp<ViewStyle> - size?: string | number - strokeWidth?: number -}) { - return ( - <Svg - width={size || 24} - height={size || 24} - viewBox="0 0 22 22" - style={style} - fill="none" - stroke="none"> - <Path - d="M16 19.6622C14.5291 20.513 12.8214 21 11 21C5.47715 21 1 16.5229 1 11C1 9.17858 1.48697 7.47088 2.33782 6.00002C3.18867 4.52915 6 7.66219 6 7.66219L14.5 16.1622C14.5 16.1622 17.4709 18.8113 16 19.6622Z" - fill="currentColor" - /> - <Path - d="M8 1.62961C9.04899 1.22255 10.1847 1 11.3704 1C16.6887 1 21 5.47715 21 11C21 12.0452 20.8456 13.053 20.5592 14" - stroke="currentColor" - strokeWidth={strokeWidth} - strokeLinecap="round" - /> - <Path - d="M9 5.38745C9.64553 5.13695 10.3444 5 11.0741 5C14.3469 5 17 7.75517 17 11.1538C17 11.797 16.905 12.4172 16.7287 13" - stroke="currentColor" - strokeWidth={strokeWidth} - strokeLinecap="round" - /> - <Circle cx="10" cy="12" r="2" fill="currentColor" /> - </Svg> - ) -} - -export function SatelliteDishIcon({ +export function HashtagIcon({ style, size, strokeWidth = 1.5, @@ -934,26 +896,16 @@ export function SatelliteDishIcon({ return ( <Svg fill="none" - viewBox="0 0 22 22" - strokeWidth={strokeWidth} stroke="currentColor" + viewBox="0 0 30 30" + strokeWidth={strokeWidth} width={size} height={size} style={style}> - <Path d="M 12.705346,15.777547 C 14.4635,17.5315 14.7526,17.8509 14.9928,18.1812 c 0.2139,0.2943 0.3371,0.5275 0.3889,0.6822 C 14.0859,19.5872 12.5926,20 11,20 6.02944,20 2,15.9706 2,11 2,9.4151 2.40883,7.9285 3.12619,6.63699 3.304,6.69748 3.56745,6.84213 3.89275,7.08309 4.3705644,7.4380098 4.7486794,7.8160923 6.4999995,9.5689376 8.2513197,11.321783 10.947192,14.023595 12.705346,15.777547 Z" /> - <Path - d="M8 1.62961C9.04899 1.22255 10.1847 1 11.3704 1C16.6887 1 21 5.47715 21 11C21 12.0452 20.8456 13.053 20.5592 14" - strokeLinecap="round" - /> - <Path - d="M9 5.38745C9.64553 5.13695 10.3444 5 11.0741 5C14.3469 5 17 7.75517 17 11.1538C17 11.797 16.905 12.4172 16.7287 13" - strokeLinecap="round" - /> - <Path - d="M12 12C12 12.7403 11.5978 13.3866 11 13.7324L8.26756 11C8.61337 10.4022 9.25972 10 10 10C11.1046 10 12 10.8954 12 12Z" - fill="currentColor" - stroke="none" - /> + <Path d="M2 10H28" strokeLinecap="round" /> + <Path d="M2 20H28" strokeLinecap="round" /> + <Path d="M11 3L9 27" strokeLinecap="round" /> + <Path d="M21 3L19 27" strokeLinecap="round" /> </Svg> ) } diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts index 7159bcb51..cc7a468e9 100644 --- a/src/lib/routes/types.ts +++ b/src/lib/routes/types.ts @@ -9,7 +9,6 @@ export type CommonNavigatorParams = { ModerationMuteLists: undefined ModerationMutedAccounts: undefined ModerationBlockedAccounts: undefined - DiscoverFeeds: undefined Settings: undefined Profile: {name: string; hideBackButton?: boolean} ProfileFollowers: {name: string} diff --git a/src/lib/strings/url-helpers.ts b/src/lib/strings/url-helpers.ts index b509aad01..671dc9781 100644 --- a/src/lib/strings/url-helpers.ts +++ b/src/lib/strings/url-helpers.ts @@ -129,6 +129,15 @@ export function listUriToHref(url: string): string { } } +export function feedUriToHref(url: string): string { + try { + const {hostname, rkey} = new AtUri(url) + return `/profile/${hostname}/feed/${rkey}` + } catch { + return '' + } +} + export function getYoutubeVideoId(link: string): string | undefined { let url try { |