diff options
author | Paul Frazee <pfrazee@gmail.com> | 2023-09-18 11:44:29 -0700 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-09-18 11:44:29 -0700 |
commit | ea885339cf3a5cba4aa82fbe5e0176052c3b68e1 (patch) | |
tree | a02b0581c42a1a0aae4442a75391c99a1719ec3e | |
parent | 3118e3e93338c62d2466699b9f339544d3273823 (diff) | |
download | voidsky-ea885339cf3a5cba4aa82fbe5e0176052c3b68e1.tar.zst |
Feed UI update working branch [WIP] (#1420)
* Feeds navigation on right side of desktop (#1403) * Remove home feed header on desktop * Add feeds to right sidebar * Add simple non-moving header to desktop * Improve loading state of custom feed header * Remove log Co-authored-by: Eric Bailey <git@esb.lol> * Remove dead comment --------- Co-authored-by: Eric Bailey <git@esb.lol> * Redesign feeds tab (#1439) * consolidate saved feeds and discover into one screen * Add hoverStyle behavior to <Link> * More UI work on SavedFeeds * Replace satellite icon with a hashtag * Tune My Feeds mobile ui * Handle no results in my feeds * Remove old DiscoverFeeds screen * Remove multifeed * Remove DiscoverFeeds from router * Improve loading placeholders * Small fixes * Fix types * Fix overflow issue on firefox * Add icons prompting to open feeds --------- Co-authored-by: Paul Frazee <pfrazee@gmail.com> * Merge feed prototype [WIP] (#1398) * POC WIP for the mergefeed * Add feed API wrapper and move mergefeed into it * Show feed source in mergefeed * Add lodash.random dep * Improve mergefeed sampling and reliability * Tune source ui element * Improve mergefeed edge condition handling * Remove in-place update of feeds for performance * Fix link on native * Fix bad ref * Improve variety in mergefeed sampling * Fix types * Fix rebase error * Add missing source field (got dropped in merge) * Update find more link * Simplify the right hand feeds nav * Bring back load latest button on desktop & unify impl * Add 'From' to source * Add simple headers to desktop home & notifications * Fix thread view jumping around horizontally * Add unread indicators to desktop headers * Add home feed preference for enabling the mergefeed * Add a preference for showing replies among followed users only (#1448) * Add a preference for showing replies among followed users only * Simplify the reply filter UI * Fix typo * Simplified custom feed header * Add soft reset to custom feed screen * Drop all the in-post translate links except when expanded (#1455) * Update mobile feed settings links to match desktop * Fixes to feeds screen loading states * Bolder active state of feeds tab on mobile web * Fix dark mode issue --------- Co-authored-by: Eric Bailey <git@esb.lol> Co-authored-by: Ansh <anshnanda10@gmail.com>
57 files changed, 1884 insertions, 1497 deletions
diff --git a/package.json b/package.json index 90d12e773..faa515749 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 c16ff3a8c..9bf6ba981 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' @@ -114,11 +113,6 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) { options={{title: title('Blocked Accounts')}} /> <Stack.Screen - name="DiscoverFeeds" - component={DiscoverFeedsScreen} - options={{title: title('Discover Feeds')}} - /> - <Stack.Screen name="Settings" component={SettingsScreen} options={{title: title('Settings')}} diff --git a/src/lib/api/feed-manip.ts b/src/lib/api/feed-manip.ts index 149859ea9..ef57fc4f2 100644 --- a/src/lib/api/feed-manip.ts +++ b/src/lib/api/feed-manip.ts @@ -4,6 +4,7 @@ import { AppBskyEmbedRecordWithMedia, AppBskyEmbedRecord, } from '@atproto/api' +import {FeedSourceInfo} from './feed/types' import {isPostInLanguage} from '../../locale/helpers' type FeedViewPost = AppBskyFeedDefs.FeedViewPost @@ -64,6 +65,11 @@ export class FeedViewPostsSlice { ) } + get source(): FeedSourceInfo | undefined { + return this.items.find(item => '__source' in item && !!item.__source) + ?.__source as FeedSourceInfo + } + containsUri(uri: string) { return !!this.items.find(item => item.post.uri === uri) } @@ -91,6 +97,23 @@ export class FeedViewPostsSlice { } } } + + isFollowingAllAuthors(userDid: string) { + const item = this.rootItem + if (item.post.author.did === userDid) { + return true + } + if (AppBskyFeedDefs.isPostView(item.reply?.parent)) { + const parent = item.reply?.parent + if (parent?.author.did === userDid) { + return true + } + return ( + parent?.author.viewer?.following && item.post.author.viewer?.following + ) + } + return false + } } export class FeedTuner { @@ -222,20 +245,34 @@ export class FeedTuner { return slices } - static likedRepliesOnly({repliesThreshold}: {repliesThreshold: number}) { + static thresholdRepliesOnly({ + userDid, + minLikes, + followedOnly, + }: { + userDid: string + minLikes: number + followedOnly: boolean + }) { return ( tuner: FeedTuner, slices: FeedViewPostsSlice[], ): FeedViewPostsSlice[] => { - // remove any replies without at least repliesThreshold likes + // remove any replies without at least minLikes likes for (let i = slices.length - 1; i >= 0; i--) { - if (slices[i].isFullThread || !slices[i].isReply) { + const slice = slices[i] + if (slice.isFullThread || !slice.isReply) { continue } - const item = slices[i].rootItem + const item = slice.rootItem const isRepost = Boolean(item.reason) - if (!isRepost && (item.post.likeCount || 0) < repliesThreshold) { + if (isRepost) { + continue + } + if ((item.post.likeCount || 0) < minLikes) { + slices.splice(i, 1) + } else if (followedOnly && !slice.isFollowingAllAuthors(userDid)) { slices.splice(i, 1) } } diff --git a/src/lib/api/feed/author.ts b/src/lib/api/feed/author.ts new file mode 100644 index 000000000..1ae925123 --- /dev/null +++ b/src/lib/api/feed/author.ts @@ -0,0 +1,45 @@ +import { + AppBskyFeedDefs, + AppBskyFeedGetAuthorFeed as GetAuthorFeed, +} from '@atproto/api' +import {RootStoreModel} from 'state/index' +import {FeedAPI, FeedAPIResponse} from './types' + +export class AuthorFeedAPI implements FeedAPI { + cursor: string | undefined + + constructor( + public rootStore: RootStoreModel, + public params: GetAuthorFeed.QueryParams, + ) {} + + reset() { + this.cursor = undefined + } + + async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> { + const res = await this.rootStore.agent.getAuthorFeed({ + ...this.params, + limit: 1, + }) + return res.data.feed[0] + } + + async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> { + const res = await this.rootStore.agent.getAuthorFeed({ + ...this.params, + cursor: this.cursor, + limit, + }) + if (res.success) { + this.cursor = res.data.cursor + return { + cursor: res.data.cursor, + feed: res.data.feed, + } + } + return { + feed: [], + } + } +} diff --git a/src/lib/api/feed/custom.ts b/src/lib/api/feed/custom.ts new file mode 100644 index 000000000..d05d5acd6 --- /dev/null +++ b/src/lib/api/feed/custom.ts @@ -0,0 +1,52 @@ +import { + AppBskyFeedDefs, + AppBskyFeedGetFeed as GetCustomFeed, +} from '@atproto/api' +import {RootStoreModel} from 'state/index' +import {FeedAPI, FeedAPIResponse} from './types' + +export class CustomFeedAPI implements FeedAPI { + cursor: string | undefined + + constructor( + public rootStore: RootStoreModel, + public params: GetCustomFeed.QueryParams, + ) {} + + reset() { + this.cursor = undefined + } + + async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> { + const res = await this.rootStore.agent.app.bsky.feed.getFeed({ + ...this.params, + limit: 1, + }) + return res.data.feed[0] + } + + async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> { + const res = await this.rootStore.agent.app.bsky.feed.getFeed({ + ...this.params, + cursor: this.cursor, + limit, + }) + if (res.success) { + this.cursor = res.data.cursor + // NOTE + // some custom feeds fail to enforce the pagination limit + // so we manually truncate here + // -prf + if (res.data.feed.length > limit) { + res.data.feed = res.data.feed.slice(0, limit) + } + return { + cursor: res.data.cursor, + feed: res.data.feed, + } + } + return { + feed: [], + } + } +} diff --git a/src/lib/api/feed/following.ts b/src/lib/api/feed/following.ts new file mode 100644 index 000000000..f14807a57 --- /dev/null +++ b/src/lib/api/feed/following.ts @@ -0,0 +1,37 @@ +import {AppBskyFeedDefs} from '@atproto/api' +import {RootStoreModel} from 'state/index' +import {FeedAPI, FeedAPIResponse} from './types' + +export class FollowingFeedAPI implements FeedAPI { + cursor: string | undefined + + constructor(public rootStore: RootStoreModel) {} + + reset() { + this.cursor = undefined + } + + async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> { + const res = await this.rootStore.agent.getTimeline({ + limit: 1, + }) + return res.data.feed[0] + } + + async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> { + const res = await this.rootStore.agent.getTimeline({ + cursor: this.cursor, + limit, + }) + if (res.success) { + this.cursor = res.data.cursor + return { + cursor: res.data.cursor, + feed: res.data.feed, + } + } + return { + feed: [], + } + } +} diff --git a/src/lib/api/feed/likes.ts b/src/lib/api/feed/likes.ts new file mode 100644 index 000000000..e9bb14b0b --- /dev/null +++ b/src/lib/api/feed/likes.ts @@ -0,0 +1,45 @@ +import { + AppBskyFeedDefs, + AppBskyFeedGetActorLikes as GetActorLikes, +} from '@atproto/api' +import {RootStoreModel} from 'state/index' +import {FeedAPI, FeedAPIResponse} from './types' + +export class LikesFeedAPI implements FeedAPI { + cursor: string | undefined + + constructor( + public rootStore: RootStoreModel, + public params: GetActorLikes.QueryParams, + ) {} + + reset() { + this.cursor = undefined + } + + async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> { + const res = await this.rootStore.agent.getActorLikes({ + ...this.params, + limit: 1, + }) + return res.data.feed[0] + } + + async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> { + const res = await this.rootStore.agent.getActorLikes({ + ...this.params, + cursor: this.cursor, + limit, + }) + if (res.success) { + this.cursor = res.data.cursor + return { + cursor: res.data.cursor, + feed: res.data.feed, + } + } + return { + feed: [], + } + } +} diff --git a/src/lib/api/feed/merge.ts b/src/lib/api/feed/merge.ts new file mode 100644 index 000000000..51a619589 --- /dev/null +++ b/src/lib/api/feed/merge.ts @@ -0,0 +1,236 @@ +import {AppBskyFeedDefs, AppBskyFeedGetTimeline} from '@atproto/api' +import shuffle from 'lodash.shuffle' +import {RootStoreModel} from 'state/index' +import {timeout} from 'lib/async/timeout' +import {bundleAsync} from 'lib/async/bundle' +import {feedUriToHref} from 'lib/strings/url-helpers' +import {FeedAPI, FeedAPIResponse, FeedSourceInfo} from './types' + +const REQUEST_WAIT_MS = 500 // 500ms +const POST_AGE_CUTOFF = 60e3 * 60 * 24 // 24hours + +export class MergeFeedAPI implements FeedAPI { + following: MergeFeedSource_Following + customFeeds: MergeFeedSource_Custom[] = [] + feedCursor = 0 + itemCursor = 0 + sampleCursor = 0 + + constructor(public rootStore: RootStoreModel) { + this.following = new MergeFeedSource_Following(this.rootStore) + } + + reset() { + this.following = new MergeFeedSource_Following(this.rootStore) + this.customFeeds = [] // just empty the array, they will be captured in _fetchNext() + this.feedCursor = 0 + this.itemCursor = 0 + this.sampleCursor = 0 + } + + async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> { + const res = await this.rootStore.agent.getTimeline({ + limit: 1, + }) + return res.data.feed[0] + } + + async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> { + // we capture here to ensure the data has loaded + this._captureFeedsIfNeeded() + + const promises = [] + + // always keep following topped up + if (this.following.numReady < limit) { + promises.push(this.following.fetchNext(30)) + } + + // pick the next feeds to sample from + const feeds = this.customFeeds.slice(this.feedCursor, this.feedCursor + 3) + this.feedCursor += 3 + if (this.feedCursor > this.customFeeds.length) { + this.feedCursor = 0 + } + + // top up the feeds + for (const feed of feeds) { + if (feed.numReady < 5) { + promises.push(feed.fetchNext(10)) + } + } + + // wait for requests (all capped at a fixed timeout) + await Promise.all(promises) + + // assemble a response by sampling from feeds with content + const posts: AppBskyFeedDefs.FeedViewPost[] = [] + while (posts.length < limit) { + let slice = this.sampleItem() + if (slice[0]) { + posts.push(slice[0]) + } else { + break + } + } + + return { + cursor: posts.length ? 'fake' : undefined, + feed: posts, + } + } + + sampleItem() { + const i = this.itemCursor++ + const candidateFeeds = this.customFeeds.filter(f => f.numReady > 0) + const canSample = candidateFeeds.length > 0 + const hasFollows = this.following.numReady > 0 + + // this condition establishes the frequency that custom feeds are woven into follows + const shouldSample = + i >= 15 && candidateFeeds.length >= 2 && (i % 4 === 0 || i % 5 === 0) + + if (!canSample && !hasFollows) { + // no data available + return [] + } + if (shouldSample || !hasFollows) { + // time to sample, or the user isnt following anybody + return candidateFeeds[this.sampleCursor++ % candidateFeeds.length].take(1) + } + // not time to sample + return this.following.take(1) + } + + _captureFeedsIfNeeded() { + if (!this.rootStore.preferences.homeFeedMergeFeedEnabled) { + return + } + if (this.customFeeds.length === 0) { + this.customFeeds = shuffle( + this.rootStore.me.savedFeeds.all.map( + feed => + new MergeFeedSource_Custom( + this.rootStore, + feed.uri, + feed.displayName, + ), + ), + ) + } + } +} + +class MergeFeedSource { + sourceInfo: FeedSourceInfo | undefined + cursor: string | undefined = undefined + queue: AppBskyFeedDefs.FeedViewPost[] = [] + hasMore = true + + constructor(public rootStore: RootStoreModel) {} + + get numReady() { + return this.queue.length + } + + get needsFetch() { + return this.hasMore && this.queue.length === 0 + } + + reset() { + this.cursor = undefined + this.queue = [] + this.hasMore = true + } + + take(n: number): AppBskyFeedDefs.FeedViewPost[] { + return this.queue.splice(0, n) + } + + async fetchNext(n: number) { + await Promise.race([this._fetchNextInner(n), timeout(REQUEST_WAIT_MS)]) + } + + _fetchNextInner = bundleAsync(async (n: number) => { + const res = await this._getFeed(this.cursor, n) + if (res.success) { + this.cursor = res.data.cursor + if (res.data.feed.length) { + this.queue = this.queue.concat(res.data.feed) + } else { + this.hasMore = false + } + } else { + this.hasMore = false + } + }) + + protected _getFeed( + _cursor: string | undefined, + _limit: number, + ): Promise<AppBskyFeedGetTimeline.Response> { + throw new Error('Must be overridden') + } +} + +class MergeFeedSource_Following extends MergeFeedSource { + async fetchNext(n: number) { + return this._fetchNextInner(n) + } + + protected async _getFeed( + cursor: string | undefined, + limit: number, + ): Promise<AppBskyFeedGetTimeline.Response> { + const res = await this.rootStore.agent.getTimeline({cursor, limit}) + // filter out mutes pre-emptively to ensure better mixing + res.data.feed = res.data.feed.filter( + post => !post.post.author.viewer?.muted, + ) + return res + } +} + +class MergeFeedSource_Custom extends MergeFeedSource { + minDate: Date + + constructor( + public rootStore: RootStoreModel, + public feedUri: string, + public feedDisplayName: string, + ) { + super(rootStore) + this.sourceInfo = { + displayName: feedDisplayName, + uri: feedUriToHref(feedUri), + } + this.minDate = new Date(Date.now() - POST_AGE_CUTOFF) + } + + protected async _getFeed( + cursor: string | undefined, + limit: number, + ): Promise<AppBskyFeedGetTimeline.Response> { + const res = await this.rootStore.agent.app.bsky.feed.getFeed({ + cursor, + limit, + feed: this.feedUri, + }) + // NOTE + // some custom feeds fail to enforce the pagination limit + // so we manually truncate here + // -prf + if (limit && res.data.feed.length > limit) { + res.data.feed = res.data.feed.slice(0, limit) + } + // filter out older posts + res.data.feed = res.data.feed.filter( + post => new Date(post.post.indexedAt) > this.minDate, + ) + // attach source info + for (const post of res.data.feed) { + post.__source = this.sourceInfo + } + return res + } +} diff --git a/src/lib/api/feed/types.ts b/src/lib/api/feed/types.ts new file mode 100644 index 000000000..006344334 --- /dev/null +++ b/src/lib/api/feed/types.ts @@ -0,0 +1,17 @@ +import {AppBskyFeedDefs} from '@atproto/api' + +export interface FeedAPIResponse { + cursor?: string + feed: AppBskyFeedDefs.FeedViewPost[] +} + +export interface FeedAPI { + reset(): void + peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> + fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> +} + +export interface FeedSourceInfo { + uri: string + displayName: string +} diff --git a/src/lib/icons.tsx b/src/lib/icons.tsx index 233f8a473..fef7be2f3 100644 --- a/src/lib/icons.tsx +++ b/src/lib/icons.tsx @@ -1,6 +1,6 @@ import React from 'react' import {StyleProp, TextStyle, ViewStyle} from 'react-native' -import Svg, {Path, Rect, Line, Ellipse, Circle} from 'react-native-svg' +import Svg, {Path, Rect, Line, Ellipse} from 'react-native-svg' export function GridIcon({ style, @@ -884,45 +884,7 @@ export function HandIcon({ ) } -export function SatelliteDishIconSolid({ - style, - size, - strokeWidth = 1.5, -}: { - style?: StyleProp<ViewStyle> - size?: string | number - strokeWidth?: number -}) { - return ( - <Svg - width={size || 24} - height={size || 24} - viewBox="0 0 22 22" - style={style} - fill="none" - stroke="none"> - <Path - d="M16 19.6622C14.5291 20.513 12.8214 21 11 21C5.47715 21 1 16.5229 1 11C1 9.17858 1.48697 7.47088 2.33782 6.00002C3.18867 4.52915 6 7.66219 6 7.66219L14.5 16.1622C14.5 16.1622 17.4709 18.8113 16 19.6622Z" - fill="currentColor" - /> - <Path - d="M8 1.62961C9.04899 1.22255 10.1847 1 11.3704 1C16.6887 1 21 5.47715 21 11C21 12.0452 20.8456 13.053 20.5592 14" - stroke="currentColor" - strokeWidth={strokeWidth} - strokeLinecap="round" - /> - <Path - d="M9 5.38745C9.64553 5.13695 10.3444 5 11.0741 5C14.3469 5 17 7.75517 17 11.1538C17 11.797 16.905 12.4172 16.7287 13" - stroke="currentColor" - strokeWidth={strokeWidth} - strokeLinecap="round" - /> - <Circle cx="10" cy="12" r="2" fill="currentColor" /> - </Svg> - ) -} - -export function SatelliteDishIcon({ +export function HashtagIcon({ style, size, strokeWidth = 1.5, @@ -934,26 +896,16 @@ export function SatelliteDishIcon({ return ( <Svg fill="none" - viewBox="0 0 22 22" - strokeWidth={strokeWidth} stroke="currentColor" + viewBox="0 0 30 30" + strokeWidth={strokeWidth} width={size} height={size} style={style}> - <Path d="M 12.705346,15.777547 C 14.4635,17.5315 14.7526,17.8509 14.9928,18.1812 c 0.2139,0.2943 0.3371,0.5275 0.3889,0.6822 C 14.0859,19.5872 12.5926,20 11,20 6.02944,20 2,15.9706 2,11 2,9.4151 2.40883,7.9285 3.12619,6.63699 3.304,6.69748 3.56745,6.84213 3.89275,7.08309 4.3705644,7.4380098 4.7486794,7.8160923 6.4999995,9.5689376 8.2513197,11.321783 10.947192,14.023595 12.705346,15.777547 Z" /> - <Path - d="M8 1.62961C9.04899 1.22255 10.1847 1 11.3704 1C16.6887 1 21 5.47715 21 11C21 12.0452 20.8456 13.053 20.5592 14" - strokeLinecap="round" - /> - <Path - d="M9 5.38745C9.64553 5.13695 10.3444 5 11.0741 5C14.3469 5 17 7.75517 17 11.1538C17 11.797 16.905 12.4172 16.7287 13" - strokeLinecap="round" - /> - <Path - d="M12 12C12 12.7403 11.5978 13.3866 11 13.7324L8.26756 11C8.61337 10.4022 9.25972 10 10 10C11.1046 10 12 10.8954 12 12Z" - fill="currentColor" - stroke="none" - /> + <Path d="M2 10H28" strokeLinecap="round" /> + <Path d="M2 20H28" strokeLinecap="round" /> + <Path d="M11 3L9 27" strokeLinecap="round" /> + <Path d="M21 3L19 27" strokeLinecap="round" /> </Svg> ) } diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts index 7159bcb51..cc7a468e9 100644 --- a/src/lib/routes/types.ts +++ b/src/lib/routes/types.ts @@ -9,7 +9,6 @@ export type CommonNavigatorParams = { ModerationMuteLists: undefined ModerationMutedAccounts: undefined ModerationBlockedAccounts: undefined - DiscoverFeeds: undefined Settings: undefined Profile: {name: string; hideBackButton?: boolean} ProfileFollowers: {name: string} diff --git a/src/lib/strings/url-helpers.ts b/src/lib/strings/url-helpers.ts index b509aad01..671dc9781 100644 --- a/src/lib/strings/url-helpers.ts +++ b/src/lib/strings/url-helpers.ts @@ -129,6 +129,15 @@ export function listUriToHref(url: string): string { } } +export function feedUriToHref(url: string): string { + try { + const {hostname, rkey} = new AtUri(url) + return `/profile/${hostname}/feed/${rkey}` + } catch { + return '' + } +} + export function getYoutubeVideoId(link: string): string | undefined { let url try { diff --git a/src/routes.ts b/src/routes.ts index 45a8fa572..7c356eb1b 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 95574fb56..000000000 --- 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 16e4eef15..2501cef6f 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 c88249c8f..d4e62533e 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) @@ -231,53 +267,15 @@ export class PostsFeedModel { } /** - * 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 */ async checkForLatest() { 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 6204e0d10..1a81072a2 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 000000000..f9ad06f77 --- /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 64ab4ecba..7232a7b74 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 11951b0ee..8525426bf 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 d457d7136..4ca22282d 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<FlatList<any> | 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={ <RefreshControl @@ -156,6 +159,8 @@ export const Feed = observer(function Feed({ onScroll={onScroll} scrollEventThrottle={100} contentContainerStyle={s.contentContainer} + // @ts-ignore our .web version only -prf + desktopFixedHeight /> ) : null} </View> diff --git a/src/view/com/pager/FeedsTabBar.web.tsx b/src/view/com/pager/FeedsTabBar.web.tsx index 0083e953b..02aa623cc 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 <FeedsTabBarMobile {...props} /> + } else if (isTablet) { + return <FeedsTabBarTablet {...props} /> } else { - return <FeedsTabBarDesktop {...props} /> + 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 5ce2906b3..30a712541 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( </Text> <View style={[pal.view]}> <Link - href="/settings/saved-feeds" + href="/settings/home-feed" hitSlop={HITSLOP_10} accessibilityRole="button" - accessibilityLabel="Edit Saved Feeds" - accessibilityHint="Opens screen to edit Saved Feeds"> - <CogIcon size={21} strokeWidth={2} style={pal.textLight} /> + accessibilityLabel="Home Feed Preferences" + accessibilityHint=""> + <FontAwesomeIcon + icon="sliders" + style={pal.textLight as FontAwesomeIconStyle} + /> </Link> </View> </View> diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx index 7a5a45771..1cc177d17 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 5b5fee0ca..37c7ece47 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({ /> </ContentHider> )} - {needsTranslation && ( - <View style={[pal.borderDark, styles.translateLink]}> - <Link href={translatorUrl} title="Translate"> - <Text type="sm" style={pal.link}> - Translate this post - </Text> - </Link> - </View> - )} <PostCtrls itemUri={itemUri} itemCid={itemCid} diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx index 0855f25bf..d7559e3c4 100644 --- a/src/view/com/post/Post.tsx +++ b/src/view/com/post/Post.tsx @@ -1,4 +1,4 @@ -import React, {useState, useMemo} from 'react' +import React, {useState} from 'react' import { ActivityIndicator, Linking, @@ -28,7 +28,7 @@ import {PreviewableUserAvatar} from '../util/UserAvatar' import {useStores} from 'state/index' import {s, colors} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' -import {getTranslatorLink, isPostInLanguage} from '../../../locale/helpers' +import {getTranslatorLink} from '../../../locale/helpers' import {makeProfileLink} from 'lib/routes/links' export const Post = observer(function PostImpl({ @@ -116,12 +116,6 @@ const PostLoaded = observer(function PostLoadedImpl({ } const translatorUrl = getTranslatorLink(record?.text || '') - const needsTranslation = useMemo( - () => - 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({ /> </ContentHider> ) : null} - {needsTranslation && ( - <View style={[pal.borderDark, styles.translateLink]}> - <Link href={translatorUrl} title="Translate"> - <Text type="sm" style={pal.link}> - Translate this post - </Text> - </Link> - </View> - )} </ContentHider> <PostCtrls itemUri={itemUri} @@ -322,9 +307,6 @@ const styles = StyleSheet.create({ alignItems: 'center', flexWrap: 'wrap', }, - translateLink: { - marginBottom: 12, - }, replyLine: { position: 'absolute', left: 36, diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index bc7b7a7e6..59ab28d72 100644 --- a/src/view/com/posts/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -8,6 +8,7 @@ import { FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' import {PostsFeedItemModel} from 'state/models/feeds/post' +import {FeedSourceInfo} from 'lib/api/feed/types' import {Link, DesktopWebTextLink} from '../util/Link' import {Text} from '../util/text/Text' import {UserInfoText} from '../util/UserInfoText' @@ -26,17 +27,19 @@ import {usePalette} from 'lib/hooks/usePalette' import {useAnalytics} from 'lib/analytics/analytics' import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeHandle} from 'lib/strings/handles' -import {getTranslatorLink, isPostInLanguage} from '../../../locale/helpers' +import {getTranslatorLink} from '../../../locale/helpers' import {makeProfileLink} from 'lib/routes/links' import {isEmbedByEmbedder} from 'lib/embeds' export const FeedItem = observer(function FeedItemImpl({ item, + source, isThreadChild, isThreadLastChild, isThreadParent, }: { item: PostsFeedItemModel + source?: FeedSourceInfo isThreadChild?: boolean isThreadLastChild?: boolean isThreadParent?: boolean @@ -62,12 +65,6 @@ export const FeedItem = observer(function FeedItemImpl({ return urip.hostname }, [record?.reply]) const translatorUrl = getTranslatorLink(record?.text || '') - const needsTranslation = useMemo( - () => - 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({ </View> <View style={{paddingTop: 12}}> - {item.reasonRepost && ( + {source ? ( + <Link + title={sanitizeDisplayName(source.displayName)} + href={source.uri}> + <Text + type="sm-bold" + style={pal.textLight} + lineHeight={1.2} + numberOfLines={1}> + From{' '} + <DesktopWebTextLink + type="sm-bold" + style={pal.textLight} + lineHeight={1.2} + numberOfLines={1} + text={sanitizeDisplayName(source.displayName)} + href={source.uri} + /> + </Text> + </Link> + ) : item.reasonRepost ? ( <Link style={styles.includeReason} href={makeProfileLink(item.reasonRepost.by)} @@ -188,10 +205,10 @@ export const FeedItem = observer(function FeedItemImpl({ )}> <FontAwesomeIcon icon="retweet" - style={[ - styles.includeReasonIcon, - {color: pal.colors.textLight} as FontAwesomeIconStyle, - ]} + style={{ + marginRight: 4, + color: pal.colors.textLight, + }} /> <Text type="sm-bold" @@ -212,7 +229,7 @@ export const FeedItem = observer(function FeedItemImpl({ /> </Text> </Link> - )} + ) : null} </View> </View> @@ -304,15 +321,6 @@ export const FeedItem = observer(function FeedItemImpl({ /> </ContentHider> ) : null} - {needsTranslation && ( - <View style={[pal.borderDark, styles.translateLink]}> - <Link href={translatorUrl} title="Translate"> - <Text type="sm" style={pal.link}> - Translate this post - </Text> - </Link> - </View> - )} </ContentHider> <PostCtrls itemUri={itemUri} @@ -362,12 +370,9 @@ const styles = StyleSheet.create({ includeReason: { flexDirection: 'row', marginTop: 2, - marginBottom: 4, + marginBottom: 2, marginLeft: -20, }, - includeReasonIcon: { - marginRight: 4, - }, layout: { flexDirection: 'row', marginTop: 1, diff --git a/src/view/com/posts/FeedSlice.tsx b/src/view/com/posts/FeedSlice.tsx index 47313ee27..1d26f6cbd 100644 --- a/src/view/com/posts/FeedSlice.tsx +++ b/src/view/com/posts/FeedSlice.tsx @@ -28,6 +28,7 @@ export const FeedSlice = observer(function FeedSliceImpl({ <FeedItem key={slice.items[0]._reactKey} item={slice.items[0]} + source={slice.source} isThreadParent={slice.isThreadParentAt(0)} isThreadChild={slice.isThreadChildAt(0)} /> @@ -55,6 +56,7 @@ export const FeedSlice = observer(function FeedSliceImpl({ <FeedItem key={item._reactKey} item={item} + source={i === 0 ? slice.source : undefined} isThreadParent={slice.isThreadParentAt(i)} isThreadChild={slice.isThreadChildAt(i)} isThreadLastChild={ diff --git a/src/view/com/posts/FollowingEmptyState.tsx b/src/view/com/posts/FollowingEmptyState.tsx index 4491b2526..a73ffb68b 100644 --- a/src/view/com/posts/FollowingEmptyState.tsx +++ b/src/view/com/posts/FollowingEmptyState.tsx @@ -28,7 +28,7 @@ export function FollowingEmptyState() { }, [navigation]) const onPressDiscoverFeeds = React.useCallback(() => { - 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 9c8f4f246..000000000 --- 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<ViewStyle> - scrollElRef?: MutableRefObject<FlatList<any> | 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 ( - <> - <View style={[pal.view, pal.border, styles.headerDesktop]}> - <Text type="2xl-bold" style={pal.text}> - My Feeds - </Text> - <Link href="/settings/saved-feeds"> - <CogIcon strokeWidth={1.5} style={pal.icon} size={28} /> - </Link> - </View> - <DiscoverLink /> - </> - ) - } - return ( - <> - <View style={[styles.header, pal.border]} /> - <DiscoverLink /> - </> - ) - } else if (item.type === 'feed-header') { - return ( - <View style={styles.feedHeader}> - <UserAvatar type="algo" avatar={item.avatar} size={28} /> - <Text type="title-lg" style={[pal.text, styles.feedHeaderTitle]}> - {item.title} - </Text> - </View> - ) - } else if (item.type === 'feed-slice') { - return <FeedSlice slice={item.slice} /> - } else if (item.type === 'feed-loading') { - return <PostFeedLoadingPlaceholder /> - } else if (item.type === 'feed-error') { - return <ErrorMessage message={item.error} /> - } else if (item.type === 'feed-footer') { - return ( - <Link - href={item.uri} - style={[styles.feedFooter, pal.border, pal.view]}> - <Text type="lg" style={pal.link}> - See more from {item.title} - </Text> - <FontAwesomeIcon - icon="angle-right" - size={18} - color={pal.colors.link} - /> - </Link> - ) - } else if (item.type === 'footer') { - return <DiscoverLink /> - } - return null - }, - [pal, isMobile], - ) - - const ListFooter = React.useCallback( - () => - multifeed.isLoading && !isRefreshing ? ( - <View style={styles.loadMore}> - <ActivityIndicator color={pal.colors.text} /> - </View> - ) : ( - <View /> - ), - [multifeed.isLoading, isRefreshing, pal], - ) - - return ( - <View testID={testID} style={style}> - {multifeed.items.length > 0 && ( - <FlatList - testID={testID ? `${testID}-flatlist` : undefined} - ref={scrollElRef} - data={multifeed.items} - keyExtractor={item => item._reactKey} - renderItem={renderItem} - ListFooterComponent={ListFooter} - refreshControl={ - <RefreshControl - refreshing={isRefreshing} - onRefresh={onRefresh} - tintColor={pal.colors.text} - titleColor={pal.colors.text} - progressViewOffset={headerOffset} - /> - } - 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 - /> - )} - </View> - ) -}) - -function DiscoverLink() { - const pal = usePalette('default') - return ( - <Link style={[styles.discoverLink, pal.viewLight]} href="/search/feeds"> - <FontAwesomeIcon icon="search" size={18} color={pal.colors.text} /> - <Text type="xl-medium" style={pal.text}> - Discover new feeds - </Text> - </Link> - ) -} - -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 321b6ab63..d4df2bec4 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<typeof TouchableOpacity> { href?: string title?: string children?: React.ReactNode + hoverStyle?: StyleProp<ViewStyle> 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 ( - <Pressable + <Com testID={testID} style={style} onPress={onPress} @@ -123,7 +126,7 @@ export const Link = observer(function Link({ href={asAnchor ? sanitizeUrl(href) : undefined} {...props}> {children ? children : <Text>{title || 'link'}</Text>} - </Pressable> + </Com> ) }) @@ -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 bf39fd50c..d7ab1be54 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<ViewStyle> +}) { + const pal = usePalette('default') + return ( + <View + style={[ + {paddingHorizontal: 12, paddingVertical: 18, borderTopWidth: 1}, + pal.border, + style, + ]}> + <View style={[pal.view, {flexDirection: 'row', marginBottom: 10}]}> + <LoadingPlaceholder + width={36} + height={36} + style={[styles.avatar, {borderRadius: 6}]} + /> + <View style={[s.flex1]}> + <LoadingPlaceholder width={100} height={8} style={[s.mt5, s.mb10]} /> + <LoadingPlaceholder width={120} height={8} /> + </View> + </View> + <View style={{paddingHorizontal: 5}}> + <LoadingPlaceholder + width={260} + height={8} + style={{marginVertical: 12}} + /> + <LoadingPlaceholder width={120} height={8} /> + </View> + </View> + ) +} + +export function FeedFeedLoadingPlaceholder() { + return ( + <> + <FeedLoadingPlaceholder /> + <FeedLoadingPlaceholder /> + <FeedLoadingPlaceholder /> + <FeedLoadingPlaceholder /> + <FeedLoadingPlaceholder /> + <FeedLoadingPlaceholder /> + <FeedLoadingPlaceholder /> + <FeedLoadingPlaceholder /> + <FeedLoadingPlaceholder /> + <FeedLoadingPlaceholder /> + <FeedLoadingPlaceholder /> + </> + ) +} + 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 000000000..4eff38a31 --- /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<ViewStyle> +}>) { + const pal = usePalette('default') + const store = useStores() + const navigation = useNavigation<NavigationProp>() + 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 ( + <Container style={[styles.header, isMobile && styles.headerMobile, style]}> + {showBackButton ? ( + <TouchableOpacity + testID="viewHeaderDrawerBtn" + onPress={canGoBack ? onPressBack : onPressMenu} + hitSlop={BACK_HITSLOP} + style={canGoBack ? styles.backBtn : styles.backBtnWide} + accessibilityRole="button" + accessibilityLabel={canGoBack ? 'Back' : 'Menu'} + accessibilityHint=""> + {canGoBack ? ( + <FontAwesomeIcon + size={18} + icon="angle-left" + style={[styles.backIcon, pal.text]} + /> + ) : ( + <FontAwesomeIcon + size={18} + icon="bars" + style={[styles.backIcon, pal.textLight]} + /> + )} + </TouchableOpacity> + ) : null} + {children} + </Container> + ) +}) + +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 0f34f75aa..7a42ab4d3 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 000000000..c1eb82bd4 --- /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<ViewStyle> +} +export function SearchInput({ + query, + setIsInputFocused, + onChangeQuery, + onPressCancelSearch, + onSubmitQuery, + style, +}: Props) { + const theme = useTheme() + const pal = usePalette('default') + const textInput = React.useRef<TextInput>(null) + + const onPressCancelSearchInner = React.useCallback(() => { + onPressCancelSearch() + textInput.current?.blur() + }, [onPressCancelSearch, textInput]) + + return ( + <View style={[pal.viewLight, styles.container, style]}> + <MagnifyingGlassIcon style={[pal.icon, styles.icon]} size={21} /> + <TextInput + testID="searchTextInput" + ref={textInput} + placeholder="Search" + placeholderTextColor={pal.colors.textLight} + selectTextOnFocus + returnKeyType="search" + value={query} + style={[pal.text, styles.input]} + keyboardAppearance={theme.colorScheme} + onFocus={() => setIsInputFocused?.(true)} + onBlur={() => setIsInputFocused?.(false)} + onChangeText={onChangeQuery} + onSubmitEditing={onSubmitQuery} + accessibilityRole="search" + accessibilityLabel="Search" + accessibilityHint="" + autoCorrect={false} + autoCapitalize="none" + /> + {query ? ( + <TouchableOpacity + onPress={onPressCancelSearchInner} + accessibilityRole="button" + accessibilityLabel="Clear search query" + accessibilityHint=""> + <FontAwesomeIcon + icon="xmark" + size={16} + style={pal.textLight as FontAwesomeIconStyle} + /> + </TouchableOpacity> + ) : undefined} + </View> + ) +} + +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 ae9cb9361..6b73edd4b 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 ( + <TouchableOpacity + style={[ + styles.loadLatest, + isDesktop && styles.loadLatestDesktop, + isTablet && styles.loadLatestTablet, + pal.borderDark, + pal.view, + isMobile && + !store.shell.minimalShellMode && { + bottom: 60 + clamp(safeAreaInsets.bottom, 15, 30), + }, + ]} + onPress={onPress} + hitSlop={HITSLOP_20} + accessibilityRole="button" + accessibilityLabel={label} + accessibilityHint=""> + <FontAwesomeIcon icon="angle-up" color={pal.colors.text} size={19} /> + {showIndicator && <View style={[styles.indicator, pal.borderDark]} />} + </TouchableOpacity> + ) +}) + +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 83c696f7e..000000000 --- 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 ( - <LoadLatestBtnMobile - onPress={onPress} - label={label} - showIndicator={showIndicator} - /> - ) - } - return ( - <> - {showIndicator && ( - <TouchableOpacity - style={[ - pal.view, - pal.borderDark, - styles.loadLatestCentered, - minimalShellMode && styles.loadLatestCenteredMinimal, - ]} - onPress={onPress} - hitSlop={HITSLOP_20} - accessibilityRole="button" - accessibilityLabel={label} - accessibilityHint=""> - <Text type="md-bold" style={pal.text}> - {label} - </Text> - </TouchableOpacity> - )} - <TouchableOpacity - style={[pal.view, pal.borderDark, styles.loadLatest]} - onPress={onPress} - hitSlop={HITSLOP_20} - accessibilityRole="button" - accessibilityLabel={label} - accessibilityHint=""> - <Text type="md-bold" style={pal.text}> - <FontAwesomeIcon - icon="angle-up" - size={21} - style={[pal.text, styles.icon]} - /> - </Text> - </TouchableOpacity> - </> - ) -} - -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 3e8add5e9..000000000 --- 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 ( - <TouchableOpacity - style={[ - styles.loadLatest, - pal.borderDark, - pal.view, - !store.shell.minimalShellMode && { - bottom: 60 + clamp(safeAreaInsets.bottom, 15, 30), - }, - ]} - onPress={onPress} - hitSlop={HITSLOP_20} - accessibilityRole="button" - accessibilityLabel={label} - accessibilityHint=""> - <FontAwesomeIcon icon="angle-up" color={pal.colors.text} size={19} /> - {showIndicator && <View style={[styles.indicator, pal.borderDark]} />} - </TouchableOpacity> - ) -}) - -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 2e4c08ec7..2fdc34e7b 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 af4d01843..eaa21f292 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<NavigationProp>() + 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,9 +213,40 @@ 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 ? 'Remove from my feeds' @@ -260,232 +294,12 @@ export const CustomFeedScreenInner = observer( }, ] return items - }, [currentFeed?.isSaved, onToggleSaved, onPressReport, onPressShare]) - - const renderHeaderBtns = React.useCallback(() => { - return ( - <View style={styles.headerBtns}> - <Button - type="default-light" - testID="toggleLikeBtn" - accessibilityLabel="Like this feed" - accessibilityHint="" - onPress={onToggleLiked}> - {currentFeed?.isLiked ? ( - <HeartIconSolid size={19} style={styles.liked} /> - ) : ( - <HeartIcon strokeWidth={3} size={19} style={pal.textLight} /> - )} - </Button> - {currentFeed?.isSaved ? ( - <Button - type="default-light" - accessibilityLabel={ - isPinned ? 'Unpin this feed' : 'Pin this feed' - } - accessibilityHint="" - onPress={onTogglePinned}> - <FontAwesomeIcon - icon="thumb-tack" - size={17} - color={isPinned ? colors.blue3 : pal.colors.textLight} - style={styles.top1} - /> - </Button> - ) : undefined} - {!currentFeed?.isSaved ? ( - <Button - type="default-light" - onPress={onToggleSaved} - accessibilityLabel="Add to my feeds" - accessibilityHint="" - style={styles.headerAddBtn}> - <FontAwesomeIcon icon="plus" color={pal.colors.link} size={19} /> - <Text type="xl-medium" style={pal.link}> - Add to My Feeds - </Text> - </Button> - ) : null} - <NativeDropdown testID="feedHeaderDropdownBtn" items={dropdownItems}> - <View - style={{ - paddingLeft: currentFeed?.isSaved ? 12 : 6, - paddingRight: 12, - paddingVertical: 8, - }}> - <FontAwesomeIcon - icon="ellipsis" - size={20} - color={pal.colors.textLight} - /> - </View> - </NativeDropdown> - </View> - ) }, [ - pal, currentFeed?.isSaved, - currentFeed?.isLiked, - isPinned, - onToggleSaved, - onTogglePinned, - onToggleLiked, - dropdownItems, - ]) - - const renderListHeaderComponent = React.useCallback(() => { - return ( - <> - <View style={[styles.header, pal.border]}> - <View style={s.flex1}> - <Text - testID="feedName" - type="title-xl" - style={[pal.text, s.bold]}> - {currentFeed?.displayName} - </Text> - {currentFeed && ( - <Text type="md" style={[pal.textLight]} numberOfLines={1}> - by{' '} - {currentFeed.data.creator.did === store.me.did ? ( - 'you' - ) : ( - <TextLink - text={sanitizeHandle( - currentFeed.data.creator.handle, - '@', - )} - href={makeProfileLink(currentFeed.data.creator)} - style={[pal.textLight]} - /> - )} - </Text> - )} - {isTabletOrDesktop && ( - <View style={[styles.headerBtns, styles.headerBtnsDesktop]}> - <Button - type={currentFeed?.isSaved ? 'default' : 'inverted'} - onPress={onToggleSaved} - accessibilityLabel={ - currentFeed?.isSaved - ? 'Unsave this feed' - : 'Save this feed' - } - accessibilityHint="" - label={ - currentFeed?.isSaved - ? 'Remove from My Feeds' - : 'Add to My Feeds' - } - /> - <Button - type="default" - accessibilityLabel={ - isPinned ? 'Unpin this feed' : 'Pin this feed' - } - accessibilityHint="" - onPress={onTogglePinned}> - <FontAwesomeIcon - icon="thumb-tack" - size={15} - color={isPinned ? colors.blue3 : pal.colors.icon} - style={styles.top2} - /> - </Button> - <Button - type="default" - accessibilityLabel="Like this feed" - accessibilityHint="" - onPress={onToggleLiked}> - {currentFeed?.isLiked ? ( - <HeartIconSolid size={18} style={styles.liked} /> - ) : ( - <HeartIcon strokeWidth={3} size={18} style={pal.icon} /> - )} - </Button> - <Button - type="default" - accessibilityLabel="Share this feed" - accessibilityHint="" - onPress={onPressShare}> - <FontAwesomeIcon - icon="share" - size={18} - color={pal.colors.icon} - /> - </Button> - <Button - type="default" - accessibilityLabel="Report this feed" - accessibilityHint="" - onPress={onPressReport}> - <FontAwesomeIcon - icon="circle-exclamation" - size={18} - color={pal.colors.icon} - /> - </Button> - </View> - )} - </View> - <View> - <UserAvatar - type="algo" - avatar={currentFeed?.data.avatar} - size={64} - /> - </View> - </View> - <View style={styles.headerDetails}> - {currentFeed?.data.description ? ( - <Text style={[pal.text, s.mb10]} numberOfLines={6}> - {currentFeed.data.description} - </Text> - ) : null} - <View style={styles.headerDetailsFooter}> - {currentFeed ? ( - <TextLink - type="md-medium" - style={pal.textLight} - href={`/profile/${handleOrDid}/feed/${rkey}/liked-by`} - text={`Liked by ${currentFeed.data.likeCount} ${pluralize( - currentFeed?.data.likeCount || 0, - 'user', - )}`} - /> - ) : null} - </View> - </View> - <View - style={[ - styles.fakeSelector, - { - paddingHorizontal: isTabletOrDesktop ? 16 : 6, - }, - pal.border, - ]}> - <View - style={[styles.fakeSelectorItem, {borderColor: pal.colors.link}]}> - <Text type="md-medium" style={[pal.text]}> - Feed - </Text> - </View> - </View> - </> - ) - }, [ - 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 ( <View style={s.hContentRegion}> - {!isTabletOrDesktop && ( - <ViewHeader title="" renderButton={currentFeed && renderHeaderBtns} /> - )} + <SimpleViewHeader + showBackButton={isMobile} + style={ + !isMobile && [pal.border, {borderLeftWidth: 1, borderRightWidth: 1}] + }> + <Text type="title-lg" style={styles.headerText} numberOfLines={1}> + {currentFeed ? ( + <TextLink + type="title-lg" + href="/" + style={[pal.text, {fontWeight: 'bold'}]} + text={currentFeed?.displayName || ''} + onPress={() => store.emitScreenSoftReset()} + /> + ) : ( + 'Loading...' + )} + </Text> + {currentFeed ? ( + <> + <Button + type="default-light" + testID="toggleLikeBtn" + accessibilityLabel="Like this feed" + accessibilityHint="" + onPress={onToggleLiked} + style={styles.headerBtn}> + {currentFeed?.isLiked ? ( + <HeartIconSolid size={19} style={styles.liked} /> + ) : ( + <HeartIcon strokeWidth={3} size={19} style={pal.textLight} /> + )} + </Button> + {currentFeed?.isSaved ? ( + <Button + type="default-light" + accessibilityLabel={ + isPinned ? 'Unpin this feed' : 'Pin this feed' + } + accessibilityHint="" + onPress={onTogglePinned} + style={styles.headerBtn}> + <FontAwesomeIcon + icon="thumb-tack" + size={17} + color={isPinned ? colors.blue3 : pal.colors.textLight} + style={styles.top1} + /> + </Button> + ) : ( + <Button + type="inverted" + onPress={onToggleSaved} + accessibilityLabel="Add to my feeds" + accessibilityHint="" + style={styles.headerAddBtn}> + <FontAwesomeIcon + icon="plus" + color={palInverted.colors.text} + size={19} + /> + <Text type="button" style={palInverted.text}> + Add{!isMobile && ' to My Feeds'} + </Text> + </Button> + )} + </> + ) : null} + <NativeDropdown testID="feedHeaderDropdownBtn" items={dropdownItems}> + <View + style={{ + paddingLeft: 12, + paddingRight: isMobile ? 12 : 0, + }}> + <FontAwesomeIcon + icon="ellipsis" + size={20} + color={pal.colors.textLight} + /> + </View> + </NativeDropdown> + </SimpleViewHeader> <Feed scrollElRef={scrollElRef} feed={algoFeed} onScroll={onMainScroll} scrollEventThrottle={100} - ListHeaderComponent={renderListHeaderComponent} renderEmptyState={renderEmptyState} extraData={[uri, isPinned]} style={!isTabletOrDesktop ? {flex: 1} : undefined} /> {isScrolledDown ? ( <LoadLatestBtn - onPress={onScrollToTop} + onPress={onSoftReset} label="Scroll to top" showIndicator={false} /> @@ -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 6aa7a9e31..000000000 --- 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<CommonNavigatorParams, 'DiscoverFeeds'> -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<boolean>(false) - const [query, setQuery] = React.useState<string>('') - 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 ( - <View style={styles.empty}> - <Text type="lg" style={pal.textLight}> - {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!`} - </Text> - </View> - ) - } - - const renderItem = React.useCallback( - ({item}: {item: CustomFeedModel}) => ( - <CustomFeed - key={item.data.uri} - item={item} - showSaveBtn - showDescription - showLikes - /> - ), - [], - ) - - return ( - <CenteredView style={[styles.container, pal.view]}> - <View - style={[isTabletOrDesktop && styles.containerDesktop, pal.border]}> - <ViewHeader title="Discover Feeds" showOnDesktop /> - </View> - <HeaderWithInput - isInputFocused={isInputFocused} - query={query} - setIsInputFocused={setIsInputFocused} - onChangeQuery={onChangeQuery} - onPressClearQuery={onPressClearQuery} - onPressCancelSearch={onPressCancelSearch} - onSubmitQuery={onSubmitQuery} - showMenu={false} - /> - <FlatList - style={[!isTabletOrDesktop && s.flex1]} - data={feeds.feeds} - keyExtractor={item => item.data.uri} - contentContainerStyle={styles.contentContainer} - refreshControl={ - <RefreshControl - refreshing={feeds.isRefreshing} - onRefresh={onRefresh} - tintColor={pal.colors.text} - titleColor={pal.colors.text} - /> - } - renderItem={renderItem} - initialNumToRender={10} - ListEmptyComponent={renderListEmptyComponent} - onEndReached={() => feeds.loadMore()} - extraData={feeds.isLoading} - /> - </CenteredView> - ) - }), -) - -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 97c6e8672..d2c4a6d2d 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<FeedsTabNavigatorParams, 'Feeds'> export const FeedsScreen = withAuthRequired( observer<Props>(function FeedsScreenImpl({}: Props) { const pal = usePalette('default') const store = useStores() - const {isMobile} = useWebMediaQueries() - const flatListRef = React.useRef<FlatList>(null) - const multifeed = React.useMemo<PostsMultiFeedModel>( - () => new PostsMultiFeedModel(store), - [store], + const {isMobile, isTabletOrDesktop} = useWebMediaQueries() + const myFeeds = React.useMemo(() => new MyFeedsUIModel(store), [store]) + const [query, setQuery] = React.useState<string>('') + 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 <FeedFeedLoadingPlaceholder /> + } else if (item.type === 'spinner') { + return ( + <View style={s.p10}> + <ActivityIndicator /> + </View> + ) + } else if (item.type === 'error') { + return <ErrorMessage message={item.error} /> + } else if (item.type === 'saved-feeds-header') { + if (!isMobile) { + return ( + <View + style={[ + pal.view, + styles.header, + pal.border, + { + borderBottomWidth: 1, + }, + ]}> + <Text type="title-lg" style={[pal.text, s.bold]}> + My Feeds + </Text> + <Link href="/settings/saved-feeds"> + <CogIcon strokeWidth={1.5} style={pal.icon} size={28} /> + </Link> + </View> + ) + } + return <View /> + } else if (item.type === 'saved-feed') { + return ( + <SavedFeed + uri={item.feed.uri} + avatar={item.feed.data.avatar} + displayName={item.feed.displayName} + /> + ) + } else if (item.type === 'discover-feeds-header') { + return ( + <> + <View + style={[ + pal.view, + styles.header, + { + marginTop: 16, + paddingLeft: isMobile ? 12 : undefined, + paddingRight: 10, + paddingBottom: isMobile ? 6 : undefined, + }, + ]}> + <Text type="title-lg" style={[pal.text, s.bold]}> + Discover new feeds + </Text> + {!isMobile && ( + <SearchInput + query={query} + onChangeQuery={onChangeQuery} + onPressCancelSearch={onPressCancelSearch} + onSubmitQuery={onSubmitQuery} + style={{flex: 1, maxWidth: 250}} + /> + )} + </View> + {isMobile && ( + <View style={{paddingHorizontal: 8, paddingBottom: 10}}> + <SearchInput + query={query} + onChangeQuery={onChangeQuery} + onPressCancelSearch={onPressCancelSearch} + onSubmitQuery={onSubmitQuery} + /> + </View> + )} + </> + ) + } else if (item.type === 'discover-feed') { + return ( + <CustomFeed + item={item.feed} + showSaveBtn + showDescription + showLikes + /> + ) + } else if (item.type === 'discover-feeds-no-results') { + return ( + <View + style={{ + paddingHorizontal: 16, + paddingTop: 10, + paddingBottom: '150%', + }}> + <Text type="lg" style={pal.textLight}> + No results found for "{query}" + </Text> + </View> + ) + } + return null + }, + [isMobile, pal, query, onChangeQuery, onPressCancelSearch, onSubmitQuery], + ) + return ( <View style={[pal.view, styles.container]}> - <MultiFeed - scrollElRef={flatListRef} - multifeed={multifeed} - onScroll={onMainScroll} - scrollEventThrottle={100} - headerOffset={isMobile ? MOBILE_HEADER_OFFSET : undefined} - /> {isMobile && ( <ViewHeader - title="My Feeds" + title="Feeds" canGoBack={false} - hideOnScroll renderButton={renderHeaderBtn} + showBorder /> )} - {isScrolledDown || loadPromptVisible ? ( - <LoadLatestBtn - onPress={onSoftReset} - label="Load latest posts" - showIndicator={loadPromptVisible} - /> - ) : null} + + <FlatList + style={[!isTabletOrDesktop && s.flex1, styles.list]} + data={myFeeds.items} + keyExtractor={item => item._reactKey} + contentContainerStyle={styles.contentContainer} + refreshControl={ + <RefreshControl + refreshing={myFeeds.isRefreshing} + onRefresh={onRefresh} + tintColor={pal.colors.text} + titleColor={pal.colors.text} + /> + } + renderItem={renderItem} + initialNumToRender={10} + onEndReached={() => myFeeds.loadMore()} + extraData={myFeeds.isLoading} + // @ts-ignore our .web version only -prf + desktopFixedHeight + /> <FAB testID="composeFAB" onPress={onPressCompose} @@ -136,8 +238,76 @@ export const FeedsScreen = withAuthRequired( }), ) +function SavedFeed({ + uri, + avatar, + displayName, +}: { + uri: string + avatar: string | undefined + displayName: string +}) { + const pal = usePalette('default') + const urip = new AtUri(uri) + const href = `/profile/${urip.hostname}/feed/${urip.rkey}` + const {isMobile} = useWebMediaQueries() + return ( + <Link + testID={`saved-feed-${displayName}`} + href={href} + style={[pal.border, styles.savedFeed, isMobile && styles.savedFeedMobile]} + hoverStyle={pal.viewLight} + accessibilityLabel={displayName} + accessibilityHint="" + asAnchor + anchorNoUnderline> + <UserAvatar type="algo" size={28} avatar={avatar} /> + <Text + type={isMobile ? 'lg' : 'lg-medium'} + style={[pal.text, s.flex1]} + numberOfLines={1}> + {displayName} + </Text> + {isMobile && ( + <FontAwesomeIcon + icon="chevron-right" + size={14} + style={pal.textLight as FontAwesomeIconStyle} + /> + )} + </Link> + ) +} + 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 33cc2e110..60cda31db 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<HomeTabNavigatorParams, 'Home'> @@ -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<FlatList>(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 ( + <View + style={[ + pal.view, + { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 18, + paddingVertical: 12, + }, + ]}> + <TextLink + type="title-lg" + href="/" + style={[pal.text, {fontWeight: 'bold'}]} + text={ + <> + {store.session.isSandbox ? 'SANDBOX' : 'Bluesky'}{' '} + {hasNew && ( + <View + style={{ + top: -8, + backgroundColor: colors.blue3, + width: 8, + height: 8, + borderRadius: 4, + }} + /> + )} + </> + } + onPress={() => store.emitScreenSoftReset()} + /> + <TextLink + type="title-lg" + href="/settings/home-feed" + style={{fontWeight: 'bold'}} + text={ + <FontAwesomeIcon + icon="sliders" + style={pal.textLight as FontAwesomeIconStyle} + /> + } + /> + </View> + ) + } + return <></> + }, [isDesktop, pal, store, hasNew]) + return ( <View testID={testID} style={s.h100pct}> <Feed @@ -259,6 +325,7 @@ const FeedPage = observer(function FeedPageImpl({ onScroll={onMainScroll} scrollEventThrottle={100} renderEmptyState={renderEmptyState} + ListHeaderComponent={ListHeaderComponent} headerOffset={headerOffset} /> {(isScrolledDown || hasNew) && ( diff --git a/src/view/screens/Notifications.tsx b/src/view/screens/Notifications.tsx index 3c257fac8..243cc9596 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<FlatList>(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 ( + <View + style={[ + pal.view, + { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 18, + paddingVertical: 12, + }, + ]}> + <TextLink + type="title-lg" + href="/notifications" + style={[pal.text, {fontWeight: 'bold'}]} + text={ + <> + Notifications{' '} + {hasNew && ( + <View + style={{ + top: -8, + backgroundColor: colors.blue3, + width: 8, + height: 8, + borderRadius: 4, + }} + /> + )} + </> + } + onPress={() => store.emitScreenSoftReset()} + /> + </View> + ) + } + return <></> + }, [isDesktop, pal, store, hasNew]) + return ( <View testID="notificationsScreen" style={s.hContentRegion}> <ViewHeader title="Notifications" canGoBack={false} /> @@ -100,6 +148,7 @@ export const NotificationsScreen = withAuthRequired( onPressTryAgain={onPressTryAgain} onScroll={onMainScroll} scrollElRef={scrollElRef} + ListHeaderComponent={ListHeaderComponent} /> {(isScrolledDown || hasNew) && ( <LoadLatestBtn diff --git a/src/view/screens/PreferencesHomeFeed.tsx b/src/view/screens/PreferencesHomeFeed.tsx index 49c13bfa3..81bdfc95e 100644 --- a/src/view/screens/PreferencesHomeFeed.tsx +++ b/src/view/screens/PreferencesHomeFeed.tsx @@ -19,14 +19,7 @@ function RepliesThresholdInput({enabled}: {enabled: boolean}) { const [value, setValue] = useState(store.preferences.homeFeedRepliesThreshold) return ( - <View style={[s.mt10, !enabled && styles.dimmed]}> - <Text type="xs" style={pal.text}> - {value === 0 - ? `Show all replies` - : `Show replies with at least ${value} ${ - value > 1 ? `likes` : `like` - }`} - </Text> + <View style={[!enabled && styles.dimmed]}> <Slider value={value} onValueChange={(v: number | number[]) => { @@ -40,6 +33,13 @@ function RepliesThresholdInput({enabled}: {enabled: boolean}) { disabled={!enabled} thumbTintColor={colors.blue3} /> + <Text type="xs" style={pal.text}> + {value === 0 + ? `Show all replies` + : `Show replies with at least ${value} ${ + value > 1 ? `likes` : `like` + }`} + </Text> </View> ) } @@ -79,8 +79,7 @@ export const PreferencesHomeFeed = observer(function PreferencesHomeFeedImpl({ Show Replies </Text> <Text style={[pal.text, s.pb10]}> - 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. </Text> <ToggleButton type="default-light" @@ -88,7 +87,36 @@ export const PreferencesHomeFeed = observer(function PreferencesHomeFeedImpl({ isSelected={store.preferences.homeFeedRepliesEnabled} onPress={store.preferences.toggleHomeFeedRepliesEnabled} /> - + </View> + <View + style={[ + pal.viewLight, + styles.card, + !store.preferences.homeFeedRepliesEnabled && styles.dimmed, + ]}> + <Text type="title-sm" style={[pal.text, s.pb5]}> + Reply Filters + </Text> + <Text style={[pal.text, s.pb10]}> + Enable this setting to only see replies between people you follow. + </Text> + <ToggleButton + type="default-light" + label="Followed users only" + isSelected={ + store.preferences.homeFeedRepliesByFollowedOnlyEnabled + } + onPress={ + store.preferences.homeFeedRepliesEnabled + ? store.preferences.toggleHomeFeedRepliesByFollowedOnlyEnabled + : undefined + } + style={[s.mb10]} + /> + <Text style={[pal.text]}> + Adjust the number of likes a reply must have to be shown in your + feed. + </Text> <RepliesThresholdInput enabled={store.preferences.homeFeedRepliesEnabled} /> @@ -124,6 +152,22 @@ export const PreferencesHomeFeed = observer(function PreferencesHomeFeedImpl({ onPress={store.preferences.toggleHomeFeedQuotePostsEnabled} /> </View> + + <View style={[pal.viewLight, styles.card]}> + <Text type="title-sm" style={[pal.text, s.pb5]}> + Show Posts from My Feeds (Experimental) + </Text> + <Text style={[pal.text, s.pb10]}> + Set this setting to "Yes" to show samples of your saved feeds in + your following feed. + </Text> + <ToggleButton + type="default-light" + label={store.preferences.homeFeedMergeFeedEnabled ? 'Yes' : 'No'} + isSelected={store.preferences.homeFeedMergeFeedEnabled} + onPress={store.preferences.toggleHomeFeedMergeFeedEnabled} + /> + </View> </View> </ScrollView> diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index 69b5ceee6..241bae1ed 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 d5c02ba63..5253c5bd6 100644 --- a/src/view/screens/SavedFeeds.tsx +++ b/src/view/screens/SavedFeeds.tsx @@ -70,7 +70,7 @@ export const SavedFeeds = withAuthRequired( return ( <> <View style={[styles.footerLinks, pal.border]}> - <Link style={styles.footerLink} href="/search/feeds"> + <Link style={styles.footerLink} href="/feeds"> <FontAwesomeIcon icon="search" size={18} diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx index 8a543fa4c..761f50d0a 100644 --- a/src/view/screens/Settings.tsx +++ b/src/view/screens/Settings.tsx @@ -40,7 +40,7 @@ import {AccountData} from 'state/models/session' import {useAnalytics} from 'lib/analytics/analytics' import {NavigationProp} from 'lib/routes/types' import {pluralize} from 'lib/strings/helpers' -import {HandIcon} from 'lib/icons' +import {HandIcon, HashtagIcon} from 'lib/icons' import {formatCount} from 'view/com/util/numeric/format' import Clipboard from '@react-native-clipboard/clipboard' import {reset as resetNavigation} from '../../Navigation' @@ -423,17 +423,14 @@ export const SettingsScreen = withAuthRequired( <TouchableOpacity testID="savedFeedsBtn" style={[styles.linkCard, pal.view, isSwitching && styles.dimmed]} - accessibilityHint="Saved Feeds" + accessibilityHint="My Saved Feeds" accessibilityLabel="Opens screen with all saved feeds" onPress={onPressSavedFeeds}> <View style={[styles.iconContainer, pal.btn]}> - <FontAwesomeIcon - icon="satellite-dish" - style={pal.text as FontAwesomeIconStyle} - /> + <HashtagIcon style={pal.text} size={18} strokeWidth={3} /> </View> <Text type="lg" style={pal.text}> - Saved Feeds + My Saved Feeds </Text> </TouchableOpacity> <TouchableOpacity diff --git a/src/view/shell/Drawer.tsx b/src/view/shell/Drawer.tsx index 3a4b8947a..174e4a806 100644 --- a/src/view/shell/Drawer.tsx +++ b/src/view/shell/Drawer.tsx @@ -28,8 +28,7 @@ import { MagnifyingGlassIcon2, MagnifyingGlassIcon2Solid, UserIconSolid, - SatelliteDishIcon, - SatelliteDishIconSolid, + HashtagIcon, HandIcon, } from 'lib/icons' import {UserAvatar} from 'view/com/util/UserAvatar' @@ -258,21 +257,21 @@ export const DrawerContent = observer(function DrawerContentImpl() { <MenuItem icon={ isAtFeeds ? ( - <SatelliteDishIconSolid - strokeWidth={1.5} + <HashtagIcon + strokeWidth={3} style={pal.text as FontAwesomeIconStyle} size={24} /> ) : ( - <SatelliteDishIcon - strokeWidth={1.5} + <HashtagIcon + strokeWidth={2} style={pal.text as FontAwesomeIconStyle} size={24} /> ) } - 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 4a34371ea..8ba74da2e 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 ? ( - <SatelliteDishIconSolid - size={25} - style={[styles.ctrlIcon, pal.text, styles.searchIcon]} - strokeWidth={1.8} + <HashtagIcon + size={24} + style={[styles.ctrlIcon, pal.text, styles.feedsIcon]} + strokeWidth={4} /> ) : ( - <SatelliteDishIcon - size={25} - style={[styles.ctrlIcon, pal.text, styles.searchIcon]} - strokeWidth={1.8} + <HashtagIcon + size={24} + style={[styles.ctrlIcon, pal.text, styles.feedsIcon]} + strokeWidth={2.25} /> ) } diff --git a/src/view/shell/bottom-bar/BottomBarStyles.tsx b/src/view/shell/bottom-bar/BottomBarStyles.tsx index f31ab44cf..ae9381440 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 af70d3364..6448eea63 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() { </NavItem> <NavItem routeName="Feeds" href="/feeds"> {({isActive}) => { - const Icon = isActive ? SatelliteDishIconSolid : SatelliteDishIcon return ( - <Icon - size={25} - style={[styles.ctrlIcon, pal.text, styles.searchIcon]} - strokeWidth={1.8} + <HashtagIcon + size={22} + style={[styles.ctrlIcon, pal.text, styles.feedsIcon]} + strokeWidth={isActive ? 4 : 2.5} /> ) }} diff --git a/src/view/shell/desktop/Feeds.tsx b/src/view/shell/desktop/Feeds.tsx new file mode 100644 index 000000000..4da1401c3 --- /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 ( + <View style={[styles.container, pal.view, pal.border]}> + <FeedItem href="/" title="Following" current={route.name === 'Home'} /> + {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<string, string> + return ( + <FeedItem + key={feed.uri} + href={href} + title={feed.displayName} + current={ + route.name === 'CustomFeed' && + params.name === hostname && + params.rkey === rkey + } + /> + ) + } catch { + return null + } + })} + <View style={{paddingTop: 8, paddingBottom: 6}}> + <TextLink + type="lg" + href="/feeds" + text="More feeds" + style={[pal.link]} + /> + </View> + </View> + ) +}) + +function FeedItem({ + title, + href, + current, +}: { + title: string + href: string + current: boolean +}) { + const pal = usePalette('default') + return ( + <View style={{paddingVertical: 6}}> + <TextLink + type="xl" + href={href} + text={title} + style={[ + current ? pal.text : pal.textLight, + {letterSpacing: 0.15, fontWeight: current ? '500' : 'normal'}, + ]} + /> + </View> + ) +} + +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 8c1a33245..907df8641 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() { <NavItem href="/feeds" icon={ - <SatelliteDishIcon - strokeWidth={1.75} + <HashtagIcon + strokeWidth={2.25} style={pal.text as FontAwesomeIconStyle} size={isDesktop ? 24 : 28} /> } iconFilled={ - <SatelliteDishIconSolid - strokeWidth={1.75} + <HashtagIcon + strokeWidth={2.5} style={pal.text as FontAwesomeIconStyle} size={isDesktop ? 24 : 28} /> } - label="My Feeds" + label="Feeds" /> <NavItem href="/notifications" diff --git a/src/view/shell/desktop/RightNav.tsx b/src/view/shell/desktop/RightNav.tsx index e17fa6a84..12ca256d2 100644 --- a/src/view/shell/desktop/RightNav.tsx +++ b/src/view/shell/desktop/RightNav.tsx @@ -4,6 +4,7 @@ import {StyleSheet, TouchableOpacity, View} from 'react-native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {usePalette} from 'lib/hooks/usePalette' import {DesktopSearch} from './Search' +import {DesktopFeeds} from './Feeds' import {Text} from 'view/com/util/text/Text' import {TextLink} from 'view/com/util/Link' import {FEEDBACK_FORM_URL, HELP_DESK_URL} from 'lib/constants' @@ -26,6 +27,7 @@ export const DesktopRightNav = observer(function DesktopRightNavImpl() { return ( <View style={[styles.rightNav, pal.view]}> {store.session.hasSession && <DesktopSearch />} + {store.session.hasSession && <DesktopFeeds />} <View style={styles.message}> {store.session.isSandbox ? ( <View style={[palError.view, styles.messageLine, s.p10]}> @@ -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 c7b322b58..dfd4f50bf 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 41b423366..3ee7d4c0d 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" |