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>
This commit is contained in:
Paul Frazee 2023-09-18 11:44:29 -07:00 committed by GitHub
parent 3118e3e933
commit ea885339cf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
57 changed files with 1884 additions and 1497 deletions

View file

@ -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)
}
}

View file

@ -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: [],
}
}
}

View file

@ -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: [],
}
}
}

View file

@ -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: [],
}
}
}

45
src/lib/api/feed/likes.ts Normal file
View file

@ -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: [],
}
}
}

236
src/lib/api/feed/merge.ts Normal file
View file

@ -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
}
}

17
src/lib/api/feed/types.ts Normal file
View file

@ -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
}