From ea885339cf3a5cba4aa82fbe5e0176052c3b68e1 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Mon, 18 Sep 2023 11:44:29 -0700 Subject: [PATCH] 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 * Remove dead comment --------- Co-authored-by: Eric Bailey * Redesign feeds tab (#1439) * consolidate saved feeds and discover into one screen * Add hoverStyle behavior to * 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 * 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 Co-authored-by: Ansh --- package.json | 2 + src/Navigation.tsx | 6 - src/lib/api/feed-manip.ts | 47 ++- src/lib/api/feed/author.ts | 45 ++ src/lib/api/feed/custom.ts | 52 +++ src/lib/api/feed/following.ts | 37 ++ src/lib/api/feed/likes.ts | 45 ++ src/lib/api/feed/merge.ts | 236 +++++++++++ src/lib/api/feed/types.ts | 17 + src/lib/icons.tsx | 64 +-- src/lib/routes/types.ts | 1 - src/lib/strings/url-helpers.ts | 9 + src/routes.ts | 1 - src/state/models/feeds/multi-feed.ts | 227 ---------- src/state/models/feeds/posts-slice.ts | 3 + src/state/models/feeds/posts.ts | 172 +++----- src/state/models/root-store.ts | 2 +- src/state/models/ui/my-feeds.ts | 157 +++++++ src/state/models/ui/preferences.ts | 31 +- src/state/models/ui/profile.ts | 7 - src/view/com/notifications/Feed.tsx | 5 + src/view/com/pager/FeedsTabBar.web.tsx | 8 +- src/view/com/pager/FeedsTabBarMobile.tsx | 13 +- src/view/com/post-thread/PostThread.tsx | 2 + src/view/com/post-thread/PostThreadItem.tsx | 9 - src/view/com/post/Post.tsx | 22 +- src/view/com/posts/FeedItem.tsx | 57 +-- src/view/com/posts/FeedSlice.tsx | 2 + src/view/com/posts/FollowingEmptyState.tsx | 2 +- src/view/com/posts/MultiFeed.tsx | 256 ------------ src/view/com/util/Link.tsx | 15 +- src/view/com/util/LoadingPlaceholder.tsx | 54 +++ src/view/com/util/SimpleViewHeader.tsx | 105 +++++ src/view/com/util/UserAvatar.tsx | 2 +- src/view/com/util/forms/SearchInput.tsx | 104 +++++ .../com/util/load-latest/LoadLatestBtn.tsx | 87 +++- .../util/load-latest/LoadLatestBtn.web.tsx | 109 ----- .../util/load-latest/LoadLatestBtnMobile.tsx | 69 ---- src/view/index.ts | 24 +- src/view/screens/CustomFeed.tsx | 391 ++++++------------ src/view/screens/DiscoverFeeds.tsx | 157 ------- src/view/screens/Feeds.tsx | 320 ++++++++++---- src/view/screens/Home.tsx | 87 +++- src/view/screens/Notifications.tsx | 57 ++- src/view/screens/PreferencesHomeFeed.tsx | 66 ++- src/view/screens/Profile.tsx | 4 +- src/view/screens/SavedFeeds.tsx | 2 +- src/view/screens/Settings.tsx | 11 +- src/view/shell/Drawer.tsx | 15 +- src/view/shell/bottom-bar/BottomBar.tsx | 19 +- src/view/shell/bottom-bar/BottomBarStyles.tsx | 3 + src/view/shell/bottom-bar/BottomBarWeb.tsx | 12 +- src/view/shell/desktop/Feeds.tsx | 92 +++++ src/view/shell/desktop/LeftNav.tsx | 13 +- src/view/shell/desktop/RightNav.tsx | 5 +- src/view/shell/desktop/Search.tsx | 1 + yarn.lock | 20 +- 57 files changed, 1884 insertions(+), 1497 deletions(-) create mode 100644 src/lib/api/feed/author.ts create mode 100644 src/lib/api/feed/custom.ts create mode 100644 src/lib/api/feed/following.ts create mode 100644 src/lib/api/feed/likes.ts create mode 100644 src/lib/api/feed/merge.ts create mode 100644 src/lib/api/feed/types.ts delete mode 100644 src/state/models/feeds/multi-feed.ts create mode 100644 src/state/models/ui/my-feeds.ts delete mode 100644 src/view/com/posts/MultiFeed.tsx create mode 100644 src/view/com/util/SimpleViewHeader.tsx create mode 100644 src/view/com/util/forms/SearchInput.tsx delete mode 100644 src/view/com/util/load-latest/LoadLatestBtn.web.tsx delete mode 100644 src/view/com/util/load-latest/LoadLatestBtnMobile.tsx delete mode 100644 src/view/screens/DiscoverFeeds.tsx create mode 100644 src/view/shell/desktop/Feeds.tsx diff --git a/package.json b/package.json index 90d12e77..faa51574 100644 --- a/package.json +++ b/package.json @@ -102,6 +102,7 @@ "lodash.isequal": "^4.5.0", "lodash.omit": "^4.5.0", "lodash.once": "^4.1.1", + "lodash.random": "^3.2.0", "lodash.samplesize": "^4.2.0", "lodash.set": "^4.3.2", "lodash.shuffle": "^4.2.0", @@ -168,6 +169,7 @@ "@types/lodash.isequal": "^4.5.6", "@types/lodash.omit": "^4.5.7", "@types/lodash.once": "^4.1.7", + "@types/lodash.random": "^3.2.7", "@types/lodash.samplesize": "^4.2.7", "@types/lodash.set": "^4.3.7", "@types/lodash.shuffle": "^4.2.7", diff --git a/src/Navigation.tsx b/src/Navigation.tsx index c16ff3a8..9bf6ba98 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -40,7 +40,6 @@ import {FeedsScreen} from './view/screens/Feeds' import {NotificationsScreen} from './view/screens/Notifications' import {ModerationScreen} from './view/screens/Moderation' import {ModerationMuteListsScreen} from './view/screens/ModerationMuteLists' -import {DiscoverFeedsScreen} from 'view/screens/DiscoverFeeds' import {NotFoundScreen} from './view/screens/NotFound' import {SettingsScreen} from './view/screens/Settings' import {ProfileScreen} from './view/screens/Profile' @@ -113,11 +112,6 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) { component={ModerationBlockedAccounts} options={{title: title('Blocked Accounts')}} /> - '__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 00000000..1ae92512 --- /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 { + const res = await this.rootStore.agent.getAuthorFeed({ + ...this.params, + limit: 1, + }) + return res.data.feed[0] + } + + async fetchNext({limit}: {limit: number}): Promise { + 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 00000000..d05d5acd --- /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 { + 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 { + 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 00000000..f14807a5 --- /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 { + const res = await this.rootStore.agent.getTimeline({ + limit: 1, + }) + return res.data.feed[0] + } + + async fetchNext({limit}: {limit: number}): Promise { + 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 00000000..e9bb14b0 --- /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 { + const res = await this.rootStore.agent.getActorLikes({ + ...this.params, + limit: 1, + }) + return res.data.feed[0] + } + + async fetchNext({limit}: {limit: number}): Promise { + 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 00000000..51a61958 --- /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 { + const res = await this.rootStore.agent.getTimeline({ + limit: 1, + }) + return res.data.feed[0] + } + + async fetchNext({limit}: {limit: number}): Promise { + // 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 { + 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 { + 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 { + 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 00000000..00634433 --- /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 + fetchNext({limit}: {limit: number}): Promise +} + +export interface FeedSourceInfo { + uri: string + displayName: string +} diff --git a/src/lib/icons.tsx b/src/lib/icons.tsx index 233f8a47..fef7be2f 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 - size?: string | number - strokeWidth?: number -}) { - return ( - - - - - - - ) -} - -export function SatelliteDishIcon({ +export function HashtagIcon({ style, size, strokeWidth = 1.5, @@ -934,26 +896,16 @@ export function SatelliteDishIcon({ return ( - - - - + + + + ) } diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts index 7159bcb5..cc7a468e 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 b509aad0..671dc978 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 { diff --git a/src/routes.ts b/src/routes.ts index 45a8fa57..7c356eb1 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -4,7 +4,6 @@ export const router = new Router({ Home: '/', Search: '/search', Feeds: '/feeds', - DiscoverFeeds: '/search/feeds', Notifications: '/notifications', Settings: '/settings', Moderation: '/moderation', diff --git a/src/state/models/feeds/multi-feed.ts b/src/state/models/feeds/multi-feed.ts deleted file mode 100644 index 95574fb5..00000000 --- a/src/state/models/feeds/multi-feed.ts +++ /dev/null @@ -1,227 +0,0 @@ -import {makeAutoObservable, runInAction} from 'mobx' -import {AtUri} from '@atproto/api' -import {bundleAsync} from 'lib/async/bundle' -import {RootStoreModel} from '../root-store' -import {CustomFeedModel} from './custom-feed' -import {PostsFeedModel} from './posts' -import {PostsFeedSliceModel} from './posts-slice' -import {makeProfileLink} from 'lib/routes/links' - -const FEED_PAGE_SIZE = 10 -const FEEDS_PAGE_SIZE = 3 - -export type MultiFeedItem = - | { - _reactKey: string - type: 'header' - } - | { - _reactKey: string - type: 'feed-header' - avatar: string | undefined - title: string - } - | { - _reactKey: string - type: 'feed-slice' - slice: PostsFeedSliceModel - } - | { - _reactKey: string - type: 'feed-loading' - } - | { - _reactKey: string - type: 'feed-error' - error: string - } - | { - _reactKey: string - type: 'feed-footer' - title: string - uri: string - } - | { - _reactKey: string - type: 'footer' - } - -export class PostsMultiFeedModel { - // state - isLoading = false - isRefreshing = false - hasLoaded = false - hasMore = true - - // data - feedInfos: CustomFeedModel[] = [] - feeds: PostsFeedModel[] = [] - - constructor(public rootStore: RootStoreModel) { - makeAutoObservable(this, {rootStore: false}, {autoBind: true}) - } - - get hasContent() { - return this.feeds.length !== 0 - } - - get isEmpty() { - return this.hasLoaded && !this.hasContent - } - - get items() { - const items: MultiFeedItem[] = [{_reactKey: '__header__', type: 'header'}] - for (let i = 0; i < this.feedInfos.length; i++) { - if (!this.feeds[i]) { - break - } - const feed = this.feeds[i] - const feedInfo = this.feedInfos[i] - const urip = new AtUri(feedInfo.uri) - items.push({ - _reactKey: `__feed_header_${i}__`, - type: 'feed-header', - avatar: feedInfo.data.avatar, - title: feedInfo.displayName, - }) - if (feed.isLoading) { - items.push({ - _reactKey: `__feed_loading_${i}__`, - type: 'feed-loading', - }) - } else if (feed.hasError) { - items.push({ - _reactKey: `__feed_error_${i}__`, - type: 'feed-error', - error: feed.error, - }) - } else { - for (let j = 0; j < feed.slices.length; j++) { - items.push({ - _reactKey: `__feed_slice_${i}_${j}__`, - type: 'feed-slice', - slice: feed.slices[j], - }) - } - } - items.push({ - _reactKey: `__feed_footer_${i}__`, - type: 'feed-footer', - title: feedInfo.displayName, - uri: makeProfileLink(feedInfo.data.creator, 'feed', urip.rkey), - }) - } - if (!this.hasMore && this.hasContent) { - // only show if hasContent to avoid double discover-feed links - items.push({_reactKey: '__footer__', type: 'footer'}) - } - return items - } - - // public api - // = - - /** - * Nuke all data - */ - clear() { - this.rootStore.log.debug('MultiFeedModel:clear') - this.isLoading = false - this.isRefreshing = false - this.hasLoaded = false - this.hasMore = true - this.feeds = [] - } - - /** - * Register any event listeners. Returns a cleanup function. - */ - registerListeners() { - const sub = this.rootStore.onPostDeleted(this.onPostDeleted.bind(this)) - return () => sub.remove() - } - - /** - * Reset and load - */ - async refresh() { - this.feedInfos = this.rootStore.me.savedFeeds.all.slice() // capture current feeds - await this.loadMore(true) - } - - /** - * Load latest in the active feeds - */ - loadLatest() { - for (const feed of this.feeds) { - /* dont await */ feed.refresh() - } - } - - /** - * Load more posts to the end of the feed - */ - loadMore = bundleAsync(async (isRefreshing: boolean = false) => { - if (!isRefreshing && !this.hasMore) { - return - } - if (isRefreshing) { - this.isRefreshing = true // set optimistically for UI - this.feeds = [] - } - this._xLoading(isRefreshing) - const start = this.feeds.length - const newFeeds: PostsFeedModel[] = [] - for ( - let i = start; - i < start + FEEDS_PAGE_SIZE && i < this.feedInfos.length; - i++ - ) { - const feed = new PostsFeedModel(this.rootStore, 'custom', { - feed: this.feedInfos[i].uri, - }) - feed.pageSize = FEED_PAGE_SIZE - await feed.setup() - newFeeds.push(feed) - } - runInAction(() => { - this.feeds = this.feeds.concat(newFeeds) - this.hasMore = this.feeds.length < this.feedInfos.length - }) - this._xIdle() - }) - - /** - * Attempt to load more again after a failure - */ - async retryLoadMore() { - this.hasMore = true - return this.loadMore() - } - - /** - * Removes posts from the feed upon deletion. - */ - onPostDeleted(uri: string) { - for (const f of this.feeds) { - f.onPostDeleted(uri) - } - } - - // state transitions - // = - - _xLoading(isRefreshing = false) { - this.isLoading = true - this.isRefreshing = isRefreshing - } - - _xIdle() { - this.isLoading = false - this.isRefreshing = false - this.hasLoaded = true - } - - // helper functions - // = -} diff --git a/src/state/models/feeds/posts-slice.ts b/src/state/models/feeds/posts-slice.ts index 16e4eef1..2501cef6 100644 --- a/src/state/models/feeds/posts-slice.ts +++ b/src/state/models/feeds/posts-slice.ts @@ -2,6 +2,7 @@ import {makeAutoObservable} from 'mobx' import {RootStoreModel} from '../root-store' import {FeedViewPostsSlice} from 'lib/api/feed-manip' import {PostsFeedItemModel} from './post' +import {FeedSourceInfo} from 'lib/api/feed/types' export class PostsFeedSliceModel { // ui state @@ -9,9 +10,11 @@ export class PostsFeedSliceModel { // data items: PostsFeedItemModel[] = [] + source: FeedSourceInfo | undefined constructor(public rootStore: RootStoreModel, slice: FeedViewPostsSlice) { this._reactKey = slice._reactKey + this.source = slice.source for (let i = 0; i < slice.items.length; i++) { this.items.push( new PostsFeedItemModel( diff --git a/src/state/models/feeds/posts.ts b/src/state/models/feeds/posts.ts index c88249c8..d4e62533 100644 --- a/src/state/models/feeds/posts.ts +++ b/src/state/models/feeds/posts.ts @@ -14,6 +14,13 @@ import {PostsFeedSliceModel} from './posts-slice' import {track} from 'lib/analytics/analytics' import {FeedViewPostsSlice} from 'lib/api/feed-manip' +import {FeedAPI, FeedAPIResponse} from 'lib/api/feed/types' +import {FollowingFeedAPI} from 'lib/api/feed/following' +import {AuthorFeedAPI} from 'lib/api/feed/author' +import {LikesFeedAPI} from 'lib/api/feed/likes' +import {CustomFeedAPI} from 'lib/api/feed/custom' +import {MergeFeedAPI} from 'lib/api/feed/merge' + const PAGE_SIZE = 30 type Options = { @@ -27,6 +34,7 @@ type Options = { type QueryParams = | GetTimeline.QueryParams | GetAuthorFeed.QueryParams + | GetActorLikes.QueryParams | GetCustomFeed.QueryParams export class PostsFeedModel { @@ -41,8 +49,8 @@ export class PostsFeedModel { loadMoreError = '' params: QueryParams hasMore = true - loadMoreCursor: string | undefined pollCursor: string | undefined + api: FeedAPI tuner = new FeedTuner() pageSize = PAGE_SIZE options: Options = {} @@ -50,7 +58,7 @@ export class PostsFeedModel { // used to linearize async modifications to state lock = new AwaitLock() - // used to track if what's hot is coming up empty + // used to track if a feed is coming up empty emptyFetches = 0 // data @@ -58,7 +66,7 @@ export class PostsFeedModel { constructor( public rootStore: RootStoreModel, - public feedType: 'home' | 'author' | 'custom' | 'likes', + public feedType: 'home' | 'following' | 'author' | 'custom' | 'likes', params: QueryParams, options?: Options, ) { @@ -67,12 +75,33 @@ export class PostsFeedModel { { rootStore: false, params: false, - loadMoreCursor: false, }, {autoBind: true}, ) this.params = params this.options = options || {} + if (feedType === 'home') { + this.api = new MergeFeedAPI(rootStore) + } else if (feedType === 'following') { + this.api = new FollowingFeedAPI(rootStore) + } else if (feedType === 'author') { + this.api = new AuthorFeedAPI( + rootStore, + params as GetAuthorFeed.QueryParams, + ) + } else if (feedType === 'likes') { + this.api = new LikesFeedAPI( + rootStore, + params as GetActorLikes.QueryParams, + ) + } else if (feedType === 'custom') { + this.api = new CustomFeedAPI( + rootStore, + params as GetCustomFeed.QueryParams, + ) + } else { + this.api = new FollowingFeedAPI(rootStore) + } } get hasContent() { @@ -105,7 +134,6 @@ export class PostsFeedModel { this.hasLoaded = false this.error = '' this.hasMore = true - this.loadMoreCursor = undefined this.pollCursor = undefined this.slices = [] this.tuner.reset() @@ -113,6 +141,8 @@ export class PostsFeedModel { get feedTuners() { const areRepliesEnabled = this.rootStore.preferences.homeFeedRepliesEnabled + const areRepliesByFollowedOnlyEnabled = + this.rootStore.preferences.homeFeedRepliesByFollowedOnlyEnabled const repliesThreshold = this.rootStore.preferences.homeFeedRepliesThreshold const areRepostsEnabled = this.rootStore.preferences.homeFeedRepostsEnabled const areQuotePostsEnabled = @@ -126,7 +156,7 @@ export class PostsFeedModel { ), ] } - if (this.feedType === 'home') { + if (this.feedType === 'home' || this.feedType === 'following') { const feedTuners = [] if (areRepostsEnabled) { @@ -136,7 +166,13 @@ export class PostsFeedModel { } if (areRepliesEnabled) { - feedTuners.push(FeedTuner.likedRepliesOnly({repliesThreshold})) + feedTuners.push( + FeedTuner.thresholdRepliesOnly({ + userDid: this.rootStore.session.data?.did || '', + minLikes: repliesThreshold, + followedOnly: areRepliesByFollowedOnlyEnabled, + }), + ) } else { feedTuners.push(FeedTuner.removeReplies) } @@ -161,10 +197,11 @@ export class PostsFeedModel { await this.lock.acquireAsync() try { this.setHasNewLatest(false) + this.api.reset() this.tuner.reset() this._xLoading(isRefreshing) try { - const res = await this._getFeed({limit: this.pageSize}) + const res = await this.api.fetchNext({limit: this.pageSize}) await this._replaceAll(res) this._xIdle() } catch (e: any) { @@ -201,8 +238,7 @@ export class PostsFeedModel { } this._xLoading() try { - const res = await this._getFeed({ - cursor: this.loadMoreCursor, + const res = await this.api.fetchNext({ limit: this.pageSize, }) await this._appendAll(res) @@ -230,44 +266,6 @@ export class PostsFeedModel { return this.loadMore() } - /** - * Update content in-place - */ - update = bundleAsync(async () => { - await this.lock.acquireAsync() - try { - if (!this.slices.length) { - return - } - this._xLoading() - let numToFetch = this.slices.length - let cursor - try { - do { - const res: GetTimeline.Response = await this._getFeed({ - cursor, - limit: Math.min(numToFetch, 100), - }) - if (res.data.feed.length === 0) { - break // sanity check - } - this._updateAll(res) - numToFetch -= res.data.feed.length - cursor = res.data.cursor - } while (cursor && numToFetch > 0) - this._xIdle() - } catch (e: any) { - this._xIdle() // don't bubble the error to the user - this.rootStore.log.error('FeedView: Failed to update', { - params: this.params, - e, - }) - } - } finally { - this.lock.release() - } - }) - /** * Check if new posts are available */ @@ -275,9 +273,9 @@ export class PostsFeedModel { if (!this.hasLoaded || this.hasNewLatest || this.isLoading) { return } - const res = await this._getFeed({limit: 1}) - if (res.data.feed[0]) { - const slices = this.tuner.tune(res.data.feed, this.feedTuners, { + const post = await this.api.peekLatest() + if (post) { + const slices = this.tuner.tune([post], this.feedTuners, { dryRun: true, }) if (slices[0]) { @@ -345,33 +343,27 @@ export class PostsFeedModel { // helper functions // = - async _replaceAll( - res: GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response, - ) { - this.pollCursor = res.data.feed[0]?.post.uri + async _replaceAll(res: FeedAPIResponse) { + this.pollCursor = res.feed[0]?.post.uri return this._appendAll(res, true) } - async _appendAll( - res: GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response, - replace = false, - ) { - this.loadMoreCursor = res.data.cursor - this.hasMore = !!this.loadMoreCursor + async _appendAll(res: FeedAPIResponse, replace = false) { + this.hasMore = !!res.cursor if (replace) { this.emptyFetches = 0 } this.rootStore.me.follows.hydrateProfiles( - res.data.feed.map(item => item.post.author), + res.feed.map(item => item.post.author), ) - for (const item of res.data.feed) { + for (const item of res.feed) { this.rootStore.posts.fromFeedItem(item) } const slices = this.options.isSimpleFeed - ? res.data.feed.map(item => new FeedViewPostsSlice([item])) - : this.tuner.tune(res.data.feed, this.feedTuners) + ? res.feed.map(item => new FeedViewPostsSlice([item])) + : this.tuner.tune(res.feed, this.feedTuners) const toAppend: PostsFeedSliceModel[] = [] for (const slice of slices) { @@ -401,54 +393,4 @@ export class PostsFeedModel { } }) } - - _updateAll( - res: GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response, - ) { - for (const item of res.data.feed) { - this.rootStore.posts.fromFeedItem(item) - const existingSlice = this.slices.find(slice => - slice.containsUri(item.post.uri), - ) - if (existingSlice) { - const existingItem = existingSlice.items.find( - item2 => item2.post.uri === item.post.uri, - ) - if (existingItem) { - existingItem.copyMetrics(item) - } - } - } - } - - protected async _getFeed( - params: QueryParams, - ): Promise< - GetTimeline.Response | GetAuthorFeed.Response | GetCustomFeed.Response - > { - params = Object.assign({}, this.params, params) - if (this.feedType === 'home') { - return this.rootStore.agent.getTimeline(params as GetTimeline.QueryParams) - } else if (this.feedType === 'custom') { - const res = await this.rootStore.agent.app.bsky.feed.getFeed( - params as GetCustomFeed.QueryParams, - ) - // NOTE - // some custom feeds fail to enforce the pagination limit - // so we manually truncate here - // -prf - if (params.limit && res.data.feed.length > params.limit) { - res.data.feed = res.data.feed.slice(0, params.limit) - } - return res - } else if (this.feedType === 'author') { - return this.rootStore.agent.getAuthorFeed( - params as GetAuthorFeed.QueryParams, - ) - } else { - return this.rootStore.agent.getActorLikes( - params as GetActorLikes.QueryParams, - ) - } - } } diff --git a/src/state/models/root-store.ts b/src/state/models/root-store.ts index 6204e0d1..1a81072a 100644 --- a/src/state/models/root-store.ts +++ b/src/state/models/root-store.ts @@ -139,7 +139,7 @@ export class RootStoreModel { this.agent = agent applyDebugHeader(this.agent) this.me.clear() - /* dont await */ this.preferences.sync() + await this.preferences.sync() await this.me.load() if (!hadSession) { await resetNavigation() diff --git a/src/state/models/ui/my-feeds.ts b/src/state/models/ui/my-feeds.ts new file mode 100644 index 00000000..f9ad06f7 --- /dev/null +++ b/src/state/models/ui/my-feeds.ts @@ -0,0 +1,157 @@ +import {makeAutoObservable} from 'mobx' +import {FeedsDiscoveryModel} from '../discovery/feeds' +import {CustomFeedModel} from '../feeds/custom-feed' +import {RootStoreModel} from '../root-store' + +export type MyFeedsItem = + | { + _reactKey: string + type: 'spinner' + } + | { + _reactKey: string + type: 'discover-feeds-loading' + } + | { + _reactKey: string + type: 'error' + error: string + } + | { + _reactKey: string + type: 'saved-feeds-header' + } + | { + _reactKey: string + type: 'saved-feed' + feed: CustomFeedModel + } + | { + _reactKey: string + type: 'saved-feeds-load-more' + } + | { + _reactKey: string + type: 'discover-feeds-header' + } + | { + _reactKey: string + type: 'discover-feeds-no-results' + } + | { + _reactKey: string + type: 'discover-feed' + feed: CustomFeedModel + } + +export class MyFeedsUIModel { + discovery: FeedsDiscoveryModel + + constructor(public rootStore: RootStoreModel) { + makeAutoObservable(this) + this.discovery = new FeedsDiscoveryModel(this.rootStore) + } + + get saved() { + return this.rootStore.me.savedFeeds + } + + get isRefreshing() { + return !this.saved.isLoading && this.saved.isRefreshing + } + + get isLoading() { + return this.saved.isLoading || this.discovery.isLoading + } + + async setup() { + if (!this.saved.hasLoaded) { + await this.saved.refresh() + } + if (!this.discovery.hasLoaded) { + await this.discovery.refresh() + } + } + + async refresh() { + return Promise.all([this.saved.refresh(), this.discovery.refresh()]) + } + + async loadMore() { + return this.discovery.loadMore() + } + + get items() { + let items: MyFeedsItem[] = [] + + items.push({ + _reactKey: '__saved_feeds_header__', + type: 'saved-feeds-header', + }) + if (this.saved.isLoading) { + items.push({ + _reactKey: '__saved_feeds_loading__', + type: 'spinner', + }) + } else if (this.saved.hasError) { + items.push({ + _reactKey: '__saved_feeds_error__', + type: 'error', + error: this.saved.error, + }) + } else { + const savedSorted = this.saved.all + .slice() + .sort((a, b) => a.displayName.localeCompare(b.displayName)) + items = items.concat( + savedSorted.map(feed => ({ + _reactKey: `saved-${feed.uri}`, + type: 'saved-feed', + feed, + })), + ) + items.push({ + _reactKey: '__saved_feeds_load_more__', + type: 'saved-feeds-load-more', + }) + } + + items.push({ + _reactKey: '__discover_feeds_header__', + type: 'discover-feeds-header', + }) + if (this.discovery.isLoading && !this.discovery.hasContent) { + items.push({ + _reactKey: '__discover_feeds_loading__', + type: 'discover-feeds-loading', + }) + } else if (this.discovery.hasError) { + items.push({ + _reactKey: '__discover_feeds_error__', + type: 'error', + error: this.discovery.error, + }) + } else if (this.discovery.isEmpty) { + items.push({ + _reactKey: '__discover_feeds_no_results__', + type: 'discover-feeds-no-results', + }) + } else { + items = items.concat( + this.discovery.feeds.map(feed => ({ + _reactKey: `discover-${feed.uri}`, + type: 'discover-feed', + feed, + })), + ) + if (this.discovery.isLoading) { + items.push({ + _reactKey: '__discover_feeds_loading_more__', + type: 'spinner', + }) + } + } + + return items + } +} diff --git a/src/state/models/ui/preferences.ts b/src/state/models/ui/preferences.ts index 64ab4ecb..7232a7b7 100644 --- a/src/state/models/ui/preferences.ts +++ b/src/state/models/ui/preferences.ts @@ -50,9 +50,11 @@ export class PreferencesModel { pinnedFeeds: string[] = [] birthDate: Date | undefined = undefined homeFeedRepliesEnabled: boolean = true - homeFeedRepliesThreshold: number = 2 + homeFeedRepliesByFollowedOnlyEnabled: boolean = true + homeFeedRepliesThreshold: number = 0 homeFeedRepostsEnabled: boolean = true homeFeedQuotePostsEnabled: boolean = true + homeFeedMergeFeedEnabled: boolean = false requireAltTextEnabled: boolean = false // used to linearize async modifications to state @@ -78,9 +80,12 @@ export class PreferencesModel { savedFeeds: this.savedFeeds, pinnedFeeds: this.pinnedFeeds, homeFeedRepliesEnabled: this.homeFeedRepliesEnabled, + homeFeedRepliesByFollowedOnlyEnabled: + this.homeFeedRepliesByFollowedOnlyEnabled, homeFeedRepliesThreshold: this.homeFeedRepliesThreshold, homeFeedRepostsEnabled: this.homeFeedRepostsEnabled, homeFeedQuotePostsEnabled: this.homeFeedQuotePostsEnabled, + homeFeedMergeFeedEnabled: this.homeFeedMergeFeedEnabled, requireAltTextEnabled: this.requireAltTextEnabled, } } @@ -148,6 +153,14 @@ export class PreferencesModel { ) { this.homeFeedRepliesEnabled = v.homeFeedRepliesEnabled } + // check if home feed replies "followed only" are enabled in preferences, then hydrate + if ( + hasProp(v, 'homeFeedRepliesByFollowedOnlyEnabled') && + typeof v.homeFeedRepliesByFollowedOnlyEnabled === 'boolean' + ) { + this.homeFeedRepliesByFollowedOnlyEnabled = + v.homeFeedRepliesByFollowedOnlyEnabled + } // check if home feed replies threshold is enabled in preferences, then hydrate if ( hasProp(v, 'homeFeedRepliesThreshold') && @@ -169,6 +182,13 @@ export class PreferencesModel { ) { this.homeFeedQuotePostsEnabled = v.homeFeedQuotePostsEnabled } + // check if home feed mergefeed is enabled in preferences, then hydrate + if ( + hasProp(v, 'homeFeedMergeFeedEnabled') && + typeof v.homeFeedMergeFeedEnabled === 'boolean' + ) { + this.homeFeedMergeFeedEnabled = v.homeFeedMergeFeedEnabled + } // check if requiring alt text is enabled in preferences, then hydrate if ( hasProp(v, 'requireAltTextEnabled') && @@ -449,6 +469,11 @@ export class PreferencesModel { this.homeFeedRepliesEnabled = !this.homeFeedRepliesEnabled } + toggleHomeFeedRepliesByFollowedOnlyEnabled() { + this.homeFeedRepliesByFollowedOnlyEnabled = + !this.homeFeedRepliesByFollowedOnlyEnabled + } + setHomeFeedRepliesThreshold(threshold: number) { this.homeFeedRepliesThreshold = threshold } @@ -461,6 +486,10 @@ export class PreferencesModel { this.homeFeedQuotePostsEnabled = !this.homeFeedQuotePostsEnabled } + toggleHomeFeedMergeFeedEnabled() { + this.homeFeedMergeFeedEnabled = !this.homeFeedMergeFeedEnabled + } + toggleRequireAltTextEnabled() { this.requireAltTextEnabled = !this.requireAltTextEnabled } diff --git a/src/state/models/ui/profile.ts b/src/state/models/ui/profile.ts index 11951b0e..8525426b 100644 --- a/src/state/models/ui/profile.ts +++ b/src/state/models/ui/profile.ts @@ -240,13 +240,6 @@ export class ProfileUiModel { .catch(err => this.rootStore.log.error('Failed to fetch lists', err)) } - async update() { - const view = this.currentView - if (view instanceof PostsFeedModel) { - await view.update() - } - } - async refresh() { await Promise.all([this.profile.refresh(), this.currentView.refresh()]) } diff --git a/src/view/com/notifications/Feed.tsx b/src/view/com/notifications/Feed.tsx index d457d713..4ca22282 100644 --- a/src/view/com/notifications/Feed.tsx +++ b/src/view/com/notifications/Feed.tsx @@ -21,11 +21,13 @@ export const Feed = observer(function Feed({ scrollElRef, onPressTryAgain, onScroll, + ListHeaderComponent, }: { view: NotificationsFeedModel scrollElRef?: MutableRefObject | null> onPressTryAgain?: () => void onScroll?: OnScrollCb + ListHeaderComponent?: () => JSX.Element }) { const pal = usePalette('default') const [isPTRing, setIsPTRing] = React.useState(false) @@ -142,6 +144,7 @@ export const Feed = observer(function Feed({ data={data} keyExtractor={item => item._reactKey} renderItem={renderItem} + ListHeaderComponent={ListHeaderComponent} ListFooterComponent={FeedFooter} refreshControl={ ) : null} diff --git a/src/view/com/pager/FeedsTabBar.web.tsx b/src/view/com/pager/FeedsTabBar.web.tsx index 0083e953..02aa623c 100644 --- a/src/view/com/pager/FeedsTabBar.web.tsx +++ b/src/view/com/pager/FeedsTabBar.web.tsx @@ -12,15 +12,17 @@ import {FeedsTabBar as FeedsTabBarMobile} from './FeedsTabBarMobile' export const FeedsTabBar = observer(function FeedsTabBarImpl( props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void}, ) { - const {isMobile} = useWebMediaQueries() + const {isMobile, isTablet} = useWebMediaQueries() if (isMobile) { return + } else if (isTablet) { + return } else { - return + return null } }) -const FeedsTabBarDesktop = observer(function FeedsTabBarDesktopImpl( +const FeedsTabBarTablet = observer(function FeedsTabBarTabletImpl( props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void}, ) { const store = useStores() diff --git a/src/view/com/pager/FeedsTabBarMobile.tsx b/src/view/com/pager/FeedsTabBarMobile.tsx index 5ce2906b..30a71254 100644 --- a/src/view/com/pager/FeedsTabBarMobile.tsx +++ b/src/view/com/pager/FeedsTabBarMobile.tsx @@ -9,8 +9,8 @@ import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle' import {Link} from '../util/Link' import {Text} from '../util/text/Text' -import {CogIcon} from 'lib/icons' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome' import {s} from 'lib/styles' import {HITSLOP_10} from 'lib/constants' @@ -67,12 +67,15 @@ export const FeedsTabBar = observer(function FeedsTabBarImpl( - + accessibilityLabel="Home Feed Preferences" + accessibilityHint=""> + diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx index 7a5a4577..1cc177d1 100644 --- a/src/view/com/post-thread/PostThread.tsx +++ b/src/view/com/post-thread/PostThread.tsx @@ -357,6 +357,8 @@ export const PostThread = observer(function PostThread({ } onScrollToIndexFailed={onScrollToIndexFailed} style={s.hContentRegion} + // @ts-ignore our .web version only -prf + desktopFixedHeight /> ) }) diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index 5b5fee0c..37c7ece4 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -483,15 +483,6 @@ export const PostThreadItem = observer(function PostThreadItem({ /> )} - {needsTranslation && ( - - - - Translate this post - - - - )} - store.preferences.contentLanguages.length > 0 && - !isPostInLanguage(item.post, store.preferences.contentLanguages), - [item.post, store.preferences.contentLanguages], - ) const onPressReply = React.useCallback(() => { store.shell.openComposer({ @@ -256,15 +250,6 @@ const PostLoaded = observer(function PostLoadedImpl({ /> ) : null} - {needsTranslation && ( - - - - Translate this post - - - - )} - store.preferences.contentLanguages.length > 0 && - !isPostInLanguage(item.post, store.preferences.contentLanguages), - [item.post, store.preferences.contentLanguages], - ) const onPressReply = React.useCallback(() => { track('FeedItem:PostReply') @@ -179,7 +176,27 @@ export const FeedItem = observer(function FeedItemImpl({ - {item.reasonRepost && ( + {source ? ( + + + From{' '} + + + + ) : item.reasonRepost ? ( - )} + ) : null} @@ -304,15 +321,6 @@ export const FeedItem = observer(function FeedItemImpl({ /> ) : null} - {needsTranslation && ( - - - - Translate this post - - - - )} @@ -55,6 +56,7 @@ export const FeedSlice = observer(function FeedSliceImpl({ { - navigation.navigate('DiscoverFeeds') + navigation.navigate('Feeds') }, [navigation]) return ( diff --git a/src/view/com/posts/MultiFeed.tsx b/src/view/com/posts/MultiFeed.tsx deleted file mode 100644 index 9c8f4f24..00000000 --- a/src/view/com/posts/MultiFeed.tsx +++ /dev/null @@ -1,256 +0,0 @@ -import React, {MutableRefObject} from 'react' -import {observer} from 'mobx-react-lite' -import { - ActivityIndicator, - RefreshControl, - StyleProp, - StyleSheet, - View, - ViewStyle, -} from 'react-native' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {FlatList} from '../util/Views' -import {PostFeedLoadingPlaceholder} from '../util/LoadingPlaceholder' -import {ErrorMessage} from '../util/error/ErrorMessage' -import {PostsMultiFeedModel, MultiFeedItem} from 'state/models/feeds/multi-feed' -import {FeedSlice} from './FeedSlice' -import {Text} from '../util/text/Text' -import {Link} from '../util/Link' -import {UserAvatar} from '../util/UserAvatar' -import {OnScrollCb} from 'lib/hooks/useOnMainScroll' -import {s} from 'lib/styles' -import {useAnalytics} from 'lib/analytics/analytics' -import {usePalette} from 'lib/hooks/usePalette' -import {useTheme} from 'lib/ThemeContext' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {CogIcon} from 'lib/icons' - -export const MultiFeed = observer(function Feed({ - multifeed, - style, - scrollElRef, - onScroll, - scrollEventThrottle, - testID, - headerOffset = 0, - extraData, -}: { - multifeed: PostsMultiFeedModel - style?: StyleProp - scrollElRef?: MutableRefObject | null> - onPressTryAgain?: () => void - onScroll?: OnScrollCb - scrollEventThrottle?: number - renderEmptyState?: () => JSX.Element - testID?: string - headerOffset?: number - extraData?: any -}) { - const pal = usePalette('default') - const theme = useTheme() - const {isMobile} = useWebMediaQueries() - const {track} = useAnalytics() - const [isRefreshing, setIsRefreshing] = React.useState(false) - - // events - // = - - const onRefresh = React.useCallback(async () => { - track('MultiFeed:onRefresh') - setIsRefreshing(true) - try { - await multifeed.refresh() - } catch (err) { - multifeed.rootStore.log.error('Failed to refresh posts feed', err) - } - setIsRefreshing(false) - }, [multifeed, track, setIsRefreshing]) - - const onEndReached = React.useCallback(async () => { - track('MultiFeed:onEndReached') - try { - await multifeed.loadMore() - } catch (err) { - multifeed.rootStore.log.error('Failed to load more posts', err) - } - }, [multifeed, track]) - - // rendering - // = - - const renderItem = React.useCallback( - ({item}: {item: MultiFeedItem}) => { - if (item.type === 'header') { - if (!isMobile) { - return ( - <> - - - My Feeds - - - - - - - - ) - } - return ( - <> - - - - ) - } else if (item.type === 'feed-header') { - return ( - - - - {item.title} - - - ) - } else if (item.type === 'feed-slice') { - return - } else if (item.type === 'feed-loading') { - return - } else if (item.type === 'feed-error') { - return - } else if (item.type === 'feed-footer') { - return ( - - - See more from {item.title} - - - - ) - } else if (item.type === 'footer') { - return - } - return null - }, - [pal, isMobile], - ) - - const ListFooter = React.useCallback( - () => - multifeed.isLoading && !isRefreshing ? ( - - - - ) : ( - - ), - [multifeed.isLoading, isRefreshing, pal], - ) - - return ( - - {multifeed.items.length > 0 && ( - item._reactKey} - renderItem={renderItem} - ListFooterComponent={ListFooter} - refreshControl={ - - } - contentContainerStyle={s.contentContainer} - style={[{paddingTop: headerOffset}, pal.view, styles.container]} - onScroll={onScroll} - scrollEventThrottle={scrollEventThrottle} - indicatorStyle={theme.colorScheme === 'dark' ? 'white' : 'black'} - onEndReached={onEndReached} - onEndReachedThreshold={0.6} - removeClippedSubviews={true} - contentOffset={{x: 0, y: headerOffset * -1}} - extraData={extraData} - // @ts-ignore our .web version only -prf - desktopFixedHeight - /> - )} - - ) -}) - -function DiscoverLink() { - const pal = usePalette('default') - return ( - - - - Discover new feeds - - - ) -} - -const styles = StyleSheet.create({ - container: { - height: '100%', - }, - header: { - borderTopWidth: 1, - marginBottom: 4, - }, - headerDesktop: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - borderBottomWidth: 1, - marginBottom: 4, - paddingHorizontal: 16, - paddingVertical: 8, - }, - feedHeader: { - flexDirection: 'row', - gap: 8, - alignItems: 'center', - paddingHorizontal: 16, - paddingBottom: 8, - marginTop: 12, - }, - feedHeaderTitle: { - fontWeight: 'bold', - }, - feedFooter: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - paddingHorizontal: 16, - paddingVertical: 16, - marginBottom: 12, - borderTopWidth: 1, - borderBottomWidth: 1, - }, - discoverLink: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - borderRadius: 8, - paddingHorizontal: 14, - paddingVertical: 12, - marginHorizontal: 8, - marginVertical: 8, - gap: 8, - }, - loadMore: { - paddingTop: 10, - }, -}) diff --git a/src/view/com/util/Link.tsx b/src/view/com/util/Link.tsx index 321b6ab6..d4df2bec 100644 --- a/src/view/com/util/Link.tsx +++ b/src/view/com/util/Link.tsx @@ -26,6 +26,7 @@ import {useStores, RootStoreModel} from 'state/index' import {convertBskyAppUrlIfNeeded, isExternalUrl} from 'lib/strings/url-helpers' import {isAndroid, isDesktopWeb} from 'platform/detection' import {sanitizeUrl} from '@braintree/sanitize-url' +import {PressableWithHover} from './PressableWithHover' import FixedTouchableHighlight from '../pager/FixedTouchableHighlight' type Event = @@ -38,6 +39,7 @@ interface Props extends ComponentProps { href?: string title?: string children?: React.ReactNode + hoverStyle?: StyleProp noFeedback?: boolean asAnchor?: boolean anchorNoUnderline?: boolean @@ -112,8 +114,9 @@ export const Link = observer(function Link({ props.accessibilityLabel = title } + const Com = props.hoverStyle ? PressableWithHover : Pressable return ( - {children ? children : {title || 'link'}} - + ) }) @@ -137,6 +140,7 @@ export const TextLink = observer(function TextLink({ lineHeight, dataSet, title, + onPress, }: { testID?: string type?: TypographyVariant @@ -154,9 +158,14 @@ export const TextLink = observer(function TextLink({ props.onPress = React.useCallback( (e?: Event) => { + if (onPress) { + e?.preventDefault?.() + // @ts-ignore function signature differs by platform -prf + return onPress() + } return onPressInner(store, navigation, sanitizeUrl(href), e) }, - [store, navigation, href], + [onPress, store, navigation, href], ) const hrefAttrs = useMemo(() => { const isExternal = isExternalUrl(href) diff --git a/src/view/com/util/LoadingPlaceholder.tsx b/src/view/com/util/LoadingPlaceholder.tsx index bf39fd50..d7ab1be5 100644 --- a/src/view/com/util/LoadingPlaceholder.tsx +++ b/src/view/com/util/LoadingPlaceholder.tsx @@ -174,6 +174,60 @@ export function ProfileCardFeedLoadingPlaceholder() { ) } +export function FeedLoadingPlaceholder({ + style, +}: { + style?: StyleProp +}) { + const pal = usePalette('default') + return ( + + + + + + + + + + + + + + ) +} + +export function FeedFeedLoadingPlaceholder() { + return ( + <> + + + + + + + + + + + + + ) +} + const styles = StyleSheet.create({ loadingPlaceholder: { borderRadius: 6, diff --git a/src/view/com/util/SimpleViewHeader.tsx b/src/view/com/util/SimpleViewHeader.tsx new file mode 100644 index 00000000..4eff38a3 --- /dev/null +++ b/src/view/com/util/SimpleViewHeader.tsx @@ -0,0 +1,105 @@ +import React from 'react' +import {observer} from 'mobx-react-lite' +import { + StyleProp, + StyleSheet, + TouchableOpacity, + View, + ViewStyle, +} from 'react-native' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {useNavigation} from '@react-navigation/native' +import {CenteredView} from './Views' +import {useStores} from 'state/index' +import {usePalette} from 'lib/hooks/usePalette' +import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {useAnalytics} from 'lib/analytics/analytics' +import {NavigationProp} from 'lib/routes/types' + +const BACK_HITSLOP = {left: 20, top: 20, right: 50, bottom: 20} + +export const SimpleViewHeader = observer(function SimpleViewHeaderImpl({ + showBackButton = true, + style, + children, +}: React.PropsWithChildren<{ + showBackButton?: boolean + style?: StyleProp +}>) { + const pal = usePalette('default') + const store = useStores() + const navigation = useNavigation() + const {track} = useAnalytics() + const {isMobile} = useWebMediaQueries() + const canGoBack = navigation.canGoBack() + + const onPressBack = React.useCallback(() => { + if (navigation.canGoBack()) { + navigation.goBack() + } else { + navigation.navigate('Home') + } + }, [navigation]) + + const onPressMenu = React.useCallback(() => { + track('ViewHeader:MenuButtonClicked') + store.shell.openDrawer() + }, [track, store]) + + const Container = isMobile ? View : CenteredView + return ( + + {showBackButton ? ( + + {canGoBack ? ( + + ) : ( + + )} + + ) : null} + {children} + + ) +}) + +const styles = StyleSheet.create({ + header: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 18, + paddingVertical: 12, + width: '100%', + }, + headerMobile: { + paddingHorizontal: 12, + paddingVertical: 10, + }, + backBtn: { + width: 30, + height: 30, + }, + backBtnWide: { + width: 30, + height: 30, + paddingHorizontal: 6, + }, + backIcon: { + marginTop: 6, + }, +}) diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx index 0f34f75a..7a42ab4d 100644 --- a/src/view/com/util/UserAvatar.tsx +++ b/src/view/com/util/UserAvatar.tsx @@ -118,7 +118,7 @@ export function UserAvatar({ return { width: size, height: size, - borderRadius: 8, + borderRadius: size > 32 ? 8 : 3, } } return { diff --git a/src/view/com/util/forms/SearchInput.tsx b/src/view/com/util/forms/SearchInput.tsx new file mode 100644 index 00000000..c1eb82bd --- /dev/null +++ b/src/view/com/util/forms/SearchInput.tsx @@ -0,0 +1,104 @@ +import React from 'react' +import { + StyleProp, + StyleSheet, + TextInput, + TouchableOpacity, + View, + ViewStyle, +} from 'react-native' +import { + FontAwesomeIcon, + FontAwesomeIconStyle, +} from '@fortawesome/react-native-fontawesome' +import {MagnifyingGlassIcon} from 'lib/icons' +import {useTheme} from 'lib/ThemeContext' +import {usePalette} from 'lib/hooks/usePalette' + +interface Props { + query: string + setIsInputFocused?: (v: boolean) => void + onChangeQuery: (v: string) => void + onPressCancelSearch: () => void + onSubmitQuery: () => void + style?: StyleProp +} +export function SearchInput({ + query, + setIsInputFocused, + onChangeQuery, + onPressCancelSearch, + onSubmitQuery, + style, +}: Props) { + const theme = useTheme() + const pal = usePalette('default') + const textInput = React.useRef(null) + + const onPressCancelSearchInner = React.useCallback(() => { + onPressCancelSearch() + textInput.current?.blur() + }, [onPressCancelSearch, textInput]) + + return ( + + + setIsInputFocused?.(true)} + onBlur={() => setIsInputFocused?.(false)} + onChangeText={onChangeQuery} + onSubmitEditing={onSubmitQuery} + accessibilityRole="search" + accessibilityLabel="Search" + accessibilityHint="" + autoCorrect={false} + autoCapitalize="none" + /> + {query ? ( + + + + ) : undefined} + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + borderRadius: 30, + paddingHorizontal: 12, + paddingVertical: 8, + }, + icon: { + marginRight: 6, + alignSelf: 'center', + }, + input: { + flex: 1, + fontSize: 17, + minWidth: 0, // overflow mitigation for firefox + }, + cancelBtn: { + paddingLeft: 10, + }, +}) diff --git a/src/view/com/util/load-latest/LoadLatestBtn.tsx b/src/view/com/util/load-latest/LoadLatestBtn.tsx index ae9cb936..6b73edd4 100644 --- a/src/view/com/util/load-latest/LoadLatestBtn.tsx +++ b/src/view/com/util/load-latest/LoadLatestBtn.tsx @@ -1 +1,86 @@ -export * from './LoadLatestBtnMobile' +import React from 'react' +import {StyleSheet, TouchableOpacity, View} from 'react-native' +import {observer} from 'mobx-react-lite' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {useSafeAreaInsets} from 'react-native-safe-area-context' +import {clamp} from 'lodash' +import {useStores} from 'state/index' +import {usePalette} from 'lib/hooks/usePalette' +import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {colors} from 'lib/styles' +import {HITSLOP_20} from 'lib/constants' + +export const LoadLatestBtn = observer(function LoadLatestBtnImpl({ + onPress, + label, + showIndicator, +}: { + onPress: () => void + label: string + showIndicator: boolean + minimalShellMode?: boolean // NOTE not used on mobile -prf +}) { + const store = useStores() + const pal = usePalette('default') + const {isDesktop, isTablet, isMobile} = useWebMediaQueries() + const safeAreaInsets = useSafeAreaInsets() + return ( + + + {showIndicator && } + + ) +}) + +const styles = StyleSheet.create({ + loadLatest: { + position: 'absolute', + left: 18, + bottom: 35, + borderWidth: 1, + width: 52, + height: 52, + borderRadius: 26, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + }, + loadLatestTablet: { + // @ts-ignore web only + left: '50vw', + // @ts-ignore web only -prf + transform: 'translateX(-282px)', + }, + loadLatestDesktop: { + // @ts-ignore web only + left: '50vw', + // @ts-ignore web only -prf + transform: 'translateX(-382px)', + }, + indicator: { + position: 'absolute', + top: 3, + right: 3, + backgroundColor: colors.blue3, + width: 12, + height: 12, + borderRadius: 6, + borderWidth: 1, + }, +}) diff --git a/src/view/com/util/load-latest/LoadLatestBtn.web.tsx b/src/view/com/util/load-latest/LoadLatestBtn.web.tsx deleted file mode 100644 index 83c696f7..00000000 --- a/src/view/com/util/load-latest/LoadLatestBtn.web.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import React from 'react' -import {StyleSheet, TouchableOpacity} from 'react-native' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {Text} from '../text/Text' -import {usePalette} from 'lib/hooks/usePalette' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {LoadLatestBtn as LoadLatestBtnMobile} from './LoadLatestBtnMobile' -import {HITSLOP_20} from 'lib/constants' - -export const LoadLatestBtn = ({ - onPress, - label, - showIndicator, - minimalShellMode, -}: { - onPress: () => void - label: string - showIndicator: boolean - minimalShellMode?: boolean -}) => { - const pal = usePalette('default') - const {isMobile} = useWebMediaQueries() - if (isMobile) { - return ( - - ) - } - return ( - <> - {showIndicator && ( - - - {label} - - - )} - - - - - - - ) -} - -const styles = StyleSheet.create({ - loadLatest: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - position: 'absolute', - // @ts-ignore web only - left: '50vw', - // @ts-ignore web only -prf - transform: 'translateX(-282px)', - bottom: 40, - width: 54, - height: 54, - borderRadius: 30, - borderWidth: 1, - }, - icon: { - position: 'relative', - top: 2, - }, - loadLatestCentered: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - position: 'absolute', - // @ts-ignore web only - left: '50vw', - // @ts-ignore web only -prf - transform: 'translateX(-50%)', - top: 60, - paddingHorizontal: 24, - paddingVertical: 14, - borderRadius: 30, - borderWidth: 1, - }, - loadLatestCenteredMinimal: { - top: 20, - }, -}) diff --git a/src/view/com/util/load-latest/LoadLatestBtnMobile.tsx b/src/view/com/util/load-latest/LoadLatestBtnMobile.tsx deleted file mode 100644 index 3e8add5e..00000000 --- a/src/view/com/util/load-latest/LoadLatestBtnMobile.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import React from 'react' -import {StyleSheet, TouchableOpacity, View} from 'react-native' -import {observer} from 'mobx-react-lite' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {useSafeAreaInsets} from 'react-native-safe-area-context' -import {clamp} from 'lodash' -import {useStores} from 'state/index' -import {usePalette} from 'lib/hooks/usePalette' -import {colors} from 'lib/styles' -import {HITSLOP_20} from 'lib/constants' - -export const LoadLatestBtn = observer(function LoadLatestBtnImpl({ - onPress, - label, - showIndicator, -}: { - onPress: () => void - label: string - showIndicator: boolean - minimalShellMode?: boolean // NOTE not used on mobile -prf -}) { - const store = useStores() - const pal = usePalette('default') - const safeAreaInsets = useSafeAreaInsets() - return ( - - - {showIndicator && } - - ) -}) - -const styles = StyleSheet.create({ - loadLatest: { - position: 'absolute', - left: 18, - bottom: 35, - borderWidth: 1, - width: 52, - height: 52, - borderRadius: 26, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - }, - indicator: { - position: 'absolute', - top: 3, - right: 3, - backgroundColor: colors.blue3, - width: 12, - height: 12, - borderRadius: 6, - borderWidth: 1, - }, -}) diff --git a/src/view/index.ts b/src/view/index.ts index 2e4c08ec..2fdc34e7 100644 --- a/src/view/index.ts +++ b/src/view/index.ts @@ -13,6 +13,7 @@ import {faArrowRightFromBracket} from '@fortawesome/free-solid-svg-icons/faArrow import {faArrowUpFromBracket} from '@fortawesome/free-solid-svg-icons/faArrowUpFromBracket' import {faArrowUpRightFromSquare} from '@fortawesome/free-solid-svg-icons/faArrowUpRightFromSquare' import {faArrowRotateLeft} from '@fortawesome/free-solid-svg-icons/faArrowRotateLeft' +import {faArrowTrendUp} from '@fortawesome/free-solid-svg-icons/faArrowTrendUp' import {faArrowsRotate} from '@fortawesome/free-solid-svg-icons/faArrowsRotate' import {faAt} from '@fortawesome/free-solid-svg-icons/faAt' import {faBars} from '@fortawesome/free-solid-svg-icons/faBars' @@ -24,6 +25,7 @@ import {faBookmark as farBookmark} from '@fortawesome/free-regular-svg-icons/faB import {faCalendar as farCalendar} from '@fortawesome/free-regular-svg-icons/faCalendar' import {faCamera} from '@fortawesome/free-solid-svg-icons/faCamera' import {faCheck} from '@fortawesome/free-solid-svg-icons/faCheck' +import {faChevronRight} from '@fortawesome/free-solid-svg-icons/faChevronRight' import {faCircle} from '@fortawesome/free-regular-svg-icons/faCircle' import {faCircleCheck as farCircleCheck} from '@fortawesome/free-regular-svg-icons/faCircleCheck' import {faCircleCheck} from '@fortawesome/free-solid-svg-icons/faCircleCheck' @@ -41,6 +43,7 @@ import {faExclamation} from '@fortawesome/free-solid-svg-icons/faExclamation' import {faEye} from '@fortawesome/free-solid-svg-icons/faEye' import {faEyeSlash as farEyeSlash} from '@fortawesome/free-regular-svg-icons/faEyeSlash' import {faFaceSmile} from '@fortawesome/free-regular-svg-icons/faFaceSmile' +import {faFire} from '@fortawesome/free-solid-svg-icons/faFire' import {faFloppyDisk} from '@fortawesome/free-regular-svg-icons/faFloppyDisk' import {faGear} from '@fortawesome/free-solid-svg-icons/faGear' import {faGlobe} from '@fortawesome/free-solid-svg-icons/faGlobe' @@ -54,15 +57,18 @@ import {faImage} from '@fortawesome/free-solid-svg-icons/faImage' import {faInfo} from '@fortawesome/free-solid-svg-icons/faInfo' import {faLanguage} from '@fortawesome/free-solid-svg-icons/faLanguage' import {faLink} from '@fortawesome/free-solid-svg-icons/faLink' +import {faList} from '@fortawesome/free-solid-svg-icons/faList' import {faListUl} from '@fortawesome/free-solid-svg-icons/faListUl' import {faLock} from '@fortawesome/free-solid-svg-icons/faLock' import {faMagnifyingGlass} from '@fortawesome/free-solid-svg-icons/faMagnifyingGlass' import {faMessage} from '@fortawesome/free-regular-svg-icons/faMessage' import {faNoteSticky} from '@fortawesome/free-solid-svg-icons/faNoteSticky' +import {faPause} from '@fortawesome/free-solid-svg-icons/faPause' import {faPaste} from '@fortawesome/free-regular-svg-icons/faPaste' import {faPen} from '@fortawesome/free-solid-svg-icons/faPen' import {faPenNib} from '@fortawesome/free-solid-svg-icons/faPenNib' import {faPenToSquare} from '@fortawesome/free-solid-svg-icons/faPenToSquare' +import {faPlay} from '@fortawesome/free-solid-svg-icons/faPlay' import {faPlus} from '@fortawesome/free-solid-svg-icons/faPlus' import {faQuoteLeft} from '@fortawesome/free-solid-svg-icons/faQuoteLeft' import {faReply} from '@fortawesome/free-solid-svg-icons/faReply' @@ -77,6 +83,7 @@ import {faSliders} from '@fortawesome/free-solid-svg-icons/faSliders' import {faSquare} from '@fortawesome/free-regular-svg-icons/faSquare' import {faSquareCheck} from '@fortawesome/free-regular-svg-icons/faSquareCheck' import {faSquarePlus} from '@fortawesome/free-regular-svg-icons/faSquarePlus' +import {faThumbtack} from '@fortawesome/free-solid-svg-icons/faThumbtack' import {faTicket} from '@fortawesome/free-solid-svg-icons/faTicket' import {faTrashCan} from '@fortawesome/free-regular-svg-icons/faTrashCan' import {faUser} from '@fortawesome/free-regular-svg-icons/faUser' @@ -88,11 +95,6 @@ import {faUserXmark} from '@fortawesome/free-solid-svg-icons/faUserXmark' import {faUsersSlash} from '@fortawesome/free-solid-svg-icons/faUsersSlash' import {faX} from '@fortawesome/free-solid-svg-icons/faX' import {faXmark} from '@fortawesome/free-solid-svg-icons/faXmark' -import {faPlay} from '@fortawesome/free-solid-svg-icons/faPlay' -import {faPause} from '@fortawesome/free-solid-svg-icons/faPause' -import {faThumbtack} from '@fortawesome/free-solid-svg-icons/faThumbtack' -import {faList} from '@fortawesome/free-solid-svg-icons/faList' -import {faChevronRight} from '@fortawesome/free-solid-svg-icons/faChevronRight' export function setup() { library.add( @@ -109,6 +111,7 @@ export function setup() { faArrowUpFromBracket, faArrowUpRightFromSquare, faArrowRotateLeft, + faArrowTrendUp, faArrowsRotate, faAt, faBan, @@ -120,6 +123,7 @@ export function setup() { farCalendar, faCamera, faCheck, + faChevronRight, faCircle, faCircleCheck, farCircleCheck, @@ -137,6 +141,7 @@ export function setup() { faExclamation, farEyeSlash, faFaceSmile, + faFire, faFloppyDisk, faGear, faGlobe, @@ -150,15 +155,18 @@ export function setup() { faInfo, faLanguage, faLink, + faList, faListUl, faLock, faMagnifyingGlass, faMessage, faNoteSticky, faPaste, + faPause, faPen, faPenNib, faPenToSquare, + faPlay, faPlus, faQuoteLeft, faReply, @@ -180,14 +188,10 @@ export function setup() { faUserPlus, faUserXmark, faUsersSlash, + faThumbtack, faTicket, faTrashCan, - faThumbtack, faX, faXmark, - faPlay, - faPause, - faList, - faChevronRight, ) } diff --git a/src/view/screens/CustomFeed.tsx b/src/view/screens/CustomFeed.tsx index af4d0184..eaa21f29 100644 --- a/src/view/screens/CustomFeed.tsx +++ b/src/view/screens/CustomFeed.tsx @@ -1,7 +1,7 @@ import React, {useMemo, useRef} from 'react' import {NativeStackScreenProps} from '@react-navigation/native-stack' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {useNavigation} from '@react-navigation/native' +import {useNavigation, useIsFocused} from '@react-navigation/native' import {usePalette} from 'lib/hooks/usePalette' import {HeartIcon, HeartIconSolid} from 'lib/icons' import {CommonNavigatorParams} from 'lib/routes/types' @@ -14,11 +14,8 @@ import {PostsFeedModel} from 'state/models/feeds/posts' import {useCustomFeed} from 'lib/hooks/useCustomFeed' import {withAuthRequired} from 'view/com/auth/withAuthRequired' import {Feed} from 'view/com/posts/Feed' -import {pluralize} from 'lib/strings/helpers' -import {sanitizeHandle} from 'lib/strings/handles' import {TextLink} from 'view/com/util/Link' -import {UserAvatar} from 'view/com/util/UserAvatar' -import {ViewHeader} from 'view/com/util/ViewHeader' +import {SimpleViewHeader} from 'view/com/util/SimpleViewHeader' import {Button} from 'view/com/util/forms/Button' import {Text} from 'view/com/util/text/Text' import * as Toast from 'view/com/util/Toast' @@ -34,7 +31,6 @@ import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' import {EmptyState} from 'view/com/util/EmptyState' import {useAnalytics} from 'lib/analytics/analytics' import {NativeDropdown, DropdownItem} from 'view/com/util/forms/NativeDropdown' -import {makeProfileLink} from 'lib/routes/links' import {resolveName} from 'lib/api' import {CenteredView} from 'view/com/util/Views' import {NavigationProp} from 'lib/routes/types' @@ -125,7 +121,10 @@ export const CustomFeedScreenInner = observer( }: Props & {feedOwnerDid: string}) { const store = useStores() const pal = usePalette('default') - const {isTabletOrDesktop} = useWebMediaQueries() + const palInverted = usePalette('inverted') + const navigation = useNavigation() + const isScreenFocused = useIsFocused() + const {isMobile, isTabletOrDesktop} = useWebMediaQueries() const {track} = useAnalytics() const {rkey, name: handleOrDid} = route.params const uri = useMemo( @@ -186,6 +185,10 @@ export const CustomFeedScreenInner = observer( }) }, [store, currentFeed]) + const onPressViewAuthor = React.useCallback(() => { + navigation.navigate('Profile', {name: handleOrDid}) + }, [handleOrDid, navigation]) + const onPressShare = React.useCallback(() => { const url = toShareUrl(`/profile/${handleOrDid}/feed/${rkey}`) shareUrl(url) @@ -210,8 +213,39 @@ export const CustomFeedScreenInner = observer( store.shell.openComposer({}) }, [store]) + const onSoftReset = React.useCallback(() => { + if (isScreenFocused) { + onScrollToTop() + algoFeed.refresh() + } + }, [isScreenFocused, onScrollToTop, algoFeed]) + + // fires when page within screen is activated/deactivated + React.useEffect(() => { + if (!isScreenFocused) { + return + } + + const softResetSub = store.onScreenSoftReset(onSoftReset) + return () => { + softResetSub.remove() + } + }, [store, onSoftReset, isScreenFocused]) + const dropdownItems: DropdownItem[] = React.useMemo(() => { let items: DropdownItem[] = [ + { + testID: 'feedHeaderDropdownViewAuthorBtn', + label: 'View author', + onPress: onPressViewAuthor, + icon: { + ios: { + name: 'person', + }, + android: '', + web: ['far', 'user'], + }, + }, { testID: 'feedHeaderDropdownToggleSavedBtn', label: currentFeed?.isSaved @@ -260,232 +294,12 @@ export const CustomFeedScreenInner = observer( }, ] return items - }, [currentFeed?.isSaved, onToggleSaved, onPressReport, onPressShare]) - - const renderHeaderBtns = React.useCallback(() => { - return ( - - - {currentFeed?.isSaved ? ( - - ) : undefined} - {!currentFeed?.isSaved ? ( - - ) : null} - - - - - - - ) }, [ - pal, currentFeed?.isSaved, - currentFeed?.isLiked, - isPinned, onToggleSaved, - onTogglePinned, - onToggleLiked, - dropdownItems, - ]) - - const renderListHeaderComponent = React.useCallback(() => { - return ( - <> - - - - {currentFeed?.displayName} - - {currentFeed && ( - - by{' '} - {currentFeed.data.creator.did === store.me.did ? ( - 'you' - ) : ( - - )} - - )} - {isTabletOrDesktop && ( - - - - - - - )} - - - - - - - {currentFeed?.data.description ? ( - - {currentFeed.data.description} - - ) : null} - - {currentFeed ? ( - - ) : null} - - - - - - Feed - - - - - ) - }, [ - pal, - currentFeed, - store.me.did, - onToggleSaved, - onToggleLiked, - onPressShare, - handleOrDid, onPressReport, - rkey, - isPinned, - onTogglePinned, - isTabletOrDesktop, + onPressShare, + onPressViewAuthor, ]) const renderEmptyState = React.useCallback(() => { @@ -498,22 +312,100 @@ export const CustomFeedScreenInner = observer( return ( - {!isTabletOrDesktop && ( - - )} + + + {currentFeed ? ( + store.emitScreenSoftReset()} + /> + ) : ( + 'Loading...' + )} + + {currentFeed ? ( + <> + + {currentFeed?.isSaved ? ( + + ) : ( + + )} + + ) : null} + + + + + + {isScrolledDown ? ( @@ -540,36 +432,19 @@ const styles = StyleSheet.create({ paddingBottom: 16, borderTopWidth: 1, }, - headerBtns: { - flexDirection: 'row', - alignItems: 'center', + headerText: { + flex: 1, + fontWeight: 'bold', }, - headerBtnsDesktop: { - marginTop: 8, - gap: 4, + headerBtn: { + paddingVertical: 0, }, headerAddBtn: { flexDirection: 'row', alignItems: 'center', gap: 4, - paddingLeft: 4, - }, - headerDetails: { - paddingHorizontal: 16, - paddingBottom: 16, - }, - headerDetailsFooter: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - }, - fakeSelector: { - flexDirection: 'row', - }, - fakeSelectorItem: { - paddingHorizontal: 12, - paddingBottom: 8, - borderBottomWidth: 3, + paddingVertical: 4, + paddingLeft: 10, }, liked: { color: colors.red3, diff --git a/src/view/screens/DiscoverFeeds.tsx b/src/view/screens/DiscoverFeeds.tsx deleted file mode 100644 index 6aa7a9e3..00000000 --- a/src/view/screens/DiscoverFeeds.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import React from 'react' -import {RefreshControl, StyleSheet, View} from 'react-native' -import {observer} from 'mobx-react-lite' -import {useFocusEffect} from '@react-navigation/native' -import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' -import {withAuthRequired} from 'view/com/auth/withAuthRequired' -import {ViewHeader} from '../com/util/ViewHeader' -import {useStores} from 'state/index' -import {FeedsDiscoveryModel} from 'state/models/discovery/feeds' -import {CenteredView, FlatList} from 'view/com/util/Views' -import {CustomFeed} from 'view/com/feeds/CustomFeed' -import {Text} from 'view/com/util/text/Text' -import {usePalette} from 'lib/hooks/usePalette' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {s} from 'lib/styles' -import {CustomFeedModel} from 'state/models/feeds/custom-feed' -import {HeaderWithInput} from 'view/com/search/HeaderWithInput' -import debounce from 'lodash.debounce' - -type Props = NativeStackScreenProps -export const DiscoverFeedsScreen = withAuthRequired( - observer(function DiscoverFeedsScreenImpl({}: Props) { - const store = useStores() - const pal = usePalette('default') - const feeds = React.useMemo(() => new FeedsDiscoveryModel(store), [store]) - const {isTabletOrDesktop} = useWebMediaQueries() - - // search stuff - const [isInputFocused, setIsInputFocused] = React.useState(false) - const [query, setQuery] = React.useState('') - const debouncedSearchFeeds = React.useMemo( - () => debounce(q => feeds.search(q), 500), // debounce for 500ms - [feeds], - ) - const onChangeQuery = React.useCallback( - (text: string) => { - setQuery(text) - if (text.length > 1) { - debouncedSearchFeeds(text) - } else { - feeds.refresh() - } - }, - [debouncedSearchFeeds, feeds], - ) - const onPressClearQuery = React.useCallback(() => { - setQuery('') - feeds.refresh() - }, [feeds]) - const onPressCancelSearch = React.useCallback(() => { - setIsInputFocused(false) - setQuery('') - feeds.refresh() - }, [feeds]) - const onSubmitQuery = React.useCallback(() => { - debouncedSearchFeeds(query) - debouncedSearchFeeds.flush() - }, [debouncedSearchFeeds, query]) - - useFocusEffect( - React.useCallback(() => { - store.shell.setMinimalShellMode(false) - if (!feeds.hasLoaded) { - feeds.refresh() - } - }, [store, feeds]), - ) - - const onRefresh = React.useCallback(() => { - feeds.refresh() - }, [feeds]) - - const renderListEmptyComponent = () => { - return ( - - - {feeds.isLoading - ? isTabletOrDesktop - ? 'Loading...' - : '' - : query - ? `No results found for "${query}"` - : `We can't find any feeds for some reason. This is probably an error - try refreshing!`} - - - ) - } - - const renderItem = React.useCallback( - ({item}: {item: CustomFeedModel}) => ( - - ), - [], - ) - - return ( - - - - - - item.data.uri} - contentContainerStyle={styles.contentContainer} - refreshControl={ - - } - renderItem={renderItem} - initialNumToRender={10} - ListEmptyComponent={renderListEmptyComponent} - onEndReached={() => feeds.loadMore()} - extraData={feeds.isLoading} - /> - - ) - }), -) - -const styles = StyleSheet.create({ - container: { - flex: 1, - }, - contentContainer: { - paddingBottom: 100, - }, - containerDesktop: { - borderLeftWidth: 1, - borderRightWidth: 1, - }, - empty: { - paddingHorizontal: 16, - paddingTop: 10, - }, -}) diff --git a/src/view/screens/Feeds.tsx b/src/view/screens/Feeds.tsx index 97c6e867..d2c4a6d2 100644 --- a/src/view/screens/Feeds.tsx +++ b/src/view/screens/Feeds.tsx @@ -1,90 +1,72 @@ import React from 'react' -import {StyleSheet, View} from 'react-native' -import {useFocusEffect} from '@react-navigation/native' -import isEqual from 'lodash.isequal' +import {ActivityIndicator, StyleSheet, RefreshControl, View} from 'react-native' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome' +import {AtUri} from '@atproto/api' import {withAuthRequired} from 'view/com/auth/withAuthRequired' -import {FlatList} from 'view/com/util/Views' import {ViewHeader} from 'view/com/util/ViewHeader' -import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn' import {FAB} from 'view/com/util/fab/FAB' import {Link} from 'view/com/util/Link' import {NativeStackScreenProps, FeedsTabNavigatorParams} from 'lib/routes/types' import {observer} from 'mobx-react-lite' -import {PostsMultiFeedModel} from 'state/models/feeds/multi-feed' -import {MultiFeed} from 'view/com/posts/MultiFeed' import {usePalette} from 'lib/hooks/usePalette' -import {useTimer} from 'lib/hooks/useTimer' import {useStores} from 'state/index' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' import {ComposeIcon2, CogIcon} from 'lib/icons' import {s} from 'lib/styles' - -const LOAD_NEW_PROMPT_TIME = 60e3 // 60 seconds -const MOBILE_HEADER_OFFSET = 40 +import {SearchInput} from 'view/com/util/forms/SearchInput' +import {UserAvatar} from 'view/com/util/UserAvatar' +import {FeedFeedLoadingPlaceholder} from 'view/com/util/LoadingPlaceholder' +import {ErrorMessage} from 'view/com/util/error/ErrorMessage' +import debounce from 'lodash.debounce' +import {Text} from 'view/com/util/text/Text' +import {MyFeedsUIModel, MyFeedsItem} from 'state/models/ui/my-feeds' +import {FlatList} from 'view/com/util/Views' +import {useFocusEffect} from '@react-navigation/native' +import {CustomFeed} from 'view/com/feeds/CustomFeed' type Props = NativeStackScreenProps export const FeedsScreen = withAuthRequired( observer(function FeedsScreenImpl({}: Props) { const pal = usePalette('default') const store = useStores() - const {isMobile} = useWebMediaQueries() - const flatListRef = React.useRef(null) - const multifeed = React.useMemo( - () => new PostsMultiFeedModel(store), - [store], + const {isMobile, isTabletOrDesktop} = useWebMediaQueries() + const myFeeds = React.useMemo(() => new MyFeedsUIModel(store), [store]) + const [query, setQuery] = React.useState('') + const debouncedSearchFeeds = React.useMemo( + () => debounce(q => myFeeds.discovery.search(q), 500), // debounce for 500ms + [myFeeds], ) - const [onMainScroll, isScrolledDown, resetMainScroll] = - useOnMainScroll(store) - const [loadPromptVisible, setLoadPromptVisible] = React.useState(false) - const [resetPromptTimer] = useTimer(LOAD_NEW_PROMPT_TIME, () => { - setLoadPromptVisible(true) - }) - - const onSoftReset = React.useCallback(() => { - flatListRef.current?.scrollToOffset({offset: 0}) - multifeed.loadLatest() - resetPromptTimer() - setLoadPromptVisible(false) - resetMainScroll() - }, [ - flatListRef, - resetMainScroll, - multifeed, - resetPromptTimer, - setLoadPromptVisible, - ]) useFocusEffect( React.useCallback(() => { - const softResetSub = store.onScreenSoftReset(onSoftReset) - const multifeedCleanup = multifeed.registerListeners() - const cleanup = () => { - softResetSub.remove() - multifeedCleanup() - } - store.shell.setMinimalShellMode(false) - return cleanup - }, [store, multifeed, onSoftReset]), + myFeeds.setup() + }, [store.shell, myFeeds]), ) - React.useEffect(() => { - if ( - isEqual( - multifeed.feedInfos.map(f => f.uri), - store.me.savedFeeds.all.map(f => f.uri), - ) - ) { - // no changes - return - } - multifeed.refresh() - }, [multifeed, store.me.savedFeeds.all]) - const onPressCompose = React.useCallback(() => { store.shell.openComposer({}) }, [store]) + const onChangeQuery = React.useCallback( + (text: string) => { + setQuery(text) + if (text.length > 1) { + debouncedSearchFeeds(text) + } else { + myFeeds.discovery.refresh() + } + }, + [debouncedSearchFeeds, myFeeds.discovery], + ) + const onPressCancelSearch = React.useCallback(() => { + setQuery('') + myFeeds.discovery.refresh() + }, [myFeeds]) + const onSubmitQuery = React.useCallback(() => { + debouncedSearchFeeds(query) + debouncedSearchFeeds.flush() + }, [debouncedSearchFeeds, query]) const renderHeaderBtn = React.useCallback(() => { return ( @@ -99,30 +81,150 @@ export const FeedsScreen = withAuthRequired( ) }, [pal]) + const onRefresh = React.useCallback(() => { + myFeeds.refresh() + }, [myFeeds]) + + const renderItem = React.useCallback( + ({item}: {item: MyFeedsItem}) => { + if (item.type === 'discover-feeds-loading') { + return + } else if (item.type === 'spinner') { + return ( + + + + ) + } else if (item.type === 'error') { + return + } else if (item.type === 'saved-feeds-header') { + if (!isMobile) { + return ( + + + My Feeds + + + + + + ) + } + return + } else if (item.type === 'saved-feed') { + return ( + + ) + } else if (item.type === 'discover-feeds-header') { + return ( + <> + + + Discover new feeds + + {!isMobile && ( + + )} + + {isMobile && ( + + + + )} + + ) + } else if (item.type === 'discover-feed') { + return ( + + ) + } else if (item.type === 'discover-feeds-no-results') { + return ( + + + No results found for "{query}" + + + ) + } + return null + }, + [isMobile, pal, query, onChangeQuery, onPressCancelSearch, onSubmitQuery], + ) + return ( - {isMobile && ( )} - {isScrolledDown || loadPromptVisible ? ( - - ) : null} + + item._reactKey} + contentContainerStyle={styles.contentContainer} + refreshControl={ + + } + renderItem={renderItem} + initialNumToRender={10} + onEndReached={() => myFeeds.loadMore()} + extraData={myFeeds.isLoading} + // @ts-ignore our .web version only -prf + desktopFixedHeight + /> + + + {displayName} + + {isMobile && ( + + )} + + ) +} + const styles = StyleSheet.create({ container: { flex: 1, }, + list: { + height: '100%', + }, + contentContainer: { + paddingBottom: 100, + }, + + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: 16, + paddingHorizontal: 16, + paddingVertical: 12, + }, + + savedFeed: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 14, + gap: 12, + borderBottomWidth: 1, + }, + savedFeedMobile: { + paddingVertical: 10, + }, }) diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index 33cc2e11..60cda31d 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -1,6 +1,8 @@ import React from 'react' import {FlatList, View} from 'react-native' import {useFocusEffect, useIsFocused} from '@react-navigation/native' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome' import {AppBskyFeedGetFeed as GetCustomFeed} from '@atproto/api' import {observer} from 'mobx-react-lite' import useAppState from 'react-native-appstate-hook' @@ -8,6 +10,7 @@ import isEqual from 'lodash.isequal' import {NativeStackScreenProps, HomeTabNavigatorParams} from 'lib/routes/types' import {PostsFeedModel} from 'state/models/feeds/posts' import {withAuthRequired} from 'view/com/auth/withAuthRequired' +import {TextLink} from 'view/com/util/Link' import {Feed} from '../com/posts/Feed' import {FollowingEmptyState} from 'view/com/posts/FollowingEmptyState' import {CustomFeedEmptyState} from 'view/com/posts/CustomFeedEmptyState' @@ -16,14 +19,16 @@ import {FeedsTabBar} from '../com/pager/FeedsTabBar' import {Pager, PagerRef, RenderTabBarFnProps} from 'view/com/pager/Pager' import {FAB} from '../com/util/fab/FAB' import {useStores} from 'state/index' -import {s} from 'lib/styles' +import {usePalette} from 'lib/hooks/usePalette' +import {s, colors} from 'lib/styles' import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' import {useAnalytics} from 'lib/analytics/analytics' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {ComposeIcon2} from 'lib/icons' const HEADER_OFFSET_MOBILE = 78 -const HEADER_OFFSET_DESKTOP = 50 +const HEADER_OFFSET_TABLET = 50 +const HEADER_OFFSET_DESKTOP = 0 const POLL_FREQ = 30e3 // 30sec type Props = NativeStackScreenProps @@ -154,17 +159,23 @@ const FeedPage = observer(function FeedPageImpl({ renderEmptyState?: () => JSX.Element }) { const store = useStores() - const {isMobile} = useWebMediaQueries() + const pal = usePalette('default') + const {isMobile, isTablet, isDesktop} = useWebMediaQueries() const [onMainScroll, isScrolledDown, resetMainScroll] = useOnMainScroll(store) const {screen, track} = useAnalytics() const [headerOffset, setHeaderOffset] = React.useState( - isMobile ? HEADER_OFFSET_MOBILE : HEADER_OFFSET_DESKTOP, + isMobile + ? HEADER_OFFSET_MOBILE + : isTablet + ? HEADER_OFFSET_TABLET + : HEADER_OFFSET_DESKTOP, ) const scrollElRef = React.useRef(null) const {appState} = useAppState({ onForeground: () => doPoll(true), }) const isScreenFocused = useIsFocused() + const hasNew = feed.hasNewLatest && !feed.isRefreshing React.useEffect(() => { // called on first load @@ -205,8 +216,14 @@ const FeedPage = observer(function FeedPageImpl({ // listens for resize events React.useEffect(() => { - setHeaderOffset(isMobile ? HEADER_OFFSET_MOBILE : HEADER_OFFSET_DESKTOP) - }, [isMobile]) + setHeaderOffset( + isMobile + ? HEADER_OFFSET_MOBILE + : isTablet + ? HEADER_OFFSET_TABLET + : HEADER_OFFSET_DESKTOP, + ) + }, [isMobile, isTablet]) // fires when page within screen is activated/deactivated // - check for latest @@ -222,9 +239,6 @@ const FeedPage = observer(function FeedPageImpl({ screen('Feed') store.log.debug('HomeScreen: Updating feed') feed.checkForLatest() - if (feed.hasContent) { - feed.update() - } return () => { clearInterval(pollInterval) @@ -247,7 +261,59 @@ const FeedPage = observer(function FeedPageImpl({ feed.refresh() }, [feed, scrollToTop]) - const hasNew = feed.hasNewLatest && !feed.isRefreshing + const ListHeaderComponent = React.useCallback(() => { + if (isDesktop) { + return ( + + + {store.session.isSandbox ? 'SANDBOX' : 'Bluesky'}{' '} + {hasNew && ( + + )} + + } + onPress={() => store.emitScreenSoftReset()} + /> + + } + /> + + ) + } + return <> + }, [isDesktop, pal, store, hasNew]) + return ( {(isScrolledDown || hasNew) && ( diff --git a/src/view/screens/Notifications.tsx b/src/view/screens/Notifications.tsx index 3c257fac..243cc959 100644 --- a/src/view/screens/Notifications.tsx +++ b/src/view/screens/Notifications.tsx @@ -9,12 +9,15 @@ import { import {withAuthRequired} from 'view/com/auth/withAuthRequired' import {ViewHeader} from '../com/util/ViewHeader' import {Feed} from '../com/notifications/Feed' +import {TextLink} from 'view/com/util/Link' import {InvitedUsers} from '../com/notifications/InvitedUsers' import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn' import {useStores} from 'state/index' import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' import {useTabFocusEffect} from 'lib/hooks/useTabFocusEffect' -import {s} from 'lib/styles' +import {usePalette} from 'lib/hooks/usePalette' +import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {s, colors} from 'lib/styles' import {useAnalytics} from 'lib/analytics/analytics' import {isWeb} from 'platform/detection' @@ -29,6 +32,12 @@ export const NotificationsScreen = withAuthRequired( useOnMainScroll(store) const scrollElRef = React.useRef(null) const {screen} = useAnalytics() + const pal = usePalette('default') + const {isDesktop} = useWebMediaQueries() + + const hasNew = + store.me.notifications.hasNewLatest && + !store.me.notifications.isRefreshing // event handlers // = @@ -88,9 +97,48 @@ export const NotificationsScreen = withAuthRequired( ), ) - const hasNew = - store.me.notifications.hasNewLatest && - !store.me.notifications.isRefreshing + const ListHeaderComponent = React.useCallback(() => { + if (isDesktop) { + return ( + + + Notifications{' '} + {hasNew && ( + + )} + + } + onPress={() => store.emitScreenSoftReset()} + /> + + ) + } + return <> + }, [isDesktop, pal, store, hasNew]) + return ( @@ -100,6 +148,7 @@ export const NotificationsScreen = withAuthRequired( onPressTryAgain={onPressTryAgain} onScroll={onMainScroll} scrollElRef={scrollElRef} + ListHeaderComponent={ListHeaderComponent} /> {(isScrolledDown || hasNew) && ( - - {value === 0 - ? `Show all replies` - : `Show replies with at least ${value} ${ - value > 1 ? `likes` : `like` - }`} - + { @@ -40,6 +33,13 @@ function RepliesThresholdInput({enabled}: {enabled: boolean}) { disabled={!enabled} thumbTintColor={colors.blue3} /> + + {value === 0 + ? `Show all replies` + : `Show replies with at least ${value} ${ + value > 1 ? `likes` : `like` + }`} + ) } @@ -79,8 +79,7 @@ export const PreferencesHomeFeed = observer(function PreferencesHomeFeedImpl({ Show Replies - Adjust the number of likes a reply must have to be shown in your - feed. + Set this setting to "No" to hide all replies from your feed. - + + + + Reply Filters + + + Enable this setting to only see replies between people you follow. + + + + Adjust the number of likes a reply must have to be shown in your + feed. + @@ -124,6 +152,22 @@ export const PreferencesHomeFeed = observer(function PreferencesHomeFeedImpl({ onPress={store.preferences.toggleHomeFeedQuotePostsEnabled} /> + + + + Show Posts from My Feeds (Experimental) + + + Set this setting to "Yes" to show samples of your saved feeds in + your following feed. + + + diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index 69b5ceee..241bae1e 100644 --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -69,9 +69,7 @@ export const ProfileScreen = withAuthRequired( let aborted = false store.shell.setMinimalShellMode(false) const feedCleanup = uiState.feed.registerListeners() - if (hasSetup) { - uiState.update() - } else { + if (!hasSetup) { uiState.setup().then(() => { if (aborted) { return diff --git a/src/view/screens/SavedFeeds.tsx b/src/view/screens/SavedFeeds.tsx index d5c02ba6..5253c5bd 100644 --- a/src/view/screens/SavedFeeds.tsx +++ b/src/view/screens/SavedFeeds.tsx @@ -70,7 +70,7 @@ export const SavedFeeds = withAuthRequired( return ( <> - + - + - Saved Feeds + My Saved Feeds ) : ( - ) } - label="My Feeds" - accessibilityLabel="My Feeds" + label="Feeds" + accessibilityLabel="Feeds" accessibilityHint="" onPress={onPressMyFeeds} /> diff --git a/src/view/shell/bottom-bar/BottomBar.tsx b/src/view/shell/bottom-bar/BottomBar.tsx index 4a34371e..8ba74da2 100644 --- a/src/view/shell/bottom-bar/BottomBar.tsx +++ b/src/view/shell/bottom-bar/BottomBar.tsx @@ -18,8 +18,7 @@ import { HomeIconSolid, MagnifyingGlassIcon2, MagnifyingGlassIcon2Solid, - SatelliteDishIcon, - SatelliteDishIconSolid, + HashtagIcon, BellIcon, BellIconSolid, } from 'lib/icons' @@ -134,16 +133,16 @@ export const BottomBar = observer(function BottomBarImpl({ testID="bottomBarFeedsBtn" icon={ isAtFeeds ? ( - ) : ( - ) } diff --git a/src/view/shell/bottom-bar/BottomBarStyles.tsx b/src/view/shell/bottom-bar/BottomBarStyles.tsx index f31ab44c..ae938144 100644 --- a/src/view/shell/bottom-bar/BottomBarStyles.tsx +++ b/src/view/shell/bottom-bar/BottomBarStyles.tsx @@ -49,6 +49,9 @@ export const styles = StyleSheet.create({ homeIcon: { top: 0, }, + feedsIcon: { + top: -2, + }, searchIcon: { top: -2, }, diff --git a/src/view/shell/bottom-bar/BottomBarWeb.tsx b/src/view/shell/bottom-bar/BottomBarWeb.tsx index af70d336..6448eea6 100644 --- a/src/view/shell/bottom-bar/BottomBarWeb.tsx +++ b/src/view/shell/bottom-bar/BottomBarWeb.tsx @@ -15,8 +15,7 @@ import { HomeIconSolid, MagnifyingGlassIcon2, MagnifyingGlassIcon2Solid, - SatelliteDishIcon, - SatelliteDishIconSolid, + HashtagIcon, UserIcon, UserIconSolid, } from 'lib/icons' @@ -68,12 +67,11 @@ export const BottomBarWeb = observer(function BottomBarWebImpl() { {({isActive}) => { - const Icon = isActive ? SatelliteDishIconSolid : SatelliteDishIcon return ( - ) }} diff --git a/src/view/shell/desktop/Feeds.tsx b/src/view/shell/desktop/Feeds.tsx new file mode 100644 index 00000000..4da1401c --- /dev/null +++ b/src/view/shell/desktop/Feeds.tsx @@ -0,0 +1,92 @@ +import React from 'react' +import {View, StyleSheet} from 'react-native' +import {useNavigationState} from '@react-navigation/native' +import {AtUri} from '@atproto/api' +import {observer} from 'mobx-react-lite' +import {useStores} from 'state/index' +import {usePalette} from 'lib/hooks/usePalette' +import {TextLink} from 'view/com/util/Link' +import {getCurrentRoute} from 'lib/routes/helpers' + +export const DesktopFeeds = observer(function DesktopFeeds() { + const store = useStores() + const pal = usePalette('default') + + const route = useNavigationState(state => { + if (!state) { + return {name: 'Home'} + } + return getCurrentRoute(state) + }) + + return ( + + + {store.me.savedFeeds.pinned.map(feed => { + try { + const {hostname, rkey} = new AtUri(feed.uri) + const href = `/profile/${hostname}/feed/${rkey}` + const params = route.params as Record + return ( + + ) + } catch { + return null + } + })} + + + + + ) +}) + +function FeedItem({ + title, + href, + current, +}: { + title: string + href: string + current: boolean +}) { + const pal = usePalette('default') + return ( + + + + ) +} + +const styles = StyleSheet.create({ + container: { + position: 'relative', + width: 300, + paddingHorizontal: 12, + borderTopWidth: 1, + borderBottomWidth: 1, + paddingVertical: 18, + }, +}) diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx index 8c1a3324..907df864 100644 --- a/src/view/shell/desktop/LeftNav.tsx +++ b/src/view/shell/desktop/LeftNav.tsx @@ -32,8 +32,7 @@ import { CogIconSolid, ComposeIcon2, HandIcon, - SatelliteDishIcon, - SatelliteDishIconSolid, + HashtagIcon, } from 'lib/icons' import {getCurrentRoute, isTab, isStateAtTabRoot} from 'lib/routes/helpers' import {NavigationProp, CommonNavigatorParams} from 'lib/routes/types' @@ -272,20 +271,20 @@ export const DesktopLeftNav = observer(function DesktopLeftNav() { } iconFilled={ - } - label="My Feeds" + label="Feeds" /> {store.session.hasSession && } + {store.session.hasSession && } {store.session.isSandbox ? ( @@ -126,7 +128,7 @@ const styles = StyleSheet.create({ }, message: { - marginTop: 20, + paddingVertical: 18, paddingHorizontal: 10, }, messageLine: { @@ -134,7 +136,6 @@ const styles = StyleSheet.create({ }, inviteCodes: { - marginTop: 12, borderTopWidth: 1, paddingHorizontal: 16, paddingVertical: 12, diff --git a/src/view/shell/desktop/Search.tsx b/src/view/shell/desktop/Search.tsx index c7b322b5..dfd4f50b 100644 --- a/src/view/shell/desktop/Search.tsx +++ b/src/view/shell/desktop/Search.tsx @@ -113,6 +113,7 @@ const styles = StyleSheet.create({ container: { position: 'relative', width: 300, + paddingBottom: 18, }, search: { paddingHorizontal: 16, diff --git a/yarn.lock b/yarn.lock index 41b42336..3ee7d4c0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6418,6 +6418,13 @@ dependencies: "@types/lodash" "*" +"@types/lodash.random@^3.2.7": + version "3.2.7" + resolved "https://registry.yarnpkg.com/@types/lodash.random/-/lodash.random-3.2.7.tgz#3100a1b7956ce86ab5adcce2e7b305412b98e3bf" + integrity sha512-gFKkVgWYi1q7RFJ+QNTzaRprdhVIZLpZd6C3MTNehKcujMn9SyFUqf2fTBOmvIYXqNk0RpwfbdOwHf0GnEQB0g== + dependencies: + "@types/lodash" "*" + "@types/lodash.samplesize@^4.2.7": version "4.2.7" resolved "https://registry.yarnpkg.com/@types/lodash.samplesize/-/lodash.samplesize-4.2.7.tgz#15784dd9e54aa1bf043552bdb533b83fcf50b82f" @@ -13886,6 +13893,11 @@ lodash.once@^4.0.0, lodash.once@^4.1.1: resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac" integrity sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg== +lodash.random@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/lodash.random/-/lodash.random-3.2.0.tgz#96e24e763333199130d2c9e2fd57f91703cc262d" + integrity sha512-A6Vn7teN0+qSnhOsE8yx2bGowCS1G7D9e5abq8VhwOP98YHS/KrGMf43yYxA05lvcvloT+W9Z2ffkSajFTcPUA== + lodash.samplesize@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/lodash.samplesize/-/lodash.samplesize-4.2.0.tgz#460762fbb2b342290517499e90d51586db465ff9" @@ -16855,10 +16867,10 @@ react-dom@^18.2.0: loose-envify "^1.1.0" scheduler "^0.23.0" -react-error-overlay@^6.0.11: - version "6.0.11" - resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.11.tgz#92835de5841c5cf08ba00ddd2d677b6d17ff9adb" - integrity sha512-/6UZ2qgEyH2aqzYZgQPxEnz33NJ2gNsnHA2o5+o4wW9bLM/JYQitNP9xPhsXwC08hMMovfGe/8retsdDsczPRg== +react-error-overlay@6.0.9, react-error-overlay@^6.0.11: + version "6.0.9" + resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.9.tgz#3c743010c9359608c375ecd6bc76f35d93995b0a" + integrity sha512-nQTTcUu+ATDbrSD1BZHr5kgSD4oF8OFjxun8uAaL8RwPBacGBNPf/yAuVVdx17N8XNzRDMrZ9XcKZHCjPW+9ew== react-freeze@^1.0.0: version "1.0.3"