From 678f75b4951d891bbc2651220fe99a2f040af88f Mon Sep 17 00:00:00 2001 From: Ansh Nanda Date: Fri, 12 May 2023 13:51:07 -0700 Subject: add window dimensions to global styles --- src/lib/styles.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/lib/styles.ts b/src/lib/styles.ts index 00a8638f9..07315c9f2 100644 --- a/src/lib/styles.ts +++ b/src/lib/styles.ts @@ -1,4 +1,4 @@ -import {StyleProp, StyleSheet, TextStyle} from 'react-native' +import {Dimensions, StyleProp, StyleSheet, TextStyle} from 'react-native' import {Theme, TypographyVariant} from './ThemeContext' import {isMobileWeb} from 'platform/detection' @@ -169,6 +169,10 @@ export const s = StyleSheet.create({ w100pct: {width: '100%'}, h100pct: {height: '100%'}, hContentRegion: isMobileWeb ? {flex: 1} : {height: '100%'}, + window: { + width: Dimensions.get('window').width, + height: Dimensions.get('window').height, + }, // text align textLeft: {textAlign: 'left'}, -- cgit 1.4.1 From 370d52bd1f4ba3b5effb7a48cd1c8b14aea88781 Mon Sep 17 00:00:00 2001 From: Ansh Nanda Date: Fri, 12 May 2023 14:40:58 -0700 Subject: add custom algorithm screen to settings under moderation --- src/Navigation.tsx | 2 ++ src/lib/routes/types.ts | 1 + src/routes.ts | 1 + src/view/screens/CustomAlgorithms.tsx | 27 +++++++++++++++++++++++++++ src/view/screens/Settings.tsx | 16 ++++++++++++++++ 5 files changed, 47 insertions(+) create mode 100644 src/view/screens/CustomAlgorithms.tsx (limited to 'src') diff --git a/src/Navigation.tsx b/src/Navigation.tsx index afc7b39b8..8b6e1b453 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -52,6 +52,7 @@ import {AppPasswords} from 'view/screens/AppPasswords' import {MutedAccounts} from 'view/screens/MutedAccounts' import {BlockedAccounts} from 'view/screens/BlockedAccounts' import {getRoutingInstrumentation} from 'lib/sentry' +import CustomAlgorithms from 'view/screens/CustomAlgorithms' const navigationRef = createNavigationContainerRef() @@ -91,6 +92,7 @@ function commonScreens(Stack: typeof HomeTab) { /> + diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts index 34e6e6a46..b91495640 100644 --- a/src/lib/routes/types.ts +++ b/src/lib/routes/types.ts @@ -20,6 +20,7 @@ export type CommonNavigatorParams = { CommunityGuidelines: undefined CopyrightPolicy: undefined AppPasswords: undefined + CustomAlgorithms: undefined MutedAccounts: undefined BlockedAccounts: undefined } diff --git a/src/routes.ts b/src/routes.ts index 43d31ee09..c1b441984 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -14,6 +14,7 @@ export const router = new Router({ Debug: '/sys/debug', Log: '/sys/log', AppPasswords: '/settings/app-passwords', + CustomAlgorithms: '/settings/custom-algorithms', MutedAccounts: '/settings/muted-accounts', BlockedAccounts: '/settings/blocked-accounts', Support: '/support', diff --git a/src/view/screens/CustomAlgorithms.tsx b/src/view/screens/CustomAlgorithms.tsx new file mode 100644 index 000000000..3e2fa7e73 --- /dev/null +++ b/src/view/screens/CustomAlgorithms.tsx @@ -0,0 +1,27 @@ +import {NativeStackScreenProps} from '@react-navigation/native-stack' +import {usePalette} from 'lib/hooks/usePalette' +import {CommonNavigatorParams} from 'lib/routes/types' +import {observer} from 'mobx-react-lite' +import React from 'react' +import {StyleSheet, View} from 'react-native' +import {withAuthRequired} from 'view/com/auth/withAuthRequired' +import {ViewHeader} from 'view/com/util/ViewHeader' +import {Text} from 'view/com/util/text/Text' + +type Props = NativeStackScreenProps + +const CustomAlgorithms = withAuthRequired( + observer((props: Props) => { + const pal = usePalette('default') + return ( + + + CustomAlgorithms + + ) + }), +) + +export default CustomAlgorithms + +const styles = StyleSheet.create({}) diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx index f98cdc0c8..fd8fb4f4a 100644 --- a/src/view/screens/Settings.tsx +++ b/src/view/screens/Settings.tsx @@ -290,6 +290,22 @@ export const SettingsScreen = withAuthRequired( Moderation + + + + + + Custom Algorithms + + Date: Fri, 12 May 2023 17:21:11 -0700 Subject: bookmarked feeds model --- src/state/models/feeds/bookmarked.ts | 134 +++++++++++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 src/state/models/feeds/bookmarked.ts (limited to 'src') diff --git a/src/state/models/feeds/bookmarked.ts b/src/state/models/feeds/bookmarked.ts new file mode 100644 index 000000000..d472f0480 --- /dev/null +++ b/src/state/models/feeds/bookmarked.ts @@ -0,0 +1,134 @@ +import {makeAutoObservable} from 'mobx' +import { + AppBskyFeedGetBookmarkedFeeds as GetBookmarkedFeeds, + // AppBskyFeedBookmarkFeed as bookmarkedFeed, + // AppBskyFeedUnbookmarkFeed as unbookmarkFeed, + AppBskyFeedDefs as FeedDefs, +} from '@atproto/api' +import {RootStoreModel} from '../root-store' +import {bundleAsync} from 'lib/async/bundle' +import {cleanError} from 'lib/strings/errors' + +const PAGE_SIZE = 30 + +export class BookmarkedFeedsModel { + // state + isLoading = false + isRefreshing = false + hasLoaded = false + error = '' + hasMore = true + loadMoreCursor?: string + + // data + feeds: FeedDefs.GeneratorView[] = [] + + constructor(public rootStore: RootStoreModel) { + makeAutoObservable( + this, + { + rootStore: false, + }, + {autoBind: true}, + ) + } + + get hasContent() { + return this.feeds.length > 0 + } + + get hasError() { + return this.error !== '' + } + + get isEmpty() { + return this.hasLoaded && !this.hasContent + } + + // public api + // = + + async refresh() { + return this.loadMore(true) + } + + clear() { + this.isLoading = false + this.isRefreshing = false + this.hasLoaded = false + this.error = '' + this.hasMore = true + this.loadMoreCursor = undefined + this.feeds = [] + } + + loadMore = bundleAsync(async (replace: boolean = false) => { + if (!replace && !this.hasMore) { + return + } + this._xLoading(replace) + try { + const res = await this.rootStore.agent.app.bsky.feed.getBookmarkedFeeds({ + limit: PAGE_SIZE, + cursor: replace ? undefined : this.loadMoreCursor, + }) + if (replace) { + this._replaceAll(res) + } else { + this._appendAll(res) + } + this._xIdle() + } catch (e: any) { + this._xIdle(e) + } + }) + + async bookmark(feed: FeedDefs.GeneratorView) { + try { + await this.rootStore.agent.app.bsky.feed.bookmarkFeed({feed: feed.uri}) + } catch (e: any) { + this.rootStore.log.error('Failed to bookmark feed', e) + } + } + + async unbookmark(feed: FeedDefs.GeneratorView) { + try { + await this.rootStore.agent.app.bsky.feed.unbookmarkFeed({feed: feed.uri}) + } catch (e: any) { + this.rootStore.log.error('Failed to unbookmark feed', e) + } + } + + // state transitions + // = + + _xLoading(isRefreshing = false) { + this.isLoading = true + this.isRefreshing = isRefreshing + this.error = '' + } + + _xIdle(err?: any) { + this.isLoading = false + this.isRefreshing = false + this.hasLoaded = true + this.error = cleanError(err) + if (err) { + this.rootStore.log.error('Failed to fetch user followers', err) + } + } + + // helper functions + // = + + _replaceAll(res: GetBookmarkedFeeds.Response) { + this.feeds = [] + this._appendAll(res) + } + + _appendAll(res: GetBookmarkedFeeds.Response) { + this.loadMoreCursor = res.data.cursor + this.hasMore = !!this.loadMoreCursor + this.feeds = this.feeds.concat(res.data.feeds) + } +} -- cgit 1.4.1 From 06ce42158ea31a857393ba1b9b00877f501398c4 Mon Sep 17 00:00:00 2001 From: Ansh Nanda Date: Fri, 12 May 2023 17:21:17 -0700 Subject: actor feeds model --- src/state/models/feeds/actor.ts | 135 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 src/state/models/feeds/actor.ts (limited to 'src') diff --git a/src/state/models/feeds/actor.ts b/src/state/models/feeds/actor.ts new file mode 100644 index 000000000..85b55f383 --- /dev/null +++ b/src/state/models/feeds/actor.ts @@ -0,0 +1,135 @@ +import {makeAutoObservable} from 'mobx' +import { + AppBskyFeedGetBookmarkedFeeds as GetBookmarkedFeeds, + // AppBskyFeedBookmarkFeed as bookmarkedFeed, + // AppBskyFeedUnbookmarkFeed as unbookmarkFeed, + AppBskyFeedDefs as FeedDefs, +} from '@atproto/api' +import {RootStoreModel} from '../root-store' +import {bundleAsync} from 'lib/async/bundle' +import {cleanError} from 'lib/strings/errors' + +const PAGE_SIZE = 30 + +export class BookmarkedFeedsModel { + // state + isLoading = false + isRefreshing = false + hasLoaded = false + error = '' + hasMore = true + loadMoreCursor?: string + + // data + feeds: FeedDefs.GeneratorView[] = [] + + constructor(public rootStore: RootStoreModel) { + makeAutoObservable( + this, + { + rootStore: false, + }, + {autoBind: true}, + ) + } + + get hasContent() { + return this.feeds.length > 0 + } + + get hasError() { + return this.error !== '' + } + + get isEmpty() { + return this.hasLoaded && !this.hasContent + } + + // public api + // = + + async refresh() { + return this.loadMore(true) + } + + clear() { + this.isLoading = false + this.isRefreshing = false + this.hasLoaded = false + this.error = '' + this.hasMore = true + this.loadMoreCursor = undefined + this.feeds = [] + } + + loadMore = bundleAsync(async (replace: boolean = false) => { + if (!replace && !this.hasMore) { + return + } + this._xLoading(replace) + try { + const res = await this.rootStore.agent.app.bsky.feed.getActorFeeds({ + actor: 'did:plc:dpny6d4qwwxu5b6dp3qob5ok', // TODO: take this as input param + limit: PAGE_SIZE, + cursor: replace ? undefined : this.loadMoreCursor, + }) + if (replace) { + this._replaceAll(res) + } else { + this._appendAll(res) + } + this._xIdle() + } catch (e: any) { + this._xIdle(e) + } + }) + + async bookmark(feed: FeedDefs.GeneratorView) { + try { + await this.rootStore.agent.app.bsky.feed.bookmarkFeed({feed: feed.uri}) + } catch (e: any) { + this.rootStore.log.error('Failed to bookmark feed', e) + } + } + + async unbookmark(feed: FeedDefs.GeneratorView) { + try { + await this.rootStore.agent.app.bsky.feed.unbookmarkFeed({feed: feed.uri}) + } catch (e: any) { + this.rootStore.log.error('Failed to unbookmark feed', e) + } + } + + // state transitions + // = + + _xLoading(isRefreshing = false) { + this.isLoading = true + this.isRefreshing = isRefreshing + this.error = '' + } + + _xIdle(err?: any) { + this.isLoading = false + this.isRefreshing = false + this.hasLoaded = true + this.error = cleanError(err) + if (err) { + this.rootStore.log.error('Failed to fetch user followers', err) + } + } + + // helper functions + // = + + _replaceAll(res: GetBookmarkedFeeds.Response) { + this.feeds = [] + this._appendAll(res) + } + + _appendAll(res: GetBookmarkedFeeds.Response) { + this.loadMoreCursor = res.data.cursor + this.hasMore = !!this.loadMoreCursor + this.feeds = this.feeds.concat(res.data.feeds) + } +} -- cgit 1.4.1 From fa4af20764b0596420c1e2a8dd981dc34020e205 Mon Sep 17 00:00:00 2001 From: Ansh Nanda Date: Fri, 12 May 2023 17:22:53 -0700 Subject: remove unncessary code from actorFeedModel --- src/state/models/feeds/actor.ts | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) (limited to 'src') diff --git a/src/state/models/feeds/actor.ts b/src/state/models/feeds/actor.ts index 85b55f383..e660d4eb8 100644 --- a/src/state/models/feeds/actor.ts +++ b/src/state/models/feeds/actor.ts @@ -11,7 +11,7 @@ import {cleanError} from 'lib/strings/errors' const PAGE_SIZE = 30 -export class BookmarkedFeedsModel { +export class ActorFeedsModel { // state isLoading = false isRefreshing = false @@ -84,22 +84,6 @@ export class BookmarkedFeedsModel { } }) - async bookmark(feed: FeedDefs.GeneratorView) { - try { - await this.rootStore.agent.app.bsky.feed.bookmarkFeed({feed: feed.uri}) - } catch (e: any) { - this.rootStore.log.error('Failed to bookmark feed', e) - } - } - - async unbookmark(feed: FeedDefs.GeneratorView) { - try { - await this.rootStore.agent.app.bsky.feed.unbookmarkFeed({feed: feed.uri}) - } catch (e: any) { - this.rootStore.log.error('Failed to unbookmark feed', e) - } - } - // state transitions // = -- cgit 1.4.1 From 760b5309e09251023682d8a93ed331ff921817ea Mon Sep 17 00:00:00 2001 From: Ansh Nanda Date: Fri, 12 May 2023 19:32:39 -0700 Subject: show algos by user on profile --- src/state/models/feeds/actor.ts | 16 +++++++----- src/state/models/ui/profile.ts | 30 +++++++++++++++++++--- src/view/com/algos/AlgoItem.tsx | 57 +++++++++++++++++++++++++++++++++++++++++ src/view/screens/Profile.tsx | 13 +++++----- 4 files changed, 99 insertions(+), 17 deletions(-) create mode 100644 src/view/com/algos/AlgoItem.tsx (limited to 'src') diff --git a/src/state/models/feeds/actor.ts b/src/state/models/feeds/actor.ts index e660d4eb8..08b7c2a74 100644 --- a/src/state/models/feeds/actor.ts +++ b/src/state/models/feeds/actor.ts @@ -1,9 +1,7 @@ import {makeAutoObservable} from 'mobx' import { - AppBskyFeedGetBookmarkedFeeds as GetBookmarkedFeeds, - // AppBskyFeedBookmarkFeed as bookmarkedFeed, - // AppBskyFeedUnbookmarkFeed as unbookmarkFeed, AppBskyFeedDefs as FeedDefs, + AppBskyFeedGetActorFeeds as GetActorFeeds, } from '@atproto/api' import {RootStoreModel} from '../root-store' import {bundleAsync} from 'lib/async/bundle' @@ -23,7 +21,10 @@ export class ActorFeedsModel { // data feeds: FeedDefs.GeneratorView[] = [] - constructor(public rootStore: RootStoreModel) { + constructor( + public rootStore: RootStoreModel, + public params: GetActorFeeds.QueryParams, + ) { makeAutoObservable( this, { @@ -69,10 +70,11 @@ export class ActorFeedsModel { this._xLoading(replace) try { const res = await this.rootStore.agent.app.bsky.feed.getActorFeeds({ - actor: 'did:plc:dpny6d4qwwxu5b6dp3qob5ok', // TODO: take this as input param + actor: this.params.actor, limit: PAGE_SIZE, cursor: replace ? undefined : this.loadMoreCursor, }) + console.log('res', res.data.feeds) if (replace) { this._replaceAll(res) } else { @@ -106,12 +108,12 @@ export class ActorFeedsModel { // helper functions // = - _replaceAll(res: GetBookmarkedFeeds.Response) { + _replaceAll(res: GetActorFeeds.Response) { this.feeds = [] this._appendAll(res) } - _appendAll(res: GetBookmarkedFeeds.Response) { + _appendAll(res: GetActorFeeds.Response) { this.loadMoreCursor = res.data.cursor this.hasMore = !!this.loadMoreCursor this.feeds = this.feeds.concat(res.data.feeds) diff --git a/src/state/models/ui/profile.ts b/src/state/models/ui/profile.ts index d06a196f3..86199108e 100644 --- a/src/state/models/ui/profile.ts +++ b/src/state/models/ui/profile.ts @@ -2,13 +2,20 @@ import {makeAutoObservable} from 'mobx' import {RootStoreModel} from '../root-store' import {ProfileModel} from '../content/profile' import {PostsFeedModel} from '../feeds/posts' +import {ActorFeedsModel} from '../feeds/actor' +import {AppBskyFeedDefs} from '@atproto/api' export enum Sections { Posts = 'Posts', PostsWithReplies = 'Posts & replies', + CustomAlgorithms = 'Algos', } -const USER_SELECTOR_ITEMS = [Sections.Posts, Sections.PostsWithReplies] +const USER_SELECTOR_ITEMS = [ + Sections.Posts, + Sections.PostsWithReplies, + Sections.CustomAlgorithms, +] export interface ProfileUiParams { user: string @@ -22,6 +29,7 @@ export class ProfileUiModel { // data profile: ProfileModel feed: PostsFeedModel + algos: ActorFeedsModel // ui state selectedViewIndex = 0 @@ -43,15 +51,19 @@ export class ProfileUiModel { actor: params.user, limit: 10, }) + this.algos = new ActorFeedsModel(rootStore, {actor: params.user}) } - get currentView(): PostsFeedModel { + get currentView(): PostsFeedModel | ActorFeedsModel { if ( this.selectedView === Sections.Posts || this.selectedView === Sections.PostsWithReplies ) { return this.feed } + if (this.selectedView === Sections.CustomAlgorithms) { + return this.algos + } throw new Error(`Invalid selector value: ${this.selectedViewIndex}`) } @@ -71,12 +83,17 @@ export class ProfileUiModel { get selectedView() { return this.selectorItems[this.selectedViewIndex] } + isGeneratorView(v: any) { + return AppBskyFeedDefs.isGeneratorView(v) + } get uiItems() { let arr: any[] = [] + // if loading, return loading item to show loading spinner if (this.isInitialLoading) { arr = arr.concat([ProfileUiModel.LOADING_ITEM]) } else if (this.currentView.hasError) { + // if error, return error item to show error message arr = arr.concat([ { _reactKey: '__error__', @@ -84,12 +101,16 @@ export class ProfileUiModel { }, ]) } else { + // not loading, no error, show content if ( this.selectedView === Sections.Posts || - this.selectedView === Sections.PostsWithReplies + this.selectedView === Sections.PostsWithReplies || + this.selectedView === Sections.CustomAlgorithms ) { if (this.feed.hasContent) { - if (this.selectedView === Sections.Posts) { + if (this.selectedView === Sections.CustomAlgorithms) { + arr = this.algos.feeds + } else if (this.selectedView === Sections.Posts) { arr = this.feed.nonReplyFeed } else { arr = this.feed.slices.slice() @@ -101,6 +122,7 @@ export class ProfileUiModel { arr = arr.concat([ProfileUiModel.EMPTY_ITEM]) } } else { + // fallback, add empty item, to show empty message arr = arr.concat([ProfileUiModel.EMPTY_ITEM]) } } diff --git a/src/view/com/algos/AlgoItem.tsx b/src/view/com/algos/AlgoItem.tsx new file mode 100644 index 000000000..57a1428e6 --- /dev/null +++ b/src/view/com/algos/AlgoItem.tsx @@ -0,0 +1,57 @@ +import React from 'react' +import {StyleSheet, View} from 'react-native' +import {Text} from '../util/text/Text' +import {AppBskyFeedDefs} from '@atproto/api' +import {usePalette} from 'lib/hooks/usePalette' +import {s} from 'lib/styles' +import {UserAvatar} from '../util/UserAvatar' + +const AlgoItem = ({item}: {item: AppBskyFeedDefs.GeneratorView}) => { + const pal = usePalette('default') + return ( + + + + + + + + {item.displayName ?? 'Feed name'} + + + {item.description ?? + 'THIS IS A FEED DESCRIPTION, IT WILL TELL YOU WHAT THE FEED IS ABOUT. THIS IS A COOL FEED ABOUT COOL PEOPLE.'} + + + + + {/* TODO: this feed is like by *3* people UserAvatars and others */} + + ) +} + +export default AlgoItem + +const styles = StyleSheet.create({ + container: { + paddingHorizontal: 18, + paddingVertical: 20, + flexDirection: 'column', + columnGap: 36, + flex: 1, + borderTopWidth: 1, + borderTopColor: '#E5E5E5', + }, + headerContainer: { + flexDirection: 'row', + }, + headerTextContainer: { + flexDirection: 'column', + columnGap: 4, + flex: 1, + }, + description: { + flex: 1, + flexWrap: 'wrap', + }, +}) diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index 5fb212554..b88caf1f8 100644 --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -21,6 +21,8 @@ import {FAB} from '../com/util/fab/FAB' import {s, colors} from 'lib/styles' import {useAnalytics} from 'lib/analytics' import {ComposeIcon2} from 'lib/icons' +import {AppBskyFeedDefs} from '@atproto/api' +import AlgoItem from 'view/com/algos/AlgoItem' type Props = NativeStackScreenProps export const ProfileScreen = withAuthRequired( @@ -152,15 +154,14 @@ export const ProfileScreen = withAuthRequired( ) } else if (item instanceof PostsFeedSliceModel) { return + } else if (item.creator) { + // TODO: this is a hack to see if it is a custom feed. fix it to something more robust + const typedItem = item as AppBskyFeedDefs.GeneratorView + return } return }, - [ - onPressTryAgain, - uiState.profile.did, - uiState.feed.isBlocking, - uiState.feed.isBlockedBy, - ], + [onPressTryAgain, uiState], ) return ( -- cgit 1.4.1 From c24389df87cc8a079c447aad6a0e0b025def84f5 Mon Sep 17 00:00:00 2001 From: Ansh Nanda Date: Fri, 12 May 2023 19:46:50 -0700 Subject: custom feed embed --- src/view/com/algos/AlgoItem.tsx | 12 +++++++++--- src/view/com/util/post-embeds/index.tsx | 26 +++++++++++++++++++++++++- 2 files changed, 34 insertions(+), 4 deletions(-) (limited to 'src') diff --git a/src/view/com/algos/AlgoItem.tsx b/src/view/com/algos/AlgoItem.tsx index 57a1428e6..979518f1d 100644 --- a/src/view/com/algos/AlgoItem.tsx +++ b/src/view/com/algos/AlgoItem.tsx @@ -1,15 +1,21 @@ import React from 'react' -import {StyleSheet, View} from 'react-native' +import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' import {Text} from '../util/text/Text' import {AppBskyFeedDefs} from '@atproto/api' import {usePalette} from 'lib/hooks/usePalette' import {s} from 'lib/styles' import {UserAvatar} from '../util/UserAvatar' -const AlgoItem = ({item}: {item: AppBskyFeedDefs.GeneratorView}) => { +const AlgoItem = ({ + item, + style, +}: { + item: AppBskyFeedDefs.GeneratorView + style?: StyleProp +}) => { const pal = usePalette('default') return ( - + diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx index 2dda9069e..72158af42 100644 --- a/src/view/com/util/post-embeds/index.tsx +++ b/src/view/com/util/post-embeds/index.tsx @@ -13,6 +13,7 @@ import { AppBskyEmbedRecord, AppBskyEmbedRecordWithMedia, AppBskyFeedPost, + AppBskyFeedDefs, } from '@atproto/api' import {Link} from '../Link' import {ImageLayoutGrid} from '../images/ImageLayoutGrid' @@ -24,6 +25,7 @@ import {ExternalLinkEmbed} from './ExternalLinkEmbed' import {getYoutubeVideoId} from 'lib/strings/url-helpers' import QuoteEmbed from './QuoteEmbed' import {AutoSizedImage} from '../images/AutoSizedImage' +import AlgoItem from 'view/com/algos/AlgoItem' type Embed = | AppBskyEmbedRecord.View @@ -42,6 +44,8 @@ export function PostEmbeds({ const pal = usePalette('default') const store = useStores() + // quote post with media + // = if ( AppBskyEmbedRecordWithMedia.isView(embed) && AppBskyEmbedRecord.isViewRecord(embed.record.record) && @@ -65,6 +69,8 @@ export function PostEmbeds({ ) } + // quote post + // = if (AppBskyEmbedRecord.isView(embed)) { if ( AppBskyEmbedRecord.isViewRecord(embed.record) && @@ -87,6 +93,8 @@ export function PostEmbeds({ } } + // image embed + // = if (AppBskyEmbedImages.isView(embed)) { const {images} = embed @@ -132,10 +140,11 @@ export function PostEmbeds({ /> ) - // } } } + // external link embed + // = if (AppBskyEmbedExternal.isView(embed)) { const link = embed.external const youtubeVideoId = getYoutubeVideoId(link.uri) @@ -153,6 +162,21 @@ export function PostEmbeds({ ) } + + // custom feed embed (i.e. generator view) + // = + if ( + AppBskyEmbedRecord.isView(embed) && + AppBskyFeedDefs.isGeneratorView(embed.record) + ) { + return ( + + ) + } + return } -- cgit 1.4.1 From 047024a5ac96a5292b6a2122835673b5a034f4c3 Mon Sep 17 00:00:00 2001 From: Ansh Nanda Date: Sat, 13 May 2023 11:59:08 -0700 Subject: create algo-item model and redefine data models --- src/state/models/feeds/actor.ts | 121 ---------------------------- src/state/models/feeds/algo/actor.ts | 121 ++++++++++++++++++++++++++++ src/state/models/feeds/algo/algo-item.ts | 56 +++++++++++++ src/state/models/feeds/algo/saved.ts | 116 ++++++++++++++++++++++++++ src/state/models/feeds/bookmarked.ts | 134 ------------------------------- src/state/models/ui/profile.ts | 2 +- src/view/com/algos/AlgoItem.tsx | 93 ++++++++++++++------- src/view/com/util/post-embeds/index.tsx | 3 +- src/view/screens/Profile.tsx | 8 +- 9 files changed, 363 insertions(+), 291 deletions(-) delete mode 100644 src/state/models/feeds/actor.ts create mode 100644 src/state/models/feeds/algo/actor.ts create mode 100644 src/state/models/feeds/algo/algo-item.ts create mode 100644 src/state/models/feeds/algo/saved.ts delete mode 100644 src/state/models/feeds/bookmarked.ts (limited to 'src') diff --git a/src/state/models/feeds/actor.ts b/src/state/models/feeds/actor.ts deleted file mode 100644 index 08b7c2a74..000000000 --- a/src/state/models/feeds/actor.ts +++ /dev/null @@ -1,121 +0,0 @@ -import {makeAutoObservable} from 'mobx' -import { - AppBskyFeedDefs as FeedDefs, - AppBskyFeedGetActorFeeds as GetActorFeeds, -} from '@atproto/api' -import {RootStoreModel} from '../root-store' -import {bundleAsync} from 'lib/async/bundle' -import {cleanError} from 'lib/strings/errors' - -const PAGE_SIZE = 30 - -export class ActorFeedsModel { - // state - isLoading = false - isRefreshing = false - hasLoaded = false - error = '' - hasMore = true - loadMoreCursor?: string - - // data - feeds: FeedDefs.GeneratorView[] = [] - - constructor( - public rootStore: RootStoreModel, - public params: GetActorFeeds.QueryParams, - ) { - makeAutoObservable( - this, - { - rootStore: false, - }, - {autoBind: true}, - ) - } - - get hasContent() { - return this.feeds.length > 0 - } - - get hasError() { - return this.error !== '' - } - - get isEmpty() { - return this.hasLoaded && !this.hasContent - } - - // public api - // = - - async refresh() { - return this.loadMore(true) - } - - clear() { - this.isLoading = false - this.isRefreshing = false - this.hasLoaded = false - this.error = '' - this.hasMore = true - this.loadMoreCursor = undefined - this.feeds = [] - } - - loadMore = bundleAsync(async (replace: boolean = false) => { - if (!replace && !this.hasMore) { - return - } - this._xLoading(replace) - try { - const res = await this.rootStore.agent.app.bsky.feed.getActorFeeds({ - actor: this.params.actor, - limit: PAGE_SIZE, - cursor: replace ? undefined : this.loadMoreCursor, - }) - console.log('res', res.data.feeds) - if (replace) { - this._replaceAll(res) - } else { - this._appendAll(res) - } - this._xIdle() - } catch (e: any) { - this._xIdle(e) - } - }) - - // state transitions - // = - - _xLoading(isRefreshing = false) { - this.isLoading = true - this.isRefreshing = isRefreshing - this.error = '' - } - - _xIdle(err?: any) { - this.isLoading = false - this.isRefreshing = false - this.hasLoaded = true - this.error = cleanError(err) - if (err) { - this.rootStore.log.error('Failed to fetch user followers', err) - } - } - - // helper functions - // = - - _replaceAll(res: GetActorFeeds.Response) { - this.feeds = [] - this._appendAll(res) - } - - _appendAll(res: GetActorFeeds.Response) { - this.loadMoreCursor = res.data.cursor - this.hasMore = !!this.loadMoreCursor - this.feeds = this.feeds.concat(res.data.feeds) - } -} diff --git a/src/state/models/feeds/algo/actor.ts b/src/state/models/feeds/algo/actor.ts new file mode 100644 index 000000000..e42df8495 --- /dev/null +++ b/src/state/models/feeds/algo/actor.ts @@ -0,0 +1,121 @@ +import {makeAutoObservable} from 'mobx' +import {AppBskyFeedGetActorFeeds as GetActorFeeds} from '@atproto/api' +import {RootStoreModel} from '../../root-store' +import {bundleAsync} from 'lib/async/bundle' +import {cleanError} from 'lib/strings/errors' +import {AlgoItemModel} from './algo-item' + +const PAGE_SIZE = 30 + +export class ActorFeedsModel { + // state + isLoading = false + isRefreshing = false + hasLoaded = false + error = '' + hasMore = true + loadMoreCursor?: string + + // data + feeds: AlgoItemModel[] = [] + + constructor( + public rootStore: RootStoreModel, + public params: GetActorFeeds.QueryParams, + ) { + makeAutoObservable( + this, + { + rootStore: false, + }, + {autoBind: true}, + ) + } + + get hasContent() { + return this.feeds.length > 0 + } + + get hasError() { + return this.error !== '' + } + + get isEmpty() { + return this.hasLoaded && !this.hasContent + } + + // public api + // = + + async refresh() { + return this.loadMore(true) + } + + clear() { + this.isLoading = false + this.isRefreshing = false + this.hasLoaded = false + this.error = '' + this.hasMore = true + this.loadMoreCursor = undefined + this.feeds = [] + } + + loadMore = bundleAsync(async (replace: boolean = false) => { + if (!replace && !this.hasMore) { + return + } + this._xLoading(replace) + try { + const res = await this.rootStore.agent.app.bsky.feed.getActorFeeds({ + actor: this.params.actor, + limit: PAGE_SIZE, + cursor: replace ? undefined : this.loadMoreCursor, + }) + console.log('res', res.data.feeds) + if (replace) { + this._replaceAll(res) + } else { + this._appendAll(res) + } + this._xIdle() + } catch (e: any) { + this._xIdle(e) + } + }) + + // state transitions + // = + + _xLoading(isRefreshing = false) { + this.isLoading = true + this.isRefreshing = isRefreshing + this.error = '' + } + + _xIdle(err?: any) { + this.isLoading = false + this.isRefreshing = false + this.hasLoaded = true + this.error = cleanError(err) + if (err) { + this.rootStore.log.error('Failed to fetch user followers', err) + } + } + + // helper functions + // = + + _replaceAll(res: GetActorFeeds.Response) { + this.feeds = [] + this._appendAll(res) + } + + _appendAll(res: GetActorFeeds.Response) { + this.loadMoreCursor = res.data.cursor + this.hasMore = !!this.loadMoreCursor + for (const f of res.data.feeds) { + this.feeds.push(new AlgoItemModel(this.rootStore, f)) + } + } +} diff --git a/src/state/models/feeds/algo/algo-item.ts b/src/state/models/feeds/algo/algo-item.ts new file mode 100644 index 000000000..555d1d56d --- /dev/null +++ b/src/state/models/feeds/algo/algo-item.ts @@ -0,0 +1,56 @@ +import {AppBskyFeedDefs} from '@atproto/api' +import {makeAutoObservable, makeObservable} from 'mobx' +import {RootStoreModel} from 'state/models/root-store' + +// algoitemmodel implemented in mobx +export class AlgoItemModel { + // data + data: AppBskyFeedDefs.GeneratorView + + constructor( + public rootStore: RootStoreModel, + view: AppBskyFeedDefs.GeneratorView, + ) { + this.data = view + makeAutoObservable( + this, + { + rootStore: false, + }, + {autoBind: true}, + ) + } + + set toggleSaved(value: boolean) { + console.log('toggleSaved', this.data.viewer) + if (this.data.viewer) { + this.data.viewer.saved = value + } + } + + async save() { + try { + // runInAction(() => { + this.toggleSaved = true + // }) + const res = await this.rootStore.agent.app.bsky.feed.saveFeed({ + feed: this.data.uri, + }) + } catch (e: any) { + this.rootStore.log.error('Failed to save feed', e) + } + } + + async unsave() { + try { + // runInAction(() => { + this.toggleSaved = false + // }) + const res = await this.rootStore.agent.app.bsky.feed.unsaveFeed({ + feed: this.data.uri, + }) + } catch (e: any) { + this.rootStore.log.error('Failed to unsanve feed', e) + } + } +} diff --git a/src/state/models/feeds/algo/saved.ts b/src/state/models/feeds/algo/saved.ts new file mode 100644 index 000000000..fabb75ae0 --- /dev/null +++ b/src/state/models/feeds/algo/saved.ts @@ -0,0 +1,116 @@ +import {makeAutoObservable} from 'mobx' +import {AppBskyFeedGetSavedFeeds as GetSavedFeeds} from '@atproto/api' +import {RootStoreModel} from '../../root-store' +import {bundleAsync} from 'lib/async/bundle' +import {cleanError} from 'lib/strings/errors' +import {AlgoItemModel} from './algo-item' + +const PAGE_SIZE = 30 + +export class SavedFeedsModel { + // state + isLoading = false + isRefreshing = false + hasLoaded = false + error = '' + hasMore = true + loadMoreCursor?: string + + // data + feeds: AlgoItemModel[] = [] + + constructor(public rootStore: RootStoreModel) { + makeAutoObservable( + this, + { + rootStore: false, + }, + {autoBind: true}, + ) + } + + get hasContent() { + return this.feeds.length > 0 + } + + get hasError() { + return this.error !== '' + } + + get isEmpty() { + return this.hasLoaded && !this.hasContent + } + + // public api + // = + + async refresh() { + return this.loadMore(true) + } + + clear() { + this.isLoading = false + this.isRefreshing = false + this.hasLoaded = false + this.error = '' + this.hasMore = true + this.loadMoreCursor = undefined + this.feeds = [] + } + + loadMore = bundleAsync(async (replace: boolean = false) => { + if (!replace && !this.hasMore) { + return + } + this._xLoading(replace) + try { + const res = await this.rootStore.agent.app.bsky.feed.getSavedFeeds({ + limit: PAGE_SIZE, + cursor: replace ? undefined : this.loadMoreCursor, + }) + if (replace) { + this._replaceAll(res) + } else { + this._appendAll(res) + } + this._xIdle() + } catch (e: any) { + this._xIdle(e) + } + }) + + // state transitions + // = + + _xLoading(isRefreshing = false) { + this.isLoading = true + this.isRefreshing = isRefreshing + this.error = '' + } + + _xIdle(err?: any) { + this.isLoading = false + this.isRefreshing = false + this.hasLoaded = true + this.error = cleanError(err) + if (err) { + this.rootStore.log.error('Failed to fetch user followers', err) + } + } + + // helper functions + // = + + _replaceAll(res: GetSavedFeeds.Response) { + this.feeds = [] + this._appendAll(res) + } + + _appendAll(res: GetSavedFeeds.Response) { + this.loadMoreCursor = res.data.cursor + this.hasMore = !!this.loadMoreCursor + for (const f of res.data.feeds) { + this.feeds.push(new AlgoItemModel(f)) + } + } +} diff --git a/src/state/models/feeds/bookmarked.ts b/src/state/models/feeds/bookmarked.ts deleted file mode 100644 index d472f0480..000000000 --- a/src/state/models/feeds/bookmarked.ts +++ /dev/null @@ -1,134 +0,0 @@ -import {makeAutoObservable} from 'mobx' -import { - AppBskyFeedGetBookmarkedFeeds as GetBookmarkedFeeds, - // AppBskyFeedBookmarkFeed as bookmarkedFeed, - // AppBskyFeedUnbookmarkFeed as unbookmarkFeed, - AppBskyFeedDefs as FeedDefs, -} from '@atproto/api' -import {RootStoreModel} from '../root-store' -import {bundleAsync} from 'lib/async/bundle' -import {cleanError} from 'lib/strings/errors' - -const PAGE_SIZE = 30 - -export class BookmarkedFeedsModel { - // state - isLoading = false - isRefreshing = false - hasLoaded = false - error = '' - hasMore = true - loadMoreCursor?: string - - // data - feeds: FeedDefs.GeneratorView[] = [] - - constructor(public rootStore: RootStoreModel) { - makeAutoObservable( - this, - { - rootStore: false, - }, - {autoBind: true}, - ) - } - - get hasContent() { - return this.feeds.length > 0 - } - - get hasError() { - return this.error !== '' - } - - get isEmpty() { - return this.hasLoaded && !this.hasContent - } - - // public api - // = - - async refresh() { - return this.loadMore(true) - } - - clear() { - this.isLoading = false - this.isRefreshing = false - this.hasLoaded = false - this.error = '' - this.hasMore = true - this.loadMoreCursor = undefined - this.feeds = [] - } - - loadMore = bundleAsync(async (replace: boolean = false) => { - if (!replace && !this.hasMore) { - return - } - this._xLoading(replace) - try { - const res = await this.rootStore.agent.app.bsky.feed.getBookmarkedFeeds({ - limit: PAGE_SIZE, - cursor: replace ? undefined : this.loadMoreCursor, - }) - if (replace) { - this._replaceAll(res) - } else { - this._appendAll(res) - } - this._xIdle() - } catch (e: any) { - this._xIdle(e) - } - }) - - async bookmark(feed: FeedDefs.GeneratorView) { - try { - await this.rootStore.agent.app.bsky.feed.bookmarkFeed({feed: feed.uri}) - } catch (e: any) { - this.rootStore.log.error('Failed to bookmark feed', e) - } - } - - async unbookmark(feed: FeedDefs.GeneratorView) { - try { - await this.rootStore.agent.app.bsky.feed.unbookmarkFeed({feed: feed.uri}) - } catch (e: any) { - this.rootStore.log.error('Failed to unbookmark feed', e) - } - } - - // state transitions - // = - - _xLoading(isRefreshing = false) { - this.isLoading = true - this.isRefreshing = isRefreshing - this.error = '' - } - - _xIdle(err?: any) { - this.isLoading = false - this.isRefreshing = false - this.hasLoaded = true - this.error = cleanError(err) - if (err) { - this.rootStore.log.error('Failed to fetch user followers', err) - } - } - - // helper functions - // = - - _replaceAll(res: GetBookmarkedFeeds.Response) { - this.feeds = [] - this._appendAll(res) - } - - _appendAll(res: GetBookmarkedFeeds.Response) { - this.loadMoreCursor = res.data.cursor - this.hasMore = !!this.loadMoreCursor - this.feeds = this.feeds.concat(res.data.feeds) - } -} diff --git a/src/state/models/ui/profile.ts b/src/state/models/ui/profile.ts index 86199108e..855955d12 100644 --- a/src/state/models/ui/profile.ts +++ b/src/state/models/ui/profile.ts @@ -2,7 +2,7 @@ import {makeAutoObservable} from 'mobx' import {RootStoreModel} from '../root-store' import {ProfileModel} from '../content/profile' import {PostsFeedModel} from '../feeds/posts' -import {ActorFeedsModel} from '../feeds/actor' +import {ActorFeedsModel} from '../feeds/algo/actor' import {AppBskyFeedDefs} from '@atproto/api' export enum Sections { diff --git a/src/view/com/algos/AlgoItem.tsx b/src/view/com/algos/AlgoItem.tsx index 979518f1d..987bfd68d 100644 --- a/src/view/com/algos/AlgoItem.tsx +++ b/src/view/com/algos/AlgoItem.tsx @@ -1,41 +1,62 @@ import React from 'react' import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' import {Text} from '../util/text/Text' -import {AppBskyFeedDefs} from '@atproto/api' import {usePalette} from 'lib/hooks/usePalette' import {s} from 'lib/styles' import {UserAvatar} from '../util/UserAvatar' +import {Button} from '../util/forms/Button' +import {observer} from 'mobx-react-lite' +import {AlgoItemModel} from 'state/models/feeds/algo/algo-item' -const AlgoItem = ({ - item, - style, -}: { - item: AppBskyFeedDefs.GeneratorView - style?: StyleProp -}) => { - const pal = usePalette('default') - return ( - - - - +const AlgoItem = observer( + ({item, style}: {item: AlgoItemModel; style?: StyleProp}) => { + const pal = usePalette('default') + return ( + + + + + + + + {item.data.displayName ?? 'Feed name'} + + + {item.data.description ?? + 'THIS IS A FEED DESCRIPTION, IT WILL TELL YOU WHAT THE FEED IS ABOUT. THIS IS A COOL FEED ABOUT COOL PEOPLE.'} + + - - - {item.displayName ?? 'Feed name'} - - - {item.description ?? - 'THIS IS A FEED DESCRIPTION, IT WILL TELL YOU WHAT THE FEED IS ABOUT. THIS IS A COOL FEED ABOUT COOL PEOPLE.'} - - - - {/* TODO: this feed is like by *3* people UserAvatars and others */} - - ) -} + {/* TODO: this feed is like by *3* people UserAvatars and others */} + + + + + + + + Liked by 3 others + + + + + )} - {isDesktopWeb && ( - - - - )} ) - }, [store.me.did, pal, currentFeed, onToggleLiked, onToggleSaved]) + }, [ + store.me.did, + pal, + currentFeed, + onToggleLiked, + onToggleSaved, + name, + rkey, + ]) return ( @@ -207,10 +221,6 @@ export const CustomFeedScreen = withAuthRequired( ) const styles = StyleSheet.create({ - headerBtns: { - flexDirection: 'row', - gap: 8, - }, header: { flexDirection: 'row', gap: 12, @@ -219,6 +229,11 @@ const styles = StyleSheet.create({ paddingBottom: 16, borderTopWidth: 1, }, + headerBtns: { + flexDirection: 'row', + gap: 8, + marginTop: 10, + }, headerDetails: { paddingHorizontal: 16, paddingBottom: 16, -- cgit 1.4.1 From acea0e074d75ac549abb01dc4ac16573a43ad7fa Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Thu, 18 May 2023 12:05:32 -0500 Subject: Tab bar fixes --- src/view/com/pager/FeedsTabBarMobile.tsx | 2 +- src/view/com/pager/TabBar.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/src/view/com/pager/FeedsTabBarMobile.tsx b/src/view/com/pager/FeedsTabBarMobile.tsx index de3f12583..cb910ccb9 100644 --- a/src/view/com/pager/FeedsTabBarMobile.tsx +++ b/src/view/com/pager/FeedsTabBarMobile.tsx @@ -73,7 +73,7 @@ const styles = StyleSheet.create({ top: 0, flexDirection: 'row', alignItems: 'center', - paddingHorizontal: 18, + paddingLeft: 18, borderBottomWidth: 1, }, tabBarAvi: { diff --git a/src/view/com/pager/TabBar.tsx b/src/view/com/pager/TabBar.tsx index fe76a08b6..f6c41ce7c 100644 --- a/src/view/com/pager/TabBar.tsx +++ b/src/view/com/pager/TabBar.tsx @@ -10,7 +10,7 @@ import {StyleSheet, View, ScrollView} from 'react-native' import {Text} from '../util/text/Text' import {PressableWithHover} from '../util/PressableWithHover' import {usePalette} from 'lib/hooks/usePalette' -import {isDesktopWeb} from 'platform/detection' +import {isDesktopWeb, isWeb} from 'platform/detection' export interface TabBarProps { testID?: string @@ -120,9 +120,9 @@ const styles = isDesktopWeb }) : StyleSheet.create({ outer: { + flex: 1, flexDirection: 'row', paddingLeft: 14, - paddingRight: 24, }, item: { paddingTop: 8, -- cgit 1.4.1 From 571fc37a9920d3b7b13a9eed2c46513036f3a4f4 Mon Sep 17 00:00:00 2001 From: Ansh Nanda Date: Thu, 18 May 2023 10:34:34 -0700 Subject: fix error & empty state when rendering custom feeds on profile --- src/view/screens/Profile.tsx | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index bf312cd06..5f31c89c9 100644 --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -120,6 +120,7 @@ export const ProfileScreen = withAuthRequired( }, [uiState.showLoadingMoreFooter]) const renderItem = React.useCallback( (item: any) => { + // if section is lists if (uiState.selectedView === Sections.Lists) { if (item === ProfileUiModel.LOADING_ITEM) { return @@ -144,6 +145,32 @@ export const ProfileScreen = withAuthRequired( } else { return } + // if section is custom algorithms + } else if (uiState.selectedView === Sections.CustomAlgorithms) { + if (item === ProfileUiModel.LOADING_ITEM) { + return + } else if (item._reactKey === '__error__') { + return ( + + + + ) + } else if (item === ProfileUiModel.EMPTY_ITEM) { + return ( + + ) + } else if (item instanceof CustomFeedModel) { + return + } + // if section is posts or posts & replies } else { if (item === ProfileUiModel.END_ITEM) { return - end of feed - @@ -188,8 +215,6 @@ export const ProfileScreen = withAuthRequired( return ( ) - } else if (item instanceof CustomFeedModel) { - return } } return -- cgit 1.4.1 From f1d2166c2911456fc60c83eb3204e5d823dff475 Mon Sep 17 00:00:00 2001 From: Ansh Nanda Date: Thu, 18 May 2023 10:36:16 -0700 Subject: fix spacing when user has no feeds --- src/view/com/feeds/SavedFeeds.tsx | 1 + 1 file changed, 1 insertion(+) (limited to 'src') diff --git a/src/view/com/feeds/SavedFeeds.tsx b/src/view/com/feeds/SavedFeeds.tsx index 1cb109a43..110a6e894 100644 --- a/src/view/com/feeds/SavedFeeds.tsx +++ b/src/view/com/feeds/SavedFeeds.tsx @@ -104,6 +104,7 @@ const styles = StyleSheet.create({ paddingHorizontal: 26, paddingVertical: 18, gap: 18, + marginTop: 8, }, empty: { paddingHorizontal: 18, -- cgit 1.4.1 From 5537d19e555c39f5f9a0ec16735ea4c3860357c4 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Thu, 18 May 2023 14:39:04 -0500 Subject: Update saved feeds to use preferences --- src/state/models/feeds/custom-feed.ts | 20 ++-------- src/state/models/media/image.ts | 2 +- src/state/models/ui/preferences.ts | 57 ++++++++++++++++++++++------- src/state/models/ui/saved-feeds.ts | 25 +++++++------ src/state/models/ui/shell.ts | 2 +- src/view/com/feeds/CustomFeed.tsx | 2 +- src/view/com/util/ViewHeader.tsx | 2 +- src/view/com/util/moderation/ImageHider.tsx | 8 ++-- 8 files changed, 68 insertions(+), 50 deletions(-) (limited to 'src') diff --git a/src/state/models/feeds/custom-feed.ts b/src/state/models/feeds/custom-feed.ts index 5e550ec69..e457d2d1e 100644 --- a/src/state/models/feeds/custom-feed.ts +++ b/src/state/models/feeds/custom-feed.ts @@ -38,7 +38,7 @@ export class CustomFeedModel { } get isSaved() { - return this.data.viewer?.saved + return this.rootStore.preferences.savedFeeds.includes(this.uri) } get isLiked() { @@ -49,23 +49,11 @@ export class CustomFeedModel { // = async save() { - await this.rootStore.agent.app.bsky.feed.saveFeed({ - feed: this.uri, - }) - runInAction(() => { - this.data.viewer = this.data.viewer || {} - this.data.viewer.saved = true - }) + await this.rootStore.preferences.addSavedFeed(this.uri) } async unsave() { - await this.rootStore.agent.app.bsky.feed.unsaveFeed({ - feed: this.uri, - }) - runInAction(() => { - this.data.viewer = this.data.viewer || {} - this.data.viewer.saved = false - }) + await this.rootStore.preferences.removeSavedFeed(this.uri) } async like() { @@ -82,7 +70,7 @@ export class CustomFeedModel { } async unlike() { - if (!this.data.viewer.like) { + if (!this.data.viewer?.like) { return } try { diff --git a/src/state/models/media/image.ts b/src/state/models/media/image.ts index ec93bf5b6..6edf88d9d 100644 --- a/src/state/models/media/image.ts +++ b/src/state/models/media/image.ts @@ -135,7 +135,7 @@ export class ImageModel implements RNImage { // Only for mobile async crop() { try { - const cropped = await openCropper({ + const cropped = await openCropper(this.rootStore, { mediaType: 'photo', path: this.path, freeStyleCropEnabled: true, diff --git a/src/state/models/ui/preferences.ts b/src/state/models/ui/preferences.ts index 05a1eb128..120b4adcc 100644 --- a/src/state/models/ui/preferences.ts +++ b/src/state/models/ui/preferences.ts @@ -46,6 +46,7 @@ export class PreferencesModel { contentLanguages: string[] = deviceLocales?.map?.(locale => locale.languageCode) || [] contentLabels = new LabelPreferencesModel() + savedFeeds: string[] = [] pinnedFeeds: string[] = [] constructor(public rootStore: RootStoreModel) { @@ -56,6 +57,7 @@ export class PreferencesModel { return { contentLanguages: this.contentLanguages, contentLabels: this.contentLabels, + savedFeeds: this.savedFeeds, pinnedFeeds: this.pinnedFeeds, } } @@ -75,6 +77,13 @@ export class PreferencesModel { // default to the device languages this.contentLanguages = deviceLocales.map(locale => locale.languageCode) } + if ( + hasProp(v, 'savedFeeds') && + Array.isArray(v.savedFeeds) && + typeof v.savedFeeds.every(item => typeof item === 'string') + ) { + this.savedFeeds = v.savedFeeds + } if ( hasProp(v, 'pinnedFeeds') && Array.isArray(v.pinnedFeeds) && @@ -106,10 +115,11 @@ export class PreferencesModel { pref.visibility as LabelPreference } } else if ( - AppBskyActorDefs.isPinnedFeedsPref(pref) && - AppBskyActorDefs.validatePinnedFeedsPref(pref).success + AppBskyActorDefs.isSavedFeedsPref(pref) && + AppBskyActorDefs.validateSavedFeedsPref(pref).success ) { - this.pinnedFeeds = pref.feeds + this.savedFeeds = pref.saved + this.pinnedFeeds = pref.pinned } } }) @@ -220,38 +230,57 @@ export class PreferencesModel { return res } - async setPinnedFeeds(v: string[]) { - const old = this.pinnedFeeds - this.pinnedFeeds = v + async setSavedFeeds(saved: string[], pinned: string[]) { + const oldSaved = this.savedFeeds + const oldPinned = this.pinnedFeeds + this.savedFeeds = saved + this.pinnedFeeds = pinned try { await this.update((prefs: AppBskyActorDefs.Preferences) => { const existing = prefs.find( pref => - AppBskyActorDefs.isPinnedFeedsPref(pref) && - AppBskyActorDefs.validatePinnedFeedsPref(pref).success, + AppBskyActorDefs.isSavedFeedsPref(pref) && + AppBskyActorDefs.validateSavedFeedsPref(pref).success, ) if (existing) { - existing.feeds = v + existing.saved = saved + existing.pinned = pinned } else { prefs.push({ - $type: 'app.bsky.actor.defs#pinnedFeedsPref', - feeds: v, + $type: 'app.bsky.actor.defs#savedFeedsPref', + saved, + pinned, }) } }) } catch (e) { runInAction(() => { - this.pinnedFeeds = old + this.savedFeeds = oldSaved + this.pinnedFeeds = oldPinned }) throw e } } + async addSavedFeed(v: string) { + return this.setSavedFeeds([...this.savedFeeds, v], this.pinnedFeeds) + } + + async removeSavedFeed(v: string) { + return this.setSavedFeeds( + this.savedFeeds.filter(uri => uri !== v), + this.pinnedFeeds.filter(uri => uri !== v), + ) + } + async addPinnedFeed(v: string) { - return this.setPinnedFeeds([...this.pinnedFeeds, v]) + return this.setSavedFeeds(this.savedFeeds, [...this.pinnedFeeds, v]) } async removePinnedFeed(v: string) { - return this.setPinnedFeeds(this.pinnedFeeds.filter(uri => uri !== v)) + return this.setSavedFeeds( + this.savedFeeds, + this.pinnedFeeds.filter(uri => uri !== v), + ) } } diff --git a/src/state/models/ui/saved-feeds.ts b/src/state/models/ui/saved-feeds.ts index f500aef2e..9de28e028 100644 --- a/src/state/models/ui/saved-feeds.ts +++ b/src/state/models/ui/saved-feeds.ts @@ -5,8 +5,6 @@ import {bundleAsync} from 'lib/async/bundle' import {cleanError} from 'lib/strings/errors' import {CustomFeedModel} from '../feeds/custom-feed' -const PAGE_SIZE = 100 - export class SavedFeedsModel { // state isLoading = false @@ -69,16 +67,15 @@ export class SavedFeedsModel { try { let feeds: AppBskyFeedDefs.GeneratorView[] = [] let cursor - for (let i = 0; i < 100; i++) { - const res = await this.rootStore.agent.app.bsky.feed.getSavedFeeds({ - limit: PAGE_SIZE, - cursor, + for ( + let i = 0; + i < this.rootStore.preferences.savedFeeds.length; + i += 25 + ) { + const res = await this.rootStore.agent.app.bsky.feed.getFeedGenerators({ + feeds: this.rootStore.preferences.savedFeeds.slice(i, 25), }) feeds = feeds.concat(res.data.feeds) - cursor = res.data.cursor - if (!cursor) { - break - } } runInAction(() => { this.feeds = feeds.map(f => new CustomFeedModel(this.rootStore, f)) @@ -127,7 +124,8 @@ export class SavedFeedsModel { } async reorderPinnedFeeds(feeds: CustomFeedModel[]) { - return this.rootStore.preferences.setPinnedFeeds( + return this.rootStore.preferences.setSavedFeeds( + this.rootStore.preferences.savedFeeds, feeds.filter(feed => this.isPinned(feed)).map(feed => feed.uri), ) } @@ -151,7 +149,10 @@ export class SavedFeedsModel { pinned[index] = pinned[index + 1] pinned[index + 1] = temp } - await this.rootStore.preferences.setPinnedFeeds(pinned) + await this.rootStore.preferences.setSavedFeeds( + this.rootStore.preferences.savedFeeds, + pinned, + ) } // state transitions diff --git a/src/state/models/ui/shell.ts b/src/state/models/ui/shell.ts index 9b9a176be..95b666243 100644 --- a/src/state/models/ui/shell.ts +++ b/src/state/models/ui/shell.ts @@ -119,7 +119,7 @@ export type Modal = // Moderation | ReportAccountModal | ReportPostModal - | CreateMuteListModal + | CreateOrEditMuteListModal | ListAddRemoveUserModal // Posts diff --git a/src/view/com/feeds/CustomFeed.tsx b/src/view/com/feeds/CustomFeed.tsx index d4e843b67..9a71eb846 100644 --- a/src/view/com/feeds/CustomFeed.tsx +++ b/src/view/com/feeds/CustomFeed.tsx @@ -40,7 +40,7 @@ export const CustomFeed = observer( const navigation = useNavigation() const onToggleSaved = React.useCallback(async () => { - if (item.data.viewer?.saved) { + if (item.isSaved) { store.shell.openModal({ name: 'confirm', title: 'Remove from my feeds', diff --git a/src/view/com/util/ViewHeader.tsx b/src/view/com/util/ViewHeader.tsx index 7f13f1838..c17a65b14 100644 --- a/src/view/com/util/ViewHeader.tsx +++ b/src/view/com/util/ViewHeader.tsx @@ -121,7 +121,7 @@ const Container = observer( }: { children: React.ReactNode hideOnScroll: boolean - showBorder: boolean + showBorder?: boolean }) => { const store = useStores() const pal = usePalette('default') diff --git a/src/view/com/util/moderation/ImageHider.tsx b/src/view/com/util/moderation/ImageHider.tsx index b42c6397d..40add5b67 100644 --- a/src/view/com/util/moderation/ImageHider.tsx +++ b/src/view/com/util/moderation/ImageHider.tsx @@ -27,6 +27,10 @@ export function ImageHider({ setOverride(false) }, [setOverride]) + if (moderation.behavior === ModerationBehaviorCode.Hide) { + return null + } + if (moderation.behavior !== ModerationBehaviorCode.WarnImages) { return ( @@ -35,10 +39,6 @@ export function ImageHider({ ) } - if (moderation.behavior === ModerationBehaviorCode.Hide) { - return null - } - return ( -- cgit 1.4.1 From 2f4408582bf27a83ba8d22605077d067f8433d7c Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Thu, 18 May 2023 15:06:32 -0500 Subject: Set default feeds --- src/lib/constants.ts | 43 ++++++++++++++++++++++++++++++++++++++ src/state/models/ui/preferences.ts | 34 ++++++++++++++++++++++++++++-- 2 files changed, 75 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 6d0d4797b..88e429d83 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -94,6 +94,49 @@ export function SUGGESTED_FOLLOWS(serviceUrl: string) { } } +export const STAGING_DEFAULT_FEED = (rkey: string) => + `at://did:plc:wqzurwm3kmaig6e6hnc2gqwo/app.bsky.feed.generator/${rkey}` +export const PROD_DEFAULT_FEED = (rkey: string) => + `at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/${rkey}` +export async function DEFAULT_FEEDS( + serviceUrl: string, + resolveHandle: (name: string) => Promise, +) { + if (serviceUrl.includes('localhost')) { + const aliceDid = await resolveHandle('alice.test') + return { + pinned: [`at://${aliceDid}/app.bsky.feed.generator/alice-favs`], + saved: [`at://${aliceDid}/app.bsky.feed.generator/alice-favs`], + } + } else if (serviceUrl.includes('staging')) { + return { + pinned: [ + STAGING_DEFAULT_FEED('skyline'), + STAGING_DEFAULT_FEED('whats-hot'), + ], + saved: [ + STAGING_DEFAULT_FEED('bsky-team'), + STAGING_DEFAULT_FEED('skyline'), + STAGING_DEFAULT_FEED('whats-hot'), + STAGING_DEFAULT_FEED('hot-classic'), + ], + } + } else { + return { + pinned: [ + STAGING_DEFAULT_FEED('skyline'), + STAGING_DEFAULT_FEED('whats-hot'), + ], + saved: [ + STAGING_DEFAULT_FEED('bsky-team'), + STAGING_DEFAULT_FEED('skyline'), + STAGING_DEFAULT_FEED('whats-hot'), + STAGING_DEFAULT_FEED('hot-classic'), + ], + } + } +} + export const POST_IMG_MAX = { width: 2000, height: 2000, diff --git a/src/state/models/ui/preferences.ts b/src/state/models/ui/preferences.ts index 120b4adcc..c85faf658 100644 --- a/src/state/models/ui/preferences.ts +++ b/src/state/models/ui/preferences.ts @@ -11,6 +11,7 @@ import { ALWAYS_FILTER_LABEL_GROUP, ALWAYS_WARN_LABEL_GROUP, } from 'lib/labeling/const' +import {DEFAULT_FEEDS} from 'lib/constants' import {isIOS} from 'platform/detection' const deviceLocales = getLocales() @@ -95,6 +96,8 @@ export class PreferencesModel { } async sync() { + // fetch preferences + let hasSavedFeedsPref = false const res = await this.rootStore.agent.app.bsky.actor.getPreferences({}) runInAction(() => { for (const pref of res.data.preferences) { @@ -120,14 +123,41 @@ export class PreferencesModel { ) { this.savedFeeds = pref.saved this.pinnedFeeds = pref.pinned + hasSavedFeedsPref = true } } }) + + // set defaults on missing items + if (!hasSavedFeedsPref) { + const {saved, pinned} = await DEFAULT_FEEDS( + this.rootStore.agent.service.toString(), + (handle: string) => + this.rootStore.agent + .resolveHandle({handle}) + .then(({data}) => data.did), + ) + runInAction(() => { + this.savedFeeds = saved + this.pinnedFeeds = pinned + }) + res.data.preferences.push({ + $type: 'app.bsky.actor.defs#savedFeedsPref', + saved, + pinned, + }) + await this.rootStore.agent.app.bsky.actor.putPreferences({ + preferences: res.data.preferences, + }) + /* dont await */ this.rootStore.me.savedFeeds.refresh() + } } - async update(cb: (prefs: AppBskyActorDefs.Preferences) => void) { + async update(cb: (prefs: AppBskyActorDefs.Preferences) => boolean | void) { const res = await this.rootStore.agent.app.bsky.actor.getPreferences({}) - cb(res.data.preferences) + if (cb(res.data.preferences) === false) { + return + } await this.rootStore.agent.app.bsky.actor.putPreferences({ preferences: res.data.preferences, }) -- cgit 1.4.1 From 84990c509e9feb0cd44921a318aedcbad92b1da7 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Thu, 18 May 2023 15:12:18 -0500 Subject: Drop the hard-coded what's hot algo --- src/state/models/feeds/posts.ts | 56 +------------- src/view/com/modals/ContentLanguagesSettings.tsx | 4 +- src/view/com/pager/FeedsTabBar.web.tsx | 7 +- src/view/com/pager/FeedsTabBarMobile.tsx | 7 +- src/view/com/posts/CustomFeedEmptyState.tsx | 97 ++++++++++++++++++++++++ src/view/com/posts/WhatsHotEmptyState.tsx | 76 ------------------- src/view/screens/Home.tsx | 35 ++------- 7 files changed, 109 insertions(+), 173 deletions(-) create mode 100644 src/view/com/posts/CustomFeedEmptyState.tsx delete mode 100644 src/view/com/posts/WhatsHotEmptyState.tsx (limited to 'src') diff --git a/src/state/models/feeds/posts.ts b/src/state/models/feeds/posts.ts index dfd92b35c..5a5b28785 100644 --- a/src/state/models/feeds/posts.ts +++ b/src/state/models/feeds/posts.ts @@ -310,7 +310,7 @@ export class PostsFeedModel { constructor( public rootStore: RootStoreModel, - public feedType: 'home' | 'author' | 'suggested' | 'goodstuff' | 'custom', + public feedType: 'home' | 'author' | 'suggested' | 'custom', params: | GetTimeline.QueryParams | GetAuthorFeed.QueryParams @@ -391,10 +391,9 @@ export class PostsFeedModel { } get feedTuners() { - if (this.feedType === 'goodstuff') { + if (this.feedType === 'custom') { return [ FeedTuner.dedupReposts, - FeedTuner.likedRepliesOnly, FeedTuner.preferredLangOnly( this.rootStore.preferences.contentLanguages, ), @@ -701,15 +700,6 @@ export class PostsFeedModel { return this.rootStore.agent.app.bsky.feed.getFeed( params as GetCustomFeed.QueryParams, ) - } else if (this.feedType === 'goodstuff') { - const res = await getGoodStuff( - this.rootStore.session.currentSession?.accessJwt || '', - params as GetTimeline.QueryParams, - ) - res.data.feed = (res.data.feed || []).filter( - item => !item.post.author.viewer?.muted, - ) - return res } else { return this.rootStore.agent.getAuthorFeed( params as GetAuthorFeed.QueryParams, @@ -717,45 +707,3 @@ export class PostsFeedModel { } } } - -// HACK -// temporary off-spec route to get the good stuff -// -prf -async function getGoodStuff( - accessJwt: string, - params: GetTimeline.QueryParams, -): Promise { - const controller = new AbortController() - const to = setTimeout(() => controller.abort(), 15e3) - - const uri = new URL('https://bsky.social/xrpc/app.bsky.unspecced.getPopular') - let k: keyof GetTimeline.QueryParams - for (k in params) { - if (typeof params[k] !== 'undefined') { - uri.searchParams.set(k, String(params[k])) - } - } - - const res = await fetch(String(uri), { - method: 'get', - headers: { - accept: 'application/json', - authorization: `Bearer ${accessJwt}`, - }, - signal: controller.signal, - }) - - const resHeaders: Record = {} - res.headers.forEach((value: string, key: string) => { - resHeaders[key] = value - }) - let resBody = await res.json() - - clearTimeout(to) - - return { - success: res.status === 200, - headers: resHeaders, - data: jsonToLex(resBody), - } -} diff --git a/src/view/com/modals/ContentLanguagesSettings.tsx b/src/view/com/modals/ContentLanguagesSettings.tsx index 0c750fe0e..700f1cbcb 100644 --- a/src/view/com/modals/ContentLanguagesSettings.tsx +++ b/src/view/com/modals/ContentLanguagesSettings.tsx @@ -41,8 +41,8 @@ export function Component({}: {}) { Content Languages - Which languages would you like to see in the What's Hot feed? (Leave - them all unchecked to see any language.) + Which languages would you like to see in the your feed? (Leave them all + unchecked to see any language.) {languages.map(lang => ( diff --git a/src/view/com/pager/FeedsTabBar.web.tsx b/src/view/com/pager/FeedsTabBar.web.tsx index 56ca6f2a1..78937611b 100644 --- a/src/view/com/pager/FeedsTabBar.web.tsx +++ b/src/view/com/pager/FeedsTabBar.web.tsx @@ -28,12 +28,7 @@ const FeedsTabBarDesktop = observer( ) => { const store = useStores() const items = useMemo( - () => [ - 'Following', - "What's hot", - ...store.me.savedFeeds.pinnedFeedNames, - 'My feeds', - ], + () => ['Following', ...store.me.savedFeeds.pinnedFeedNames, 'My feeds'], [store.me.savedFeeds.pinnedFeedNames], ) const pal = usePalette('default') diff --git a/src/view/com/pager/FeedsTabBarMobile.tsx b/src/view/com/pager/FeedsTabBarMobile.tsx index cb910ccb9..a41f0ef32 100644 --- a/src/view/com/pager/FeedsTabBarMobile.tsx +++ b/src/view/com/pager/FeedsTabBarMobile.tsx @@ -33,12 +33,7 @@ export const FeedsTabBar = observer( }, [store]) const items = useMemo( - () => [ - 'Following', - "What's hot", - ...store.me.savedFeeds.pinnedFeedNames, - 'My feeds', - ], + () => ['Following', ...store.me.savedFeeds.pinnedFeedNames, 'My feeds'], [store.me.savedFeeds.pinnedFeedNames], ) diff --git a/src/view/com/posts/CustomFeedEmptyState.tsx b/src/view/com/posts/CustomFeedEmptyState.tsx new file mode 100644 index 000000000..69dd79902 --- /dev/null +++ b/src/view/com/posts/CustomFeedEmptyState.tsx @@ -0,0 +1,97 @@ +import React from 'react' +import {StyleSheet, View} from 'react-native' +import {useNavigation} from '@react-navigation/native' +import { + FontAwesomeIcon, + FontAwesomeIconStyle, +} from '@fortawesome/react-native-fontawesome' +import {Text} from '../util/text/Text' +import {Button} from '../util/forms/Button' +import {MagnifyingGlassIcon} from 'lib/icons' +import {NavigationProp} from 'lib/routes/types' +import {useStores} from 'state/index' +import {usePalette} from 'lib/hooks/usePalette' +import {s} from 'lib/styles' + +export function CustomFeedEmptyState() { + const pal = usePalette('default') + const palInverted = usePalette('inverted') + const store = useStores() + const navigation = useNavigation() + + const onPressFindAccounts = React.useCallback(() => { + navigation.navigate('SearchTab') + navigation.popToTop() + }, [navigation]) + + const onPressSettings = React.useCallback(() => { + store.shell.openModal({name: 'content-languages-settings'}) + }, [store]) + + return ( + + + + + + This feed is empty! You may need to follow more users or tune your + language settings. + + + + + ) +} +const styles = StyleSheet.create({ + emptyContainer: { + height: '100%', + paddingVertical: 40, + paddingHorizontal: 30, + }, + emptyIconContainer: { + marginBottom: 16, + }, + emptyIcon: { + marginLeft: 'auto', + marginRight: 'auto', + }, + emptyBtn: { + marginVertical: 20, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: 18, + paddingHorizontal: 24, + borderRadius: 30, + }, + + feedsTip: { + position: 'absolute', + left: 22, + }, + feedsTipArrow: { + marginLeft: 32, + marginTop: 8, + }, +}) diff --git a/src/view/com/posts/WhatsHotEmptyState.tsx b/src/view/com/posts/WhatsHotEmptyState.tsx deleted file mode 100644 index ade94ca3f..000000000 --- a/src/view/com/posts/WhatsHotEmptyState.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import React from 'react' -import {StyleSheet, View} from 'react-native' -import { - FontAwesomeIcon, - FontAwesomeIconStyle, -} from '@fortawesome/react-native-fontawesome' -import {Text} from '../util/text/Text' -import {Button} from '../util/forms/Button' -import {MagnifyingGlassIcon} from 'lib/icons' -import {useStores} from 'state/index' -import {usePalette} from 'lib/hooks/usePalette' -import {s} from 'lib/styles' - -export function WhatsHotEmptyState() { - const pal = usePalette('default') - const palInverted = usePalette('inverted') - const store = useStores() - - const onPressSettings = React.useCallback(() => { - store.shell.openModal({name: 'content-languages-settings'}) - }, [store]) - - return ( - - - - - - Your What's Hot feed is empty! This is because there aren't enough users - posting in your selected language. - - - - ) -} -const styles = StyleSheet.create({ - emptyContainer: { - height: '100%', - paddingVertical: 40, - paddingHorizontal: 30, - }, - emptyIconContainer: { - marginBottom: 16, - }, - emptyIcon: { - marginLeft: 'auto', - marginRight: 'auto', - }, - emptyBtn: { - marginVertical: 20, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - paddingVertical: 18, - paddingHorizontal: 24, - borderRadius: 30, - }, - - feedsTip: { - position: 'absolute', - left: 22, - }, - feedsTipArrow: { - marginLeft: 32, - marginTop: 8, - }, -}) diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index 54cec3b31..d761994f3 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -11,7 +11,7 @@ import {withAuthRequired} from 'view/com/auth/withAuthRequired' import {useTabFocusEffect} from 'lib/hooks/useTabFocusEffect' import {Feed} from '../com/posts/Feed' import {FollowingEmptyState} from 'view/com/posts/FollowingEmptyState' -import {WhatsHotEmptyState} from 'view/com/posts/WhatsHotEmptyState' +import {CustomFeedEmptyState} from 'view/com/posts/CustomFeedEmptyState' import {LoadLatestBtn} from '../com/util/load-latest/LoadLatestBtn' import {FeedsTabBar} from '../com/pager/FeedsTabBar' import {Pager, PagerRef, RenderTabBarFnProps} from 'view/com/pager/Pager' @@ -34,15 +34,6 @@ export const HomeScreen = withAuthRequired( const pagerRef = React.useRef(null) const [selectedPage, setSelectedPage] = React.useState(0) const [customFeeds, setCustomFeeds] = React.useState([]) - const [initialLanguages] = React.useState( - store.preferences.contentLanguages, - ) - - const algoFeed: PostsFeedModel = React.useMemo(() => { - const feed = new PostsFeedModel(store, 'goodstuff', {}) - feed.setup() - return feed - }, [store]) React.useEffect(() => { const {pinned} = store.me.savedFeeds @@ -66,13 +57,6 @@ export const HomeScreen = withAuthRequired( setCustomFeeds(feeds) }, [store, store.me.savedFeeds.pinned, customFeeds, setCustomFeeds]) - React.useEffect(() => { - // refresh whats hot when lang preferences change - if (initialLanguages !== store.preferences.contentLanguages) { - algoFeed.refresh() - } - }, [initialLanguages, store.preferences.contentLanguages, algoFeed]) - useFocusEffect( React.useCallback(() => { store.shell.setMinimalShellMode(false) @@ -113,8 +97,8 @@ export const HomeScreen = withAuthRequired( return }, []) - const renderWhatsHotEmptyState = React.useCallback(() => { - return + const renderCustomFeedEmptyState = React.useCallback(() => { + return }, []) const initialPage = store.me.followsCount === 0 ? 1 : 0 @@ -133,26 +117,19 @@ export const HomeScreen = withAuthRequired( feed={store.me.mainFeed} renderEmptyState={renderFollowingEmptyState} /> - {customFeeds.map((f, index) => { return ( ) })} -- cgit 1.4.1 From 1ecf0da81b6e5eaf7959e1416df1e8f004e2566f Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Thu, 18 May 2023 16:22:11 -0500 Subject: Add feed sharing --- src/lib/api/index.ts | 93 ++++++++++++++------------- src/lib/link-meta/bsky.ts | 27 ++++++++ src/lib/strings/url-helpers.ts | 12 ++++ src/view/com/composer/useExternalLinkFetch.ts | 22 ++++++- src/view/screens/CustomFeed.tsx | 35 +++++++++- 5 files changed, 141 insertions(+), 48 deletions(-) (limited to 'src') diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 3877b3ef7..81b61a444 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -18,6 +18,7 @@ export interface ExternalEmbedDraft { uri: string isLoading: boolean meta?: LinkMeta + embed?: AppBskyEmbedRecord.Main localThumb?: ImageModel } @@ -135,40 +136,54 @@ export async function post(store: RootStoreModel, opts: PostOpts) { } if (opts.extLink && !opts.images?.length) { - let thumb - if (opts.extLink.localThumb) { - opts.onStateChange?.('Uploading link thumbnail...') - let encoding - if (opts.extLink.localThumb.mime) { - encoding = opts.extLink.localThumb.mime - } else if (opts.extLink.localThumb.path.endsWith('.png')) { - encoding = 'image/png' - } else if ( - opts.extLink.localThumb.path.endsWith('.jpeg') || - opts.extLink.localThumb.path.endsWith('.jpg') - ) { - encoding = 'image/jpeg' - } else { - store.log.warn( - 'Unexpected image format for thumbnail, skipping', - opts.extLink.localThumb.path, - ) - } - if (encoding) { - const thumbUploadRes = await uploadBlob( - store, - opts.extLink.localThumb.path, - encoding, - ) - thumb = thumbUploadRes.data.blob + if (opts.extLink.embed) { + embed = opts.extLink.embed + } else { + let thumb + if (opts.extLink.localThumb) { + opts.onStateChange?.('Uploading link thumbnail...') + let encoding + if (opts.extLink.localThumb.mime) { + encoding = opts.extLink.localThumb.mime + } else if (opts.extLink.localThumb.path.endsWith('.png')) { + encoding = 'image/png' + } else if ( + opts.extLink.localThumb.path.endsWith('.jpeg') || + opts.extLink.localThumb.path.endsWith('.jpg') + ) { + encoding = 'image/jpeg' + } else { + store.log.warn( + 'Unexpected image format for thumbnail, skipping', + opts.extLink.localThumb.path, + ) + } + if (encoding) { + const thumbUploadRes = await uploadBlob( + store, + opts.extLink.localThumb.path, + encoding, + ) + thumb = thumbUploadRes.data.blob + } } - } - if (opts.quote) { - embed = { - $type: 'app.bsky.embed.recordWithMedia', - record: embed, - media: { + if (opts.quote) { + embed = { + $type: 'app.bsky.embed.recordWithMedia', + record: embed, + media: { + $type: 'app.bsky.embed.external', + external: { + uri: opts.extLink.uri, + title: opts.extLink.meta?.title || '', + description: opts.extLink.meta?.description || '', + thumb, + }, + } as AppBskyEmbedExternal.Main, + } as AppBskyEmbedRecordWithMedia.Main + } else { + embed = { $type: 'app.bsky.embed.external', external: { uri: opts.extLink.uri, @@ -176,18 +191,8 @@ export async function post(store: RootStoreModel, opts: PostOpts) { description: opts.extLink.meta?.description || '', thumb, }, - } as AppBskyEmbedExternal.Main, - } as AppBskyEmbedRecordWithMedia.Main - } else { - embed = { - $type: 'app.bsky.embed.external', - external: { - uri: opts.extLink.uri, - title: opts.extLink.meta?.title || '', - description: opts.extLink.meta?.description || '', - thumb, - }, - } as AppBskyEmbedExternal.Main + } as AppBskyEmbedExternal.Main + } } } diff --git a/src/lib/link-meta/bsky.ts b/src/lib/link-meta/bsky.ts index f4a96a22f..cf43feca8 100644 --- a/src/lib/link-meta/bsky.ts +++ b/src/lib/link-meta/bsky.ts @@ -1,3 +1,4 @@ +import * as apilib from 'lib/api/index' import {LikelyType, LinkMeta} from './link-meta' // import {match as matchRoute} from 'view/routes' import {convertBskyAppUrlIfNeeded, makeRecordUri} from '../strings/url-helpers' @@ -128,3 +129,29 @@ export async function getPostAsQuote( }, } } + +export async function getFeedAsEmbed( + store: RootStoreModel, + url: string, +): Promise { + url = convertBskyAppUrlIfNeeded(url) + const [_0, user, _1, rkey] = url.split('/').filter(Boolean) + const feed = makeRecordUri(user, 'app.bsky.feed.generator', rkey) + const res = await store.agent.app.bsky.feed.getFeedGenerator({feed}) + return { + isLoading: false, + uri: feed, + meta: { + url: feed, + likelyType: LikelyType.AtpData, + title: res.data.view.displayName, + }, + embed: { + $type: 'app.bsky.embed.record', + record: { + uri: res.data.view.uri, + cid: res.data.view.cid, + }, + }, + } +} diff --git a/src/lib/strings/url-helpers.ts b/src/lib/strings/url-helpers.ts index a5412920e..d6d43b89d 100644 --- a/src/lib/strings/url-helpers.ts +++ b/src/lib/strings/url-helpers.ts @@ -82,6 +82,18 @@ export function isBskyPostUrl(url: string): boolean { return false } +export function isBskyCustomFeedUrl(url: string): boolean { + if (isBskyAppUrl(url)) { + try { + const urlp = new URL(url) + return /profile\/(?[^/]+)\/feed\/(?[^/]+)/i.test( + urlp.pathname, + ) + } catch {} + } + return false +} + export function convertBskyAppUrlIfNeeded(url: string): string { if (isBskyAppUrl(url)) { try { diff --git a/src/view/com/composer/useExternalLinkFetch.ts b/src/view/com/composer/useExternalLinkFetch.ts index 45c2dfd0d..8d3b8cac2 100644 --- a/src/view/com/composer/useExternalLinkFetch.ts +++ b/src/view/com/composer/useExternalLinkFetch.ts @@ -2,9 +2,9 @@ import {useState, useEffect} from 'react' import {useStores} from 'state/index' import * as apilib from 'lib/api/index' import {getLinkMeta} from 'lib/link-meta/link-meta' -import {getPostAsQuote} from 'lib/link-meta/bsky' +import {getPostAsQuote, getFeedAsEmbed} from 'lib/link-meta/bsky' import {downloadAndResize} from 'lib/media/manip' -import {isBskyPostUrl} from 'lib/strings/url-helpers' +import {isBskyPostUrl, isBskyCustomFeedUrl} from 'lib/strings/url-helpers' import {ComposerOpts} from 'state/models/ui/shell' import {POST_IMG_MAX} from 'lib/constants' @@ -41,6 +41,24 @@ export function useExternalLinkFetch({ setExtLink(undefined) }, ) + } else if (isBskyCustomFeedUrl(extLink.uri)) { + getFeedAsEmbed(store, extLink.uri).then( + ({embed, meta}) => { + if (aborted) { + return + } + setExtLink({ + uri: extLink.uri, + isLoading: false, + meta, + embed, + }) + }, + err => { + store.log.error('Failed to fetch feed for embedding', {err}) + setExtLink(undefined) + }, + ) } else { getLinkMeta(store, extLink.uri).then(meta => { if (aborted) { diff --git a/src/view/screens/CustomFeed.tsx b/src/view/screens/CustomFeed.tsx index bbcc08513..d2b9041f9 100644 --- a/src/view/screens/CustomFeed.tsx +++ b/src/view/screens/CustomFeed.tsx @@ -1,5 +1,6 @@ import React, {useMemo, useRef} from 'react' import {NativeStackScreenProps} from '@react-navigation/native-stack' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {usePalette} from 'lib/hooks/usePalette' import {HeartIcon, HeartIconSolid} from 'lib/icons' import {CommonNavigatorParams} from 'lib/routes/types' @@ -21,6 +22,8 @@ import {Text} from 'view/com/util/text/Text' import * as Toast from 'view/com/util/Toast' import {isDesktopWeb} from 'platform/detection' import {useSetTitle} from 'lib/hooks/useSetTitle' +import {shareUrl} from 'lib/sharing' +import {toShareUrl} from 'lib/strings/url-helpers' type Props = NativeStackScreenProps export const CustomFeedScreen = withAuthRequired( @@ -73,10 +76,22 @@ export const CustomFeedScreen = withAuthRequired( store.log.error('Failed up toggle like', {err}) } }, [store, currentFeed]) + const onPressShare = React.useCallback(() => { + const url = toShareUrl(`/profile/${name}/feed/${rkey}`) + shareUrl(url) + }, [name, rkey]) const renderHeaderBtns = React.useCallback(() => { return ( + + )} @@ -202,6 +232,7 @@ export const CustomFeedScreen = withAuthRequired( currentFeed, onToggleLiked, onToggleSaved, + onPressShare, name, rkey, ]) -- cgit 1.4.1 From 3c89dd40f90deda00dd4d717bec0bb2f4217c1d1 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Thu, 18 May 2023 16:54:17 -0500 Subject: Fix lint --- src/state/models/feeds/posts.ts | 1 - src/state/models/ui/saved-feeds.ts | 1 - src/view/com/pager/TabBar.tsx | 2 +- 3 files changed, 1 insertion(+), 3 deletions(-) (limited to 'src') diff --git a/src/state/models/feeds/posts.ts b/src/state/models/feeds/posts.ts index 5a5b28785..ac32044b4 100644 --- a/src/state/models/feeds/posts.ts +++ b/src/state/models/feeds/posts.ts @@ -6,7 +6,6 @@ import { AppBskyFeedGetAuthorFeed as GetAuthorFeed, AppBskyFeedGetFeed as GetCustomFeed, RichText, - jsonToLex, } from '@atproto/api' import AwaitLock from 'await-lock' import {bundleAsync} from 'lib/async/bundle' diff --git a/src/state/models/ui/saved-feeds.ts b/src/state/models/ui/saved-feeds.ts index 9de28e028..0d04f9c8d 100644 --- a/src/state/models/ui/saved-feeds.ts +++ b/src/state/models/ui/saved-feeds.ts @@ -66,7 +66,6 @@ export class SavedFeedsModel { this._xLoading(!quietRefresh) try { let feeds: AppBskyFeedDefs.GeneratorView[] = [] - let cursor for ( let i = 0; i < this.rootStore.preferences.savedFeeds.length; diff --git a/src/view/com/pager/TabBar.tsx b/src/view/com/pager/TabBar.tsx index f6c41ce7c..485219730 100644 --- a/src/view/com/pager/TabBar.tsx +++ b/src/view/com/pager/TabBar.tsx @@ -10,7 +10,7 @@ import {StyleSheet, View, ScrollView} from 'react-native' import {Text} from '../util/text/Text' import {PressableWithHover} from '../util/PressableWithHover' import {usePalette} from 'lib/hooks/usePalette' -import {isDesktopWeb, isWeb} from 'platform/detection' +import {isDesktopWeb} from 'platform/detection' export interface TabBarProps { testID?: string -- cgit 1.4.1 From 324c9209dc5777dcf3019926fb5847f6073fd2e4 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Thu, 18 May 2023 17:01:48 -0500 Subject: Only show algos and lists on profiles if there are items --- src/state/models/ui/profile.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) (limited to 'src') diff --git a/src/state/models/ui/profile.ts b/src/state/models/ui/profile.ts index 54ee461b0..35831d1f7 100644 --- a/src/state/models/ui/profile.ts +++ b/src/state/models/ui/profile.ts @@ -12,13 +12,6 @@ export enum Sections { Lists = 'Lists', } -const USER_SELECTOR_ITEMS = [ - Sections.Posts, - Sections.PostsWithReplies, - Sections.CustomAlgorithms, - Sections.Lists, -] - export interface ProfileUiParams { user: string } @@ -83,7 +76,14 @@ export class ProfileUiModel { } get selectorItems() { - return USER_SELECTOR_ITEMS + const items = [Sections.Posts, Sections.PostsWithReplies] + if (this.algos.hasLoaded && !this.algos.isEmpty) { + items.push(Sections.CustomAlgorithms) + } + if (this.lists.hasLoaded && !this.lists.isEmpty) { + items.push(Sections.Lists) + } + return items } get selectedView() { @@ -166,6 +166,7 @@ export class ProfileUiModel { .setup() .catch(err => this.rootStore.log.error('Failed to fetch feed', err)), ]) + this.algos.refresh() // HACK: need to use the DID as a param, not the username -prf this.lists.source = this.profile.did this.lists -- cgit 1.4.1 From 46ed910cdaeaa675b858ccdad3d64425f8e63031 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Thu, 18 May 2023 17:10:42 -0500 Subject: Add list-type avatar --- src/view/com/lists/ListCard.tsx | 2 +- src/view/com/lists/ListItems.tsx | 2 +- src/view/com/modals/CreateOrEditMuteList.tsx | 1 + src/view/com/util/UserAvatar.tsx | 29 ++++++++++++++++++++++++++-- 4 files changed, 30 insertions(+), 4 deletions(-) (limited to 'src') diff --git a/src/view/com/lists/ListCard.tsx b/src/view/com/lists/ListCard.tsx index 7cbdaaf64..0e13ca333 100644 --- a/src/view/com/lists/ListCard.tsx +++ b/src/view/com/lists/ListCard.tsx @@ -60,7 +60,7 @@ export const ListCard = ({ anchorNoUnderline> - + - + diff --git a/src/view/com/modals/CreateOrEditMuteList.tsx b/src/view/com/modals/CreateOrEditMuteList.tsx index 0c13f243a..736deae74 100644 --- a/src/view/com/modals/CreateOrEditMuteList.tsx +++ b/src/view/com/modals/CreateOrEditMuteList.tsx @@ -143,6 +143,7 @@ export function Component({ List Avatar ) } + if (type === 'list') { + // Font Awesome Pro 6.4.0 by @fontawesome -https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. + return ( + + + + + + ) + } return ( { - if (type === 'algo') { + if (type === 'algo' || type === 'list') { return { width: size, height: size, -- cgit 1.4.1 From 4fa4c67cc5ef856a9548bb4154f82aebf447e91d Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Thu, 18 May 2023 17:36:16 -0500 Subject: Some fixes --- src/view/com/posts/CustomFeedEmptyState.tsx | 16 ---------------- src/view/screens/Home.tsx | 1 - 2 files changed, 17 deletions(-) (limited to 'src') diff --git a/src/view/com/posts/CustomFeedEmptyState.tsx b/src/view/com/posts/CustomFeedEmptyState.tsx index 69dd79902..e51794e7c 100644 --- a/src/view/com/posts/CustomFeedEmptyState.tsx +++ b/src/view/com/posts/CustomFeedEmptyState.tsx @@ -9,14 +9,12 @@ import {Text} from '../util/text/Text' import {Button} from '../util/forms/Button' import {MagnifyingGlassIcon} from 'lib/icons' import {NavigationProp} from 'lib/routes/types' -import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' import {s} from 'lib/styles' export function CustomFeedEmptyState() { const pal = usePalette('default') const palInverted = usePalette('inverted') - const store = useStores() const navigation = useNavigation() const onPressFindAccounts = React.useCallback(() => { @@ -24,10 +22,6 @@ export function CustomFeedEmptyState() { navigation.popToTop() }, [navigation]) - const onPressSettings = React.useCallback(() => { - store.shell.openModal({name: 'content-languages-settings'}) - }, [store]) - return ( @@ -50,16 +44,6 @@ export function CustomFeedEmptyState() { size={14} /> - ) } diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index d761994f3..f8a497028 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -53,7 +53,6 @@ export const HomeScreen = withAuthRequired( model.setup() feeds.push(model) } - pagerRef.current?.setPage(0) setCustomFeeds(feeds) }, [store, store.me.savedFeeds.pinned, customFeeds, setCustomFeeds]) -- cgit 1.4.1 From 37acc9e9304b594ff21443e1be896e1e576bb488 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Thu, 18 May 2023 18:22:46 -0500 Subject: A few more UX tweaks --- src/lib/constants.ts | 14 ++++-------- src/view/com/feeds/SavedFeeds.tsx | 39 ++++++++++++++++++++++++-------- src/view/com/pager/FeedsTabBar.web.tsx | 2 +- src/view/com/pager/FeedsTabBarMobile.tsx | 2 +- 4 files changed, 36 insertions(+), 21 deletions(-) (limited to 'src') diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 88e429d83..f4c6f5021 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -110,26 +110,20 @@ export async function DEFAULT_FEEDS( } } else if (serviceUrl.includes('staging')) { return { - pinned: [ - STAGING_DEFAULT_FEED('skyline'), - STAGING_DEFAULT_FEED('whats-hot'), - ], + pinned: [STAGING_DEFAULT_FEED('whats-hot')], saved: [ STAGING_DEFAULT_FEED('bsky-team'), - STAGING_DEFAULT_FEED('skyline'), + STAGING_DEFAULT_FEED('with-friends'), STAGING_DEFAULT_FEED('whats-hot'), STAGING_DEFAULT_FEED('hot-classic'), ], } } else { return { - pinned: [ - STAGING_DEFAULT_FEED('skyline'), - STAGING_DEFAULT_FEED('whats-hot'), - ], + pinned: [STAGING_DEFAULT_FEED('whats-hot')], saved: [ STAGING_DEFAULT_FEED('bsky-team'), - STAGING_DEFAULT_FEED('skyline'), + STAGING_DEFAULT_FEED('with-friends'), STAGING_DEFAULT_FEED('whats-hot'), STAGING_DEFAULT_FEED('hot-classic'), ], diff --git a/src/view/com/feeds/SavedFeeds.tsx b/src/view/com/feeds/SavedFeeds.tsx index 110a6e894..e92e741da 100644 --- a/src/view/com/feeds/SavedFeeds.tsx +++ b/src/view/com/feeds/SavedFeeds.tsx @@ -8,7 +8,7 @@ import {FlatList} from 'view/com/util/Views' import {Text} from 'view/com/util/text/Text' import {isDesktopWeb} from 'platform/detection' import {s} from 'lib/styles' -import {Link} from 'view/com/util/Link' +import {Link, TextLink} from 'view/com/util/Link' import {CustomFeed} from './CustomFeed' export const SavedFeeds = observer( @@ -52,14 +52,35 @@ export const SavedFeeds = observer( const renderListFooterComponent = useCallback(() => { return ( - - - - Settings - - + <> + + + + Change Order + + + + + Feeds are custom algorithms that users build with a little coding + expertise.{' '} + {' '} + for more information. + + + ) }, [pal]) diff --git a/src/view/com/pager/FeedsTabBar.web.tsx b/src/view/com/pager/FeedsTabBar.web.tsx index 78937611b..fc04c3b2c 100644 --- a/src/view/com/pager/FeedsTabBar.web.tsx +++ b/src/view/com/pager/FeedsTabBar.web.tsx @@ -28,7 +28,7 @@ const FeedsTabBarDesktop = observer( ) => { const store = useStores() const items = useMemo( - () => ['Following', ...store.me.savedFeeds.pinnedFeedNames, 'My feeds'], + () => ['Following', ...store.me.savedFeeds.pinnedFeedNames, 'My Feeds'], [store.me.savedFeeds.pinnedFeedNames], ) const pal = usePalette('default') diff --git a/src/view/com/pager/FeedsTabBarMobile.tsx b/src/view/com/pager/FeedsTabBarMobile.tsx index a41f0ef32..5954e7f2e 100644 --- a/src/view/com/pager/FeedsTabBarMobile.tsx +++ b/src/view/com/pager/FeedsTabBarMobile.tsx @@ -33,7 +33,7 @@ export const FeedsTabBar = observer( }, [store]) const items = useMemo( - () => ['Following', ...store.me.savedFeeds.pinnedFeedNames, 'My feeds'], + () => ['Following', ...store.me.savedFeeds.pinnedFeedNames, 'My Feeds'], [store.me.savedFeeds.pinnedFeedNames], ) -- cgit 1.4.1 From 2a5ac1a6de6315a2e7d9af8c86d132667e3fec4c Mon Sep 17 00:00:00 2001 From: renahlee Date: Thu, 18 May 2023 17:29:46 -0700 Subject: Update labels for avatar --- src/view/com/pager/FeedsTabBarMobile.tsx | 4 ++-- src/view/com/search/HeaderWithInput.tsx | 4 ++-- src/view/com/util/ViewHeader.tsx | 4 +++- 3 files changed, 7 insertions(+), 5 deletions(-) (limited to 'src') diff --git a/src/view/com/pager/FeedsTabBarMobile.tsx b/src/view/com/pager/FeedsTabBarMobile.tsx index 725c44603..b42ffe726 100644 --- a/src/view/com/pager/FeedsTabBarMobile.tsx +++ b/src/view/com/pager/FeedsTabBarMobile.tsx @@ -39,8 +39,8 @@ export const FeedsTabBar = observer( style={styles.tabBarAvi} onPress={onPressAvi} accessibilityRole="button" - accessibilityLabel="Open navigation" - accessibilityHint="Access profile and other navigation links"> + accessibilityLabel="Menu" + accessibilityHint="Access navigation links and settings"> + accessibilityLabel="Menu" + accessibilityHint="Access navigation links and settings"> + accessibilityHint={ + canGoBack ? '' : 'Access navigation links and settings' + }> {canGoBack ? ( Date: Fri, 19 May 2023 18:27:13 -0700 Subject: fix refresh control color in ViewSelector.tsx --- src/view/com/util/ViewSelector.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/view/com/util/ViewSelector.tsx b/src/view/com/util/ViewSelector.tsx index f9ef0945d..c44d372f5 100644 --- a/src/view/com/util/ViewSelector.tsx +++ b/src/view/com/util/ViewSelector.tsx @@ -1,5 +1,5 @@ import React, {useEffect, useState} from 'react' -import {Pressable, StyleSheet, View} from 'react-native' +import {Pressable, RefreshControl, StyleSheet, View} from 'react-native' import {FlatList} from './Views' import {OnScrollCb} from 'lib/hooks/useOnMainScroll' import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle' @@ -41,6 +41,7 @@ export function ViewSelector({ onRefresh?: () => void onEndReached?: (info: {distanceFromEnd: number}) => void }) { + const pal = usePalette('default') const [selectedIndex, setSelectedIndex] = useState(0) // events @@ -97,6 +98,13 @@ export function ViewSelector({ onScroll={onScroll} onRefresh={onRefresh} onEndReached={onEndReached} + refreshControl={ + + } onEndReachedThreshold={0.6} contentContainerStyle={s.contentContainer} removeClippedSubviews={true} -- cgit 1.4.1 From 8bcbbb869af22e482cceaaf6c754c5c126a7a1a6 Mon Sep 17 00:00:00 2001 From: Ansh Nanda Date: Fri, 19 May 2023 18:30:24 -0700 Subject: fix dark mode color for creator handle on CustomFeed screen --- src/view/screens/CustomFeed.tsx | 1 + 1 file changed, 1 insertion(+) (limited to 'src') diff --git a/src/view/screens/CustomFeed.tsx b/src/view/screens/CustomFeed.tsx index d2b9041f9..7ff22f7f3 100644 --- a/src/view/screens/CustomFeed.tsx +++ b/src/view/screens/CustomFeed.tsx @@ -146,6 +146,7 @@ export const CustomFeedScreen = withAuthRequired( )} -- cgit 1.4.1 From 7cad7d12f1b6d97ae3395a2b3ce6ad0c102aea56 Mon Sep 17 00:00:00 2001 From: Ansh Nanda Date: Fri, 19 May 2023 18:32:21 -0700 Subject: add refreshControl to tab ViewSelector --- src/view/com/util/ViewSelector.tsx | 2 -- 1 file changed, 2 deletions(-) (limited to 'src') diff --git a/src/view/com/util/ViewSelector.tsx b/src/view/com/util/ViewSelector.tsx index c44d372f5..5b671d06c 100644 --- a/src/view/com/util/ViewSelector.tsx +++ b/src/view/com/util/ViewSelector.tsx @@ -94,9 +94,7 @@ export function ViewSelector({ ListFooterComponent={ListFooterComponent} // NOTE sticky header disabled on android due to major performance issues -prf stickyHeaderIndices={isAndroid ? undefined : STICKY_HEADER_INDICES} - refreshing={refreshing} onScroll={onScroll} - onRefresh={onRefresh} onEndReached={onEndReached} refreshControl={ Date: Mon, 22 May 2023 16:12:05 -0700 Subject: fix prod default feeds not working --- src/lib/constants.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) (limited to 'src') diff --git a/src/lib/constants.ts b/src/lib/constants.ts index f4c6f5021..e492dd61a 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -102,13 +102,13 @@ export async function DEFAULT_FEEDS( serviceUrl: string, resolveHandle: (name: string) => Promise, ) { - if (serviceUrl.includes('localhost')) { + if (serviceUrl.includes('localhost')) { // local dev const aliceDid = await resolveHandle('alice.test') return { pinned: [`at://${aliceDid}/app.bsky.feed.generator/alice-favs`], saved: [`at://${aliceDid}/app.bsky.feed.generator/alice-favs`], } - } else if (serviceUrl.includes('staging')) { + } else if (serviceUrl.includes('staging')) { // staging return { pinned: [STAGING_DEFAULT_FEED('whats-hot')], saved: [ @@ -118,14 +118,14 @@ export async function DEFAULT_FEEDS( STAGING_DEFAULT_FEED('hot-classic'), ], } - } else { + } else { // production return { - pinned: [STAGING_DEFAULT_FEED('whats-hot')], + pinned: [PROD_DEFAULT_FEED('whats-hot')], saved: [ - STAGING_DEFAULT_FEED('bsky-team'), - STAGING_DEFAULT_FEED('with-friends'), - STAGING_DEFAULT_FEED('whats-hot'), - STAGING_DEFAULT_FEED('hot-classic'), + PROD_DEFAULT_FEED('bsky-team'), + PROD_DEFAULT_FEED('with-friends'), + PROD_DEFAULT_FEED('whats-hot'), + PROD_DEFAULT_FEED('hot-classic'), ], } } -- cgit 1.4.1 From 64e303d911d351a2f492a23ae97207e5c6035b6e Mon Sep 17 00:00:00 2001 From: Ansh Nanda Date: Mon, 22 May 2023 16:35:37 -0700 Subject: optimistic updates for liking custom feeds --- src/lib/async/revertible.ts | 16 ++++++++++++++++ src/state/models/feeds/custom-feed.ts | 36 +++++++++++++++++++++++------------ 2 files changed, 40 insertions(+), 12 deletions(-) (limited to 'src') diff --git a/src/lib/async/revertible.ts b/src/lib/async/revertible.ts index 3c8e3e8f9..43383b61e 100644 --- a/src/lib/async/revertible.ts +++ b/src/lib/async/revertible.ts @@ -4,6 +4,22 @@ import set from 'lodash.set' const ongoingActions = new Set() +/** + * This is a TypeScript function that optimistically updates data on the client-side before sending a + * request to the server and rolling back changes if the request fails. + * @param {T} model - The object or record that needs to be updated optimistically. + * @param preUpdate - `preUpdate` is a function that is called before the server update is executed. It + * can be used to perform any necessary actions or updates on the model or UI before the server update + * is initiated. + * @param serverUpdate - `serverUpdate` is a function that returns a Promise representing the server + * update operation. This function is called after the previous state of the model has been recorded + * and the `preUpdate` function has been executed. If the server update is successful, the `postUpdate` + * function is called with the result + * @param [postUpdate] - `postUpdate` is an optional callback function that will be called after the + * server update is successful. It takes in the response from the server update as its parameter. If + * this parameter is not provided, nothing will happen after the server update. + * @returns A Promise that resolves to `void`. + */ export const updateDataOptimistically = async < T extends Record, U, diff --git a/src/state/models/feeds/custom-feed.ts b/src/state/models/feeds/custom-feed.ts index e457d2d1e..9ac69ac28 100644 --- a/src/state/models/feeds/custom-feed.ts +++ b/src/state/models/feeds/custom-feed.ts @@ -2,6 +2,7 @@ import {AppBskyFeedDefs} from '@atproto/api' import {makeAutoObservable, runInAction} from 'mobx' import {RootStoreModel} from 'state/models/root-store' import {sanitizeDisplayName} from 'lib/strings/display-names' +import {updateDataOptimistically} from 'lib/async/revertible' export class CustomFeedModel { // data @@ -58,12 +59,19 @@ export class CustomFeedModel { async like() { try { - const res = await this.rootStore.agent.like(this.data.uri, this.data.cid) - runInAction(() => { - this.data.viewer = this.data.viewer || {} - this.data.viewer.like = res.uri - this.data.likeCount = (this.data.likeCount || 0) + 1 - }) + await updateDataOptimistically( + this.data, + () => { + this.data.viewer = this.data.viewer || {} + this.data.viewer.like = 'pending' + this.data.likeCount = (this.data.likeCount || 0) + 1 + }, + () => this.rootStore.agent.like(this.data.uri, this.data.cid), + res => { + this.data.viewer = this.data.viewer || {} + this.data.viewer.like = res.uri + }, + ) } catch (e: any) { this.rootStore.log.error('Failed to like feed', e) } @@ -74,12 +82,16 @@ export class CustomFeedModel { return } try { - await this.rootStore.agent.deleteLike(this.data.viewer.like!) - runInAction(() => { - this.data.viewer = this.data.viewer || {} - this.data.viewer.like = undefined - this.data.likeCount = (this.data.likeCount || 1) - 1 - }) + const likeUri = this.data.viewer.like + await updateDataOptimistically( + this.data, + () => { + this.data.viewer = this.data.viewer || {} + this.data.viewer.like = undefined + this.data.likeCount = (this.data.likeCount || 1) - 1 + }, + () => this.rootStore.agent.deleteLike(likeUri), + ) } catch (e: any) { this.rootStore.log.error('Failed to unlike feed', e) } -- cgit 1.4.1 From dfcdd37087c0be4055c92a0f88431b32646ced6f Mon Sep 17 00:00:00 2001 From: Ansh Nanda Date: Mon, 22 May 2023 18:46:36 -0700 Subject: add haptics to save, like, and pin actions on feed --- src/lib/haptics.ts | 24 ++++++++++++++++++++++++ src/view/com/util/post-ctrls/PostCtrls.tsx | 15 +++++++-------- src/view/screens/CustomFeed.tsx | 5 +++++ src/view/screens/SavedFeeds.tsx | 16 ++++++++-------- 4 files changed, 44 insertions(+), 16 deletions(-) create mode 100644 src/lib/haptics.ts (limited to 'src') diff --git a/src/lib/haptics.ts b/src/lib/haptics.ts new file mode 100644 index 000000000..23a321796 --- /dev/null +++ b/src/lib/haptics.ts @@ -0,0 +1,24 @@ +import { isIOS } from 'platform/detection' +import ReactNativeHapticFeedback, { + HapticFeedbackTypes, +} from 'react-native-haptic-feedback' + + +const hapticImpact: HapticFeedbackTypes = isIOS ? 'impactMedium' : 'impactLight' // Users said the medium impact was too strong on Android; see APP-537s + + +export class Haptics { + static default = () => ReactNativeHapticFeedback.trigger(hapticImpact) + static impact = (type: HapticFeedbackTypes = hapticImpact) => ReactNativeHapticFeedback.trigger(type) + static selection = () => ReactNativeHapticFeedback.trigger('selection') + static notification = (type: 'success' | 'warning' | 'error') => { + switch (type) { + case 'success': + return ReactNativeHapticFeedback.trigger('notificationSuccess') + case 'warning': + return ReactNativeHapticFeedback.trigger('notificationWarning') + case 'error': + return ReactNativeHapticFeedback.trigger('notificationError') + } + } +} \ No newline at end of file diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx index 9980e9de0..0d2f83ce7 100644 --- a/src/view/com/util/post-ctrls/PostCtrls.tsx +++ b/src/view/com/util/post-ctrls/PostCtrls.tsx @@ -10,9 +10,6 @@ import { FontAwesomeIcon, FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' -import ReactNativeHapticFeedback, { - HapticFeedbackTypes, -} from 'react-native-haptic-feedback' // DISABLED see #135 // import { // TriggerableAnimated, @@ -24,8 +21,9 @@ import {HeartIcon, HeartIconSolid, CommentBottomArrow} from 'lib/icons' import {s, colors} from 'lib/styles' import {useTheme} from 'lib/ThemeContext' import {useStores} from 'state/index' -import {isIOS, isNative} from 'platform/detection' +import {isNative} from 'platform/detection' import {RepostButton} from './RepostButton' +import {Haptics} from 'lib/haptics' interface PostCtrlsOpts { itemUri: string @@ -58,7 +56,6 @@ interface PostCtrlsOpts { } const HITSLOP = {top: 5, left: 5, bottom: 5, right: 5} -const hapticImpact: HapticFeedbackTypes = isIOS ? 'impactMedium' : 'impactLight' // Users said the medium impact was too strong on Android; see APP-537 // DISABLED see #135 /* @@ -112,7 +109,7 @@ export function PostCtrls(opts: PostCtrlsOpts) { store.shell.closeModal() if (!opts.isReposted) { if (isNative) { - ReactNativeHapticFeedback.trigger(hapticImpact) + Haptics.default() } opts.onPressToggleRepost().catch(_e => undefined) // DISABLED see #135 @@ -141,7 +138,7 @@ export function PostCtrls(opts: PostCtrlsOpts) { }) if (isNative) { - ReactNativeHapticFeedback.trigger(hapticImpact) + Haptics.default() } }, [ opts.author, @@ -154,7 +151,9 @@ export function PostCtrls(opts: PostCtrlsOpts) { const onPressToggleLikeWrapper = async () => { if (!opts.isLiked) { - ReactNativeHapticFeedback.trigger(hapticImpact) + if (isNative) { + Haptics.default() + } await opts.onPressToggleLike().catch(_e => undefined) // DISABLED see #135 // likeRef.current?.trigger( diff --git a/src/view/screens/CustomFeed.tsx b/src/view/screens/CustomFeed.tsx index 7ff22f7f3..353995540 100644 --- a/src/view/screens/CustomFeed.tsx +++ b/src/view/screens/CustomFeed.tsx @@ -24,6 +24,9 @@ import {isDesktopWeb} from 'platform/detection' import {useSetTitle} from 'lib/hooks/useSetTitle' import {shareUrl} from 'lib/sharing' import {toShareUrl} from 'lib/strings/url-helpers' +import { Haptics } from 'lib/haptics' + +const HITSLOP = {top: 5, left: 5, bottom: 5, right: 5} type Props = NativeStackScreenProps export const CustomFeedScreen = withAuthRequired( @@ -49,6 +52,7 @@ export const CustomFeedScreen = withAuthRequired( const onToggleSaved = React.useCallback(async () => { try { + Haptics.default() if (currentFeed?.isSaved) { await currentFeed?.unsave() } else { @@ -63,6 +67,7 @@ export const CustomFeedScreen = withAuthRequired( }, [store, currentFeed]) const onToggleLiked = React.useCallback(async () => { + Haptics.default() try { if (currentFeed?.isLiked) { await currentFeed?.unlike() diff --git a/src/view/screens/SavedFeeds.tsx b/src/view/screens/SavedFeeds.tsx index 613e42fbf..0213b36a9 100644 --- a/src/view/screens/SavedFeeds.tsx +++ b/src/view/screens/SavedFeeds.tsx @@ -28,6 +28,7 @@ import {CustomFeed} from 'view/com/feeds/CustomFeed' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {CustomFeedModel} from 'state/models/feeds/custom-feed' import * as Toast from 'view/com/util/Toast' +import {Haptics} from 'lib/haptics' type Props = NativeStackScreenProps @@ -128,14 +129,13 @@ const ListItem = observer( const savedFeeds = useMemo(() => store.me.savedFeeds, [store]) const isPinned = savedFeeds.isPinned(item) - const onTogglePinned = useCallback( - () => - savedFeeds.togglePinnedFeed(item).catch(e => { - Toast.show('There was an issue contacting the server') - store.log.error('Failed to toggle pinned feed', {e}) - }), - [savedFeeds, item, store], - ) + const onTogglePinned = useCallback(() => { + Haptics.default() + savedFeeds.togglePinnedFeed(item).catch(e => { + Toast.show('There was an issue contacting the server') + store.log.error('Failed to toggle pinned feed', {e}) + }) + }, [savedFeeds, item, store]) const onPressUp = useCallback( () => savedFeeds.movePinnedFeed(item, 'up').catch(e => { -- cgit 1.4.1 From 512c918c033abf974260ea6a87a85ef14cdabe38 Mon Sep 17 00:00:00 2001 From: Ansh Nanda Date: Mon, 22 May 2023 19:10:03 -0700 Subject: decrease long press time required to reoreder pinned feed --- src/view/screens/SavedFeeds.tsx | 1 + 1 file changed, 1 insertion(+) (limited to 'src') diff --git a/src/view/screens/SavedFeeds.tsx b/src/view/screens/SavedFeeds.tsx index 0213b36a9..4a060c2fd 100644 --- a/src/view/screens/SavedFeeds.tsx +++ b/src/view/screens/SavedFeeds.tsx @@ -159,6 +159,7 @@ const ListItem = observer( {isPinned && isWeb ? ( -- cgit 1.4.1 From 8a2349c55ffcff4f833c015f9b10296aa9d77738 Mon Sep 17 00:00:00 2001 From: Ansh Nanda Date: Mon, 22 May 2023 19:14:10 -0700 Subject: increase pin button hitslop --- src/view/screens/SavedFeeds.tsx | 1 + 1 file changed, 1 insertion(+) (limited to 'src') diff --git a/src/view/screens/SavedFeeds.tsx b/src/view/screens/SavedFeeds.tsx index 4a060c2fd..2f9165b37 100644 --- a/src/view/screens/SavedFeeds.tsx +++ b/src/view/screens/SavedFeeds.tsx @@ -198,6 +198,7 @@ const ListItem = observer( /> Date: Mon, 22 May 2023 20:07:40 -0700 Subject: update pinned feed from custom feed view --- src/state/models/ui/saved-feeds.ts | 10 ++++++-- src/view/screens/CustomFeed.tsx | 52 +++++++++++++++++++++++++++++--------- 2 files changed, 48 insertions(+), 14 deletions(-) (limited to 'src') diff --git a/src/state/models/ui/saved-feeds.ts b/src/state/models/ui/saved-feeds.ts index 0d04f9c8d..244e75898 100644 --- a/src/state/models/ui/saved-feeds.ts +++ b/src/state/models/ui/saved-feeds.ts @@ -129,8 +129,14 @@ export class SavedFeedsModel { ) } - isPinned(feed: CustomFeedModel) { - return this.rootStore.preferences.pinnedFeeds.includes(feed.uri) + isPinned(feedOrUri: CustomFeedModel | string) { + let uri: string + if (typeof feedOrUri === 'string') { + uri = feedOrUri + } else { + uri = feedOrUri.uri + } + return this.rootStore.preferences.pinnedFeeds.includes(uri) } async movePinnedFeed(item: CustomFeedModel, direction: 'up' | 'down') { diff --git a/src/view/screens/CustomFeed.tsx b/src/view/screens/CustomFeed.tsx index 353995540..952461c9c 100644 --- a/src/view/screens/CustomFeed.tsx +++ b/src/view/screens/CustomFeed.tsx @@ -24,7 +24,7 @@ import {isDesktopWeb} from 'platform/detection' import {useSetTitle} from 'lib/hooks/useSetTitle' import {shareUrl} from 'lib/sharing' import {toShareUrl} from 'lib/strings/url-helpers' -import { Haptics } from 'lib/haptics' +import {Haptics} from 'lib/haptics' const HITSLOP = {top: 5, left: 5, bottom: 5, right: 5} @@ -47,6 +47,7 @@ export const CustomFeedScreen = withAuthRequired( feed.setup() return feed }, [store, uri]) + const isPinned = store.me.savedFeeds.isPinned(uri) useSetTitle(currentFeed?.displayName) @@ -65,7 +66,6 @@ export const CustomFeedScreen = withAuthRequired( store.log.error('Failed up update feeds', {err}) } }, [store, currentFeed]) - const onToggleLiked = React.useCallback(async () => { Haptics.default() try { @@ -81,6 +81,13 @@ export const CustomFeedScreen = withAuthRequired( store.log.error('Failed up toggle like', {err}) } }, [store, currentFeed]) + const onTogglePinned = React.useCallback(async () => { + Haptics.default() + store.me.savedFeeds.togglePinnedFeed(currentFeed!).catch(e => { + Toast.show('There was an issue contacting the server') + store.log.error('Failed to toggle pinned feed', {e}) + }) + }, [store, currentFeed]) const onPressShare = React.useCallback(() => { const url = toShareUrl(`/profile/${name}/feed/${rkey}`) shareUrl(url) @@ -212,15 +219,30 @@ export const CustomFeedScreen = withAuthRequired( {currentFeed.data.description} ) : null} - + + + + ) @@ -275,6 +298,11 @@ const styles = StyleSheet.create({ paddingHorizontal: 16, paddingBottom: 16, }, + headerDetailsFooter: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, fakeSelector: { flexDirection: 'row', paddingHorizontal: isDesktopWeb ? 16 : 6, -- cgit 1.4.1 From b561a51ed9f798194c3c6a72eefab562a773f2c9 Mon Sep 17 00:00:00 2001 From: Ansh Nanda Date: Tue, 23 May 2023 14:18:35 -0700 Subject: add button to reset preferences in dev mode --- src/state/models/ui/preferences.ts | 32 ++++++++++++++++++++++++++++++++ src/view/screens/Settings.tsx | 15 +++++++++++++++ 2 files changed, 47 insertions(+) (limited to 'src') diff --git a/src/state/models/ui/preferences.ts b/src/state/models/ui/preferences.ts index c85faf658..c4b6da0f6 100644 --- a/src/state/models/ui/preferences.ts +++ b/src/state/models/ui/preferences.ts @@ -63,6 +63,11 @@ export class PreferencesModel { } } + /** + * The function hydrates an object with properties related to content languages, labels, saved feeds, + * and pinned feeds that it gets from the parameter `v` (probably local storage) + * @param {unknown} v - the data object to hydrate from + */ hydrate(v: unknown) { if (isObj(v)) { if ( @@ -95,6 +100,9 @@ export class PreferencesModel { } } + /** + * This function fetches preferences and sets defaults for missing items. + */ async sync() { // fetch preferences let hasSavedFeedsPref = false @@ -153,6 +161,15 @@ export class PreferencesModel { } } + /** + * This function updates the preferences of a user and allows for a callback function to be executed + * before the update. + * @param cb - cb is a callback function that takes in a single parameter of type + * AppBskyActorDefs.Preferences and returns either a boolean or void. This callback function is used to + * update the preferences of the user. The function is called with the current preferences as an + * argument and if the callback returns false, the preferences are not updated. + * @returns void + */ async update(cb: (prefs: AppBskyActorDefs.Preferences) => boolean | void) { const res = await this.rootStore.agent.app.bsky.actor.getPreferences({}) if (cb(res.data.preferences) === false) { @@ -163,6 +180,21 @@ export class PreferencesModel { }) } + /** + * This function resets the preferences to an empty array of no preferences. + */ + async reset() { + runInAction(() => { + this.contentLabels = new LabelPreferencesModel() + this.contentLanguages = deviceLocales.map(locale => locale.languageCode) + this.savedFeeds = [] + this.pinnedFeeds = [] + }) + await this.rootStore.agent.app.bsky.actor.putPreferences({ + preferences: [], + }) + } + hasContentLanguage(code2: string) { return this.contentLanguages.includes(code2) } diff --git a/src/view/screens/Settings.tsx b/src/view/screens/Settings.tsx index 3ce41f8c0..ac4e5a9e0 100644 --- a/src/view/screens/Settings.tsx +++ b/src/view/screens/Settings.tsx @@ -141,6 +141,11 @@ export const SettingsScreen = withAuthRequired( store.shell.openModal({name: 'delete-account'}) }, [store]) + const onPressResetPreferences = React.useCallback(async () => { + await store.preferences.reset() + Toast.show('Preferences reset') + }, [store]) + return ( @@ -393,6 +398,16 @@ export const SettingsScreen = withAuthRequired( Storybook + {__DEV__ ? ( + + + Reset preferences state + + + ) : null} Build version {AppInfo.appVersion} ({AppInfo.buildVersion}) -- cgit 1.4.1 From fc9e28ca72ce498df8d0902c8e51d226affefd83 Mon Sep 17 00:00:00 2001 From: Ansh Nanda Date: Tue, 23 May 2023 15:28:46 -0700 Subject: slight performance improvements --- src/state/models/log.ts | 16 ++++++++++++++++ src/state/models/ui/preferences.ts | 8 ++++++-- src/state/models/ui/saved-feeds.ts | 4 ++++ src/view/screens/SavedFeeds.tsx | 9 +++++++-- 4 files changed, 33 insertions(+), 4 deletions(-) (limited to 'src') diff --git a/src/state/models/log.ts b/src/state/models/log.ts index d80617139..7c9c37c0d 100644 --- a/src/state/models/log.ts +++ b/src/state/models/log.ts @@ -27,6 +27,7 @@ function genId(): string { export class LogModel { entries: LogEntry[] = [] + timers = new Map() constructor() { makeAutoObservable(this) @@ -74,6 +75,21 @@ export class LogModel { ts: Date.now(), }) } + + time = (label = 'default') => { + this.timers.set(label, performance.now()) + } + + timeEnd = (label = 'default', warn = false) => { + const endTime = performance.now() + if (this.timers.has(label)) { + const elapsedTime = endTime - this.timers.get(label)! + console.log(`${label}: ${elapsedTime.toFixed(3)}ms`) + this.timers.delete(label) + } else { + warn && console.warn(`Timer with label '${label}' does not exist.`) + } + } } function detailsToStr(details?: any) { diff --git a/src/state/models/ui/preferences.ts b/src/state/models/ui/preferences.ts index c4b6da0f6..dcf6b9a7a 100644 --- a/src/state/models/ui/preferences.ts +++ b/src/state/models/ui/preferences.ts @@ -292,11 +292,15 @@ export class PreferencesModel { return res } + setFeeds(saved: string[], pinned: string[]) { + this.savedFeeds = saved + this.pinnedFeeds = pinned + } + async setSavedFeeds(saved: string[], pinned: string[]) { const oldSaved = this.savedFeeds const oldPinned = this.pinnedFeeds - this.savedFeeds = saved - this.pinnedFeeds = pinned + this.setFeeds(saved, pinned) try { await this.update((prefs: AppBskyActorDefs.Preferences) => { const existing = prefs.find( diff --git a/src/state/models/ui/saved-feeds.ts b/src/state/models/ui/saved-feeds.ts index 244e75898..979fddf49 100644 --- a/src/state/models/ui/saved-feeds.ts +++ b/src/state/models/ui/saved-feeds.ts @@ -47,6 +47,10 @@ export class SavedFeedsModel { return this.feeds.filter(f => !this.isPinned(f)) } + get all() { + return this.pinned.concat(this.unpinned) + } + get pinnedFeedNames() { return this.pinned.map(f => f.displayName) } diff --git a/src/view/screens/SavedFeeds.tsx b/src/view/screens/SavedFeeds.tsx index 2f9165b37..e305e6305 100644 --- a/src/view/screens/SavedFeeds.tsx +++ b/src/view/screens/SavedFeeds.tsx @@ -99,7 +99,7 @@ export const SavedFeeds = withAuthRequired( /> item.data.uri} refreshing={savedFeeds.isRefreshing} refreshControl={ @@ -111,6 +111,11 @@ export const SavedFeeds = withAuthRequired( /> } renderItem={({item, drag}) => } + getItemLayout={(data, index) => ({ + length: 77, + offset: 77 * index, + index, + })} initialNumToRender={10} ListFooterComponent={renderListFooterComponent} ListEmptyComponent={renderListEmptyComponent} @@ -198,7 +203,7 @@ const ListItem = observer( /> Date: Tue, 23 May 2023 15:33:27 -0700 Subject: refactor load latest btn --- src/view/com/util/load-latest/LoadLatestBtn.web.tsx | 4 ++-- src/view/com/util/load-latest/LoadLatestBtnMobile.tsx | 6 +++--- src/view/screens/Home.tsx | 2 +- src/view/screens/Notifications.tsx | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) (limited to 'src') diff --git a/src/view/com/util/load-latest/LoadLatestBtn.web.tsx b/src/view/com/util/load-latest/LoadLatestBtn.web.tsx index 839685029..85fb5a014 100644 --- a/src/view/com/util/load-latest/LoadLatestBtn.web.tsx +++ b/src/view/com/util/load-latest/LoadLatestBtn.web.tsx @@ -25,11 +25,11 @@ export const LoadLatestBtn = ({ onPress={onPress} hitSlop={HITSLOP} accessibilityRole="button" - accessibilityLabel={`Load new ${label}`} + accessibilityLabel={label} accessibilityHint=""> - Load new {label} + {label} ) diff --git a/src/view/com/util/load-latest/LoadLatestBtnMobile.tsx b/src/view/com/util/load-latest/LoadLatestBtnMobile.tsx index 5279696a2..548d30d5a 100644 --- a/src/view/com/util/load-latest/LoadLatestBtnMobile.tsx +++ b/src/view/com/util/load-latest/LoadLatestBtnMobile.tsx @@ -25,15 +25,15 @@ export const LoadLatestBtn = observer( onPress={onPress} hitSlop={HITSLOP} accessibilityRole="button" - accessibilityLabel={`Load new ${label}`} - accessibilityHint={`Loads new ${label}`}> + accessibilityLabel={label} + accessibilityHint={label}> - Load new {label} + {label} diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index f8a497028..4fe175fc1 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -258,7 +258,7 @@ const FeedPage = observer( headerOffset={HEADER_OFFSET} /> {feed.hasNewLatest && !feed.isRefreshing && ( - + )} {store.me.notifications.hasNewLatest && !store.me.notifications.isRefreshing && ( - + )} ) -- cgit 1.4.1 From 858ec6438da9cc9bee765857ea925f77e074fde2 Mon Sep 17 00:00:00 2001 From: Ansh Nanda Date: Tue, 23 May 2023 15:48:14 -0700 Subject: show scroll to top button when scrolling stops --- src/lib/hooks/useOnMainScroll.ts | 3 +++ src/view/com/posts/Feed.tsx | 8 +++++++- src/view/screens/CustomFeed.tsx | 20 ++++++++++++++++++-- 3 files changed, 28 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/src/lib/hooks/useOnMainScroll.ts b/src/lib/hooks/useOnMainScroll.ts index 41b35dd4f..994a35714 100644 --- a/src/lib/hooks/useOnMainScroll.ts +++ b/src/lib/hooks/useOnMainScroll.ts @@ -2,6 +2,9 @@ import {useState} from 'react' import {NativeSyntheticEvent, NativeScrollEvent} from 'react-native' import {RootStoreModel} from 'state/index' +export type onMomentumScrollEndCb = ( + event: NativeSyntheticEvent, +) => void export type OnScrollCb = ( event: NativeSyntheticEvent, ) => void diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx index 5b0110df8..50398e706 100644 --- a/src/view/com/posts/Feed.tsx +++ b/src/view/com/posts/Feed.tsx @@ -14,7 +14,7 @@ import {ErrorMessage} from '../util/error/ErrorMessage' import {PostsFeedModel} from 'state/models/feeds/posts' import {FeedSlice} from './FeedSlice' import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn' -import {OnScrollCb} from 'lib/hooks/useOnMainScroll' +import {OnScrollCb, onMomentumScrollEndCb} from 'lib/hooks/useOnMainScroll' import {s} from 'lib/styles' import {useAnalytics} from 'lib/analytics' import {usePalette} from 'lib/hooks/usePalette' @@ -31,6 +31,8 @@ export const Feed = observer(function Feed({ scrollElRef, onPressTryAgain, onScroll, + scrollEventThrottle, + onMomentumScrollEnd, renderEmptyState, testID, headerOffset = 0, @@ -43,6 +45,8 @@ export const Feed = observer(function Feed({ scrollElRef?: MutableRefObject | null> onPressTryAgain?: () => void onScroll?: OnScrollCb + scrollEventThrottle?: number + onMomentumScrollEnd?: onMomentumScrollEndCb renderEmptyState?: () => JSX.Element testID?: string headerOffset?: number @@ -180,6 +184,8 @@ export const Feed = observer(function Feed({ contentContainerStyle={s.contentContainer} style={{paddingTop: headerOffset}} onScroll={onScroll} + scrollEventThrottle={scrollEventThrottle} + onMomentumScrollEnd={onMomentumScrollEnd} onEndReached={onEndReached} onEndReachedThreshold={0.6} removeClippedSubviews={true} diff --git a/src/view/screens/CustomFeed.tsx b/src/view/screens/CustomFeed.tsx index 952461c9c..2316d7f06 100644 --- a/src/view/screens/CustomFeed.tsx +++ b/src/view/screens/CustomFeed.tsx @@ -1,4 +1,4 @@ -import React, {useMemo, useRef} from 'react' +import React, {useMemo, useRef, useState} from 'react' import {NativeStackScreenProps} from '@react-navigation/native-stack' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {usePalette} from 'lib/hooks/usePalette' @@ -25,6 +25,8 @@ import {useSetTitle} from 'lib/hooks/useSetTitle' import {shareUrl} from 'lib/sharing' import {toShareUrl} from 'lib/strings/url-helpers' import {Haptics} from 'lib/haptics' +import { LoadLatestBtn } from 'view/com/util/load-latest/LoadLatestBtn' +import { onMomentumScrollEndCb } from 'lib/hooks/useOnMainScroll' const HITSLOP = {top: 5, left: 5, bottom: 5, right: 5} @@ -48,7 +50,7 @@ export const CustomFeedScreen = withAuthRequired( return feed }, [store, uri]) const isPinned = store.me.savedFeeds.isPinned(uri) - + const [allowScrollToTop, setAllowScrollToTop] = useState(false) useSetTitle(currentFeed?.displayName) const onToggleSaved = React.useCallback(async () => { @@ -266,15 +268,29 @@ export const CustomFeedScreen = withAuthRequired( isPinned, ]) + const onMomentumScrollEnd: onMomentumScrollEndCb = React.useCallback((event) => { + if (event.nativeEvent.contentOffset.y > 200) { + setAllowScrollToTop(true) + } else { + setAllowScrollToTop(false) + } + }, []) + return ( + {allowScrollToTop ? { + scrollElRef.current?.scrollToOffset({offset: 0, animated: true}) + }} + label='Scroll to top' + /> : null} ) }), -- cgit 1.4.1 From 58a0489ce3028c6e6d205557e3edcf03c85f6626 Mon Sep 17 00:00:00 2001 From: Ansh Nanda Date: Wed, 24 May 2023 13:59:53 -0700 Subject: add isWeb check to disable haptics on web --- src/lib/haptics.ts | 30 +++++++++++++++++++++++------- src/view/com/util/post-ctrls/PostCtrls.tsx | 16 ++++------------ 2 files changed, 27 insertions(+), 19 deletions(-) (limited to 'src') diff --git a/src/lib/haptics.ts b/src/lib/haptics.ts index 23a321796..516940c1c 100644 --- a/src/lib/haptics.ts +++ b/src/lib/haptics.ts @@ -1,17 +1,33 @@ -import { isIOS } from 'platform/detection' +import {isIOS, isWeb} from 'platform/detection' import ReactNativeHapticFeedback, { HapticFeedbackTypes, } from 'react-native-haptic-feedback' - const hapticImpact: HapticFeedbackTypes = isIOS ? 'impactMedium' : 'impactLight' // Users said the medium impact was too strong on Android; see APP-537s - export class Haptics { - static default = () => ReactNativeHapticFeedback.trigger(hapticImpact) - static impact = (type: HapticFeedbackTypes = hapticImpact) => ReactNativeHapticFeedback.trigger(type) - static selection = () => ReactNativeHapticFeedback.trigger('selection') + static default() { + if (isWeb) { + return + } + ReactNativeHapticFeedback.trigger(hapticImpact) + } + static impact(type: HapticFeedbackTypes = hapticImpact) { + if (isWeb) { + return + } + ReactNativeHapticFeedback.trigger(type) + } + static selection() { + if (isWeb) { + return + } + ReactNativeHapticFeedback.trigger('selection') + } static notification = (type: 'success' | 'warning' | 'error') => { + if (isWeb) { + return + } switch (type) { case 'success': return ReactNativeHapticFeedback.trigger('notificationSuccess') @@ -21,4 +37,4 @@ export class Haptics { return ReactNativeHapticFeedback.trigger('notificationError') } } -} \ No newline at end of file +} diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx index 0d2f83ce7..41d66641f 100644 --- a/src/view/com/util/post-ctrls/PostCtrls.tsx +++ b/src/view/com/util/post-ctrls/PostCtrls.tsx @@ -21,7 +21,6 @@ import {HeartIcon, HeartIconSolid, CommentBottomArrow} from 'lib/icons' import {s, colors} from 'lib/styles' import {useTheme} from 'lib/ThemeContext' import {useStores} from 'state/index' -import {isNative} from 'platform/detection' import {RepostButton} from './RepostButton' import {Haptics} from 'lib/haptics' @@ -108,9 +107,7 @@ export function PostCtrls(opts: PostCtrlsOpts) { const onRepost = useCallback(() => { store.shell.closeModal() if (!opts.isReposted) { - if (isNative) { - Haptics.default() - } + Haptics.default() opts.onPressToggleRepost().catch(_e => undefined) // DISABLED see #135 // repostRef.current?.trigger( @@ -136,10 +133,7 @@ export function PostCtrls(opts: PostCtrlsOpts) { indexedAt: opts.indexedAt, }, }) - - if (isNative) { - Haptics.default() - } + Haptics.default() }, [ opts.author, opts.indexedAt, @@ -151,9 +145,7 @@ export function PostCtrls(opts: PostCtrlsOpts) { const onPressToggleLikeWrapper = async () => { if (!opts.isLiked) { - if (isNative) { - Haptics.default() - } + Haptics.default() await opts.onPressToggleLike().catch(_e => undefined) // DISABLED see #135 // likeRef.current?.trigger( @@ -200,7 +192,7 @@ export function PostCtrls(opts: PostCtrlsOpts) { accessibilityRole="button" accessibilityLabel={opts.isLiked ? 'Unlike' : 'Like'} accessibilityHint={ - opts.isReposted ? `Removes like from the post` : `Like the post` + opts.isReposted ? 'Removes like from the post' : 'Like the post' }> {opts.isLiked ? ( Date: Wed, 24 May 2023 14:18:49 -0700 Subject: fix scrollToTop for web --- src/view/com/posts/Feed.tsx | 3 +++ src/view/screens/CustomFeed.tsx | 51 +++++++++++++++++++++++++++-------------- 2 files changed, 37 insertions(+), 17 deletions(-) (limited to 'src') diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx index 50398e706..2726ff7d3 100644 --- a/src/view/com/posts/Feed.tsx +++ b/src/view/com/posts/Feed.tsx @@ -18,6 +18,7 @@ import {OnScrollCb, onMomentumScrollEndCb} from 'lib/hooks/useOnMainScroll' import {s} from 'lib/styles' import {useAnalytics} from 'lib/analytics' import {usePalette} from 'lib/hooks/usePalette' +import {useTheme} from 'lib/ThemeContext' const LOADING_ITEM = {_reactKey: '__loading__'} const EMPTY_FEED_ITEM = {_reactKey: '__empty__'} @@ -54,6 +55,7 @@ export const Feed = observer(function Feed({ extraData?: any }) { const pal = usePalette('default') + const theme = useTheme() const {track} = useAnalytics() const [isRefreshing, setIsRefreshing] = React.useState(false) @@ -186,6 +188,7 @@ export const Feed = observer(function Feed({ onScroll={onScroll} scrollEventThrottle={scrollEventThrottle} onMomentumScrollEnd={onMomentumScrollEnd} + indicatorStyle={theme.colorScheme === 'dark' ? 'white' : 'black'} onEndReached={onEndReached} onEndReachedThreshold={0.6} removeClippedSubviews={true} diff --git a/src/view/screens/CustomFeed.tsx b/src/view/screens/CustomFeed.tsx index 2316d7f06..dcb726873 100644 --- a/src/view/screens/CustomFeed.tsx +++ b/src/view/screens/CustomFeed.tsx @@ -20,15 +20,13 @@ import {ViewHeader} from 'view/com/util/ViewHeader' import {Button} from 'view/com/util/forms/Button' import {Text} from 'view/com/util/text/Text' import * as Toast from 'view/com/util/Toast' -import {isDesktopWeb} from 'platform/detection' +import {isDesktopWeb, isWeb} from 'platform/detection' import {useSetTitle} from 'lib/hooks/useSetTitle' import {shareUrl} from 'lib/sharing' import {toShareUrl} from 'lib/strings/url-helpers' import {Haptics} from 'lib/haptics' -import { LoadLatestBtn } from 'view/com/util/load-latest/LoadLatestBtn' -import { onMomentumScrollEndCb } from 'lib/hooks/useOnMainScroll' - -const HITSLOP = {top: 5, left: 5, bottom: 5, right: 5} +import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn' +import {OnScrollCb, onMomentumScrollEndCb} from 'lib/hooks/useOnMainScroll' type Props = NativeStackScreenProps export const CustomFeedScreen = withAuthRequired( @@ -257,22 +255,37 @@ export const CustomFeedScreen = withAuthRequired( ) }, [ - store.me.did, pal, currentFeed, - onToggleLiked, + store.me.did, onToggleSaved, + onToggleLiked, onPressShare, name, rkey, isPinned, + onTogglePinned, ]) - const onMomentumScrollEnd: onMomentumScrollEndCb = React.useCallback((event) => { - if (event.nativeEvent.contentOffset.y > 200) { - setAllowScrollToTop(true) - } else { - setAllowScrollToTop(false) + const onMomentumScrollEnd: onMomentumScrollEndCb = React.useCallback( + event => { + console.log('onMomentumScrollEnd') + if (event.nativeEvent.contentOffset.y > s.window.height * 3) { + setAllowScrollToTop(true) + } else { + setAllowScrollToTop(false) + } + }, + [], + ) + const onScroll: OnScrollCb = React.useCallback(event => { + // since onMomentumScrollEnd is not supported in react-native-web, we have to use onScroll which fires more often so is not desirable on mobile + if (isWeb) { + if (event.nativeEvent.contentOffset.y > s.window.height * 2) { + setAllowScrollToTop(true) + } else { + setAllowScrollToTop(false) + } } }, []) @@ -283,14 +296,18 @@ export const CustomFeedScreen = withAuthRequired( scrollElRef={scrollElRef} feed={algoFeed} onMomentumScrollEnd={onMomentumScrollEnd} + onScroll={onScroll} // same logic as onMomentumScrollEnd but for web ListHeaderComponent={renderListHeaderComponent} extraData={[uri, isPinned]} /> - {allowScrollToTop ? { - scrollElRef.current?.scrollToOffset({offset: 0, animated: true}) - }} - label='Scroll to top' - /> : null} + {allowScrollToTop ? ( + { + scrollElRef.current?.scrollToOffset({offset: 0, animated: true}) + }} + label="Scroll to top" + /> + ) : null} ) }), -- cgit 1.4.1 From 7e555ecc1b04fed96192d3c68b87cf679993abfa Mon Sep 17 00:00:00 2001 From: Ansh Nanda Date: Wed, 24 May 2023 15:00:36 -0700 Subject: fix lint errors --- src/lib/constants.ts | 9 ++++++--- src/view/screens/Notifications.tsx | 5 ++++- 2 files changed, 10 insertions(+), 4 deletions(-) (limited to 'src') diff --git a/src/lib/constants.ts b/src/lib/constants.ts index e492dd61a..c42e6f3a9 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -102,13 +102,15 @@ export async function DEFAULT_FEEDS( serviceUrl: string, resolveHandle: (name: string) => Promise, ) { - if (serviceUrl.includes('localhost')) { // local dev + if (serviceUrl.includes('localhost')) { + // local dev const aliceDid = await resolveHandle('alice.test') return { pinned: [`at://${aliceDid}/app.bsky.feed.generator/alice-favs`], saved: [`at://${aliceDid}/app.bsky.feed.generator/alice-favs`], } - } else if (serviceUrl.includes('staging')) { // staging + } else if (serviceUrl.includes('staging')) { + // staging return { pinned: [STAGING_DEFAULT_FEED('whats-hot')], saved: [ @@ -118,7 +120,8 @@ export async function DEFAULT_FEEDS( STAGING_DEFAULT_FEED('hot-classic'), ], } - } else { // production + } else { + // production return { pinned: [PROD_DEFAULT_FEED('whats-hot')], saved: [ diff --git a/src/view/screens/Notifications.tsx b/src/view/screens/Notifications.tsx index df84b541b..67507d009 100644 --- a/src/view/screens/Notifications.tsx +++ b/src/view/screens/Notifications.tsx @@ -98,7 +98,10 @@ export const NotificationsScreen = withAuthRequired( /> {store.me.notifications.hasNewLatest && !store.me.notifications.isRefreshing && ( - + )} ) -- cgit 1.4.1 From 32c9dabb7467149baf39d8f5c2eb3d0b81236d92 Mon Sep 17 00:00:00 2001 From: Ansh Nanda Date: Wed, 24 May 2023 15:04:30 -0700 Subject: make tab bar scroll view draggable on web --- src/lib/hooks/useDraggableScrollView.ts | 84 ++++++++++++++++++++++++++++++ src/lib/merge-refs.ts | 27 ++++++++++ src/view/com/pager/DraggableScrollView.tsx | 15 ++++++ src/view/com/pager/FeedsTabBar.web.tsx | 2 +- src/view/com/pager/TabBar.tsx | 5 +- 5 files changed, 130 insertions(+), 3 deletions(-) create mode 100644 src/lib/hooks/useDraggableScrollView.ts create mode 100644 src/lib/merge-refs.ts create mode 100644 src/view/com/pager/DraggableScrollView.tsx (limited to 'src') diff --git a/src/lib/hooks/useDraggableScrollView.ts b/src/lib/hooks/useDraggableScrollView.ts new file mode 100644 index 000000000..b0f7465d7 --- /dev/null +++ b/src/lib/hooks/useDraggableScrollView.ts @@ -0,0 +1,84 @@ +import {useEffect, useRef, useMemo, ForwardedRef} from 'react' +import {Platform, findNodeHandle} from 'react-native' +import type {ScrollView} from 'react-native' +import {mergeRefs} from 'lib/merge-refs' + +type Props = { + cursor?: string + outerRef?: ForwardedRef +} + +export function useDraggableScroll({ + outerRef, + cursor = 'grab', +}: Props = {}) { + const ref = useRef(null) + + useEffect(() => { + if (Platform.OS !== 'web' || !ref.current) { + return + } + const slider = findNodeHandle(ref.current) as unknown as HTMLDivElement + if (!slider) { + return + } + let isDragging = false + let isMouseDown = false + let startX = 0 + let scrollLeft = 0 + + const mouseDown = (e: MouseEvent) => { + isMouseDown = true + startX = e.pageX - slider.offsetLeft + scrollLeft = slider.scrollLeft + + slider.style.cursor = cursor + } + + const mouseUp = () => { + if (isDragging) { + slider.addEventListener('click', e => e.stopPropagation(), {once: true}) + } + + isMouseDown = false + isDragging = false + slider.style.cursor = 'default' + } + + const mouseMove = (e: MouseEvent) => { + if (!isMouseDown) { + return + } + + // Require n pixels momement before start of drag (3 in this case ) + const x = e.pageX - slider.offsetLeft + if (Math.abs(x - startX) < 3) { + return + } + + isDragging = true + e.preventDefault() + const walk = x - startX + slider.scrollLeft = scrollLeft - walk + } + + slider.addEventListener('mousedown', mouseDown) + window.addEventListener('mouseup', mouseUp) + window.addEventListener('mousemove', mouseMove) + + return () => { + slider.removeEventListener('mousedown', mouseDown) + window.removeEventListener('mouseup', mouseUp) + window.removeEventListener('mousemove', mouseMove) + } + }, [cursor]) + + const refs = useMemo( + () => mergeRefs(outerRef ? [ref, outerRef] : [ref]), + [ref, outerRef], + ) + + return { + refs, + } +} diff --git a/src/lib/merge-refs.ts b/src/lib/merge-refs.ts new file mode 100644 index 000000000..4617b5260 --- /dev/null +++ b/src/lib/merge-refs.ts @@ -0,0 +1,27 @@ +/** + * This TypeScript function merges multiple React refs into a single ref callback. + * When developing low level UI components, it is common to have to use a local ref + * but also support an external one using React.forwardRef. + * Natively, React does not offer a way to set two refs inside the ref property. This is the goal of this small utility. + * Today a ref can be a function or an object, tomorrow it could be another thing, who knows. + * This utility handles compatibility for you. + * This function is inspired by https://github.com/gregberge/react-merge-refs + * @param refs - An array of React refs, which can be either `React.MutableRefObject` or + * `React.LegacyRef`. These refs are used to store references to DOM elements or React components. + * The `mergeRefs` function takes in an array of these refs and returns a callback function that + * @returns The function `mergeRefs` is being returned. It takes an array of mutable or legacy refs and + * returns a ref callback function that can be used to merge multiple refs into a single ref. + */ +export function mergeRefs( + refs: Array | React.LegacyRef>, +): React.RefCallback { + return value => { + refs.forEach(ref => { + if (typeof ref === 'function') { + ref(value) + } else if (ref != null) { + ;(ref as React.MutableRefObject).current = value + } + }) + } +} diff --git a/src/view/com/pager/DraggableScrollView.tsx b/src/view/com/pager/DraggableScrollView.tsx new file mode 100644 index 000000000..4b7396eaa --- /dev/null +++ b/src/view/com/pager/DraggableScrollView.tsx @@ -0,0 +1,15 @@ +import {useDraggableScroll} from 'lib/hooks/useDraggableScrollView' +import React, {ComponentProps} from 'react' +import {ScrollView} from 'react-native' + +export const DraggableScrollView = React.forwardRef< + ScrollView, + ComponentProps +>(function DraggableScrollView(props, ref) { + const {refs} = useDraggableScroll({ + outerRef: ref, + cursor: 'grab', // optional, default + }) + + return +}) diff --git a/src/view/com/pager/FeedsTabBar.web.tsx b/src/view/com/pager/FeedsTabBar.web.tsx index fc04c3b2c..b51db1741 100644 --- a/src/view/com/pager/FeedsTabBar.web.tsx +++ b/src/view/com/pager/FeedsTabBar.web.tsx @@ -53,8 +53,8 @@ const FeedsTabBarDesktop = observer( // @ts-ignore the type signature for transform wrong here, translateX and translateY need to be in separate objects -prf diff --git a/src/view/com/pager/TabBar.tsx b/src/view/com/pager/TabBar.tsx index 485219730..cebf58b48 100644 --- a/src/view/com/pager/TabBar.tsx +++ b/src/view/com/pager/TabBar.tsx @@ -11,6 +11,7 @@ import {Text} from '../util/text/Text' import {PressableWithHover} from '../util/PressableWithHover' import {usePalette} from 'lib/hooks/usePalette' import {isDesktopWeb} from 'platform/detection' +import {DraggableScrollView} from './DraggableScrollView' export interface TabBarProps { testID?: string @@ -75,7 +76,7 @@ export function TabBar({ return ( - ) })} - + ) } -- cgit 1.4.1 From 4e1876fe85ab3a70eba50466a62bff8a9d01c16c Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Wed, 24 May 2023 18:46:27 -0500 Subject: Refactor the scroll-to-top UX --- src/lib/hooks/useOnMainScroll.ts | 58 ++++++++++++------ src/view/com/notifications/Feed.tsx | 1 + src/view/com/posts/Feed.tsx | 3 +- src/view/com/util/fab/FABInner.tsx | 2 +- .../com/util/load-latest/LoadLatestBtnMobile.tsx | 39 +++++------- src/view/screens/CustomFeed.tsx | 70 +++++++++------------- src/view/screens/Home.tsx | 11 ++-- src/view/screens/Notifications.tsx | 16 +++-- src/view/screens/SearchMobile.tsx | 2 +- 9 files changed, 102 insertions(+), 100 deletions(-) (limited to 'src') diff --git a/src/lib/hooks/useOnMainScroll.ts b/src/lib/hooks/useOnMainScroll.ts index 994a35714..782c4704b 100644 --- a/src/lib/hooks/useOnMainScroll.ts +++ b/src/lib/hooks/useOnMainScroll.ts @@ -1,28 +1,50 @@ -import {useState} from 'react' +import {useState, useCallback, useRef} from 'react' import {NativeSyntheticEvent, NativeScrollEvent} from 'react-native' import {RootStoreModel} from 'state/index' +import {s} from 'lib/styles' -export type onMomentumScrollEndCb = ( - event: NativeSyntheticEvent, -) => void export type OnScrollCb = ( event: NativeSyntheticEvent, ) => void +export type ResetCb = () => void + +export function useOnMainScroll( + store: RootStoreModel, +): [OnScrollCb, boolean, ResetCb] { + let lastY = useRef(0) + let [isScrolledDown, setIsScrolledDown] = useState(false) + return [ + useCallback( + (event: NativeSyntheticEvent) => { + const y = event.nativeEvent.contentOffset.y + const dy = y - (lastY.current || 0) + lastY.current = y -export function useOnMainScroll(store: RootStoreModel) { - let [lastY, setLastY] = useState(0) - let isMinimal = store.shell.minimalShellMode - return function onMainScroll(event: NativeSyntheticEvent) { - const y = event.nativeEvent.contentOffset.y - const dy = y - (lastY || 0) - setLastY(y) + if (!store.shell.minimalShellMode && y > 10 && dy > 10) { + store.shell.setMinimalShellMode(true) + } else if (store.shell.minimalShellMode && (y <= 10 || dy < -10)) { + store.shell.setMinimalShellMode(false) + } - if (!isMinimal && y > 10 && dy > 10) { - store.shell.setMinimalShellMode(true) - isMinimal = true - } else if (isMinimal && (y <= 10 || dy < -10)) { + if ( + !isScrolledDown && + event.nativeEvent.contentOffset.y > s.window.height + ) { + setIsScrolledDown(true) + } else if ( + isScrolledDown && + event.nativeEvent.contentOffset.y < s.window.height + ) { + setIsScrolledDown(false) + } + }, + [store, isScrolledDown], + ), + isScrolledDown, + useCallback(() => { + setIsScrolledDown(false) store.shell.setMinimalShellMode(false) - isMinimal = false - } - } + lastY.current = 1e8 // NOTE we set this very high so that the onScroll logic works right -prf + }, [store, setIsScrolledDown]), + ] } diff --git a/src/view/com/notifications/Feed.tsx b/src/view/com/notifications/Feed.tsx index 50bdc5dc9..d457d7136 100644 --- a/src/view/com/notifications/Feed.tsx +++ b/src/view/com/notifications/Feed.tsx @@ -154,6 +154,7 @@ export const Feed = observer(function Feed({ onEndReached={onEndReached} onEndReachedThreshold={0.6} onScroll={onScroll} + scrollEventThrottle={100} contentContainerStyle={s.contentContainer} /> ) : null} diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx index 2726ff7d3..b90213472 100644 --- a/src/view/com/posts/Feed.tsx +++ b/src/view/com/posts/Feed.tsx @@ -14,7 +14,7 @@ import {ErrorMessage} from '../util/error/ErrorMessage' import {PostsFeedModel} from 'state/models/feeds/posts' import {FeedSlice} from './FeedSlice' import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn' -import {OnScrollCb, onMomentumScrollEndCb} from 'lib/hooks/useOnMainScroll' +import {OnScrollCb} from 'lib/hooks/useOnMainScroll' import {s} from 'lib/styles' import {useAnalytics} from 'lib/analytics' import {usePalette} from 'lib/hooks/usePalette' @@ -47,7 +47,6 @@ export const Feed = observer(function Feed({ onPressTryAgain?: () => void onScroll?: OnScrollCb scrollEventThrottle?: number - onMomentumScrollEnd?: onMomentumScrollEndCb renderEmptyState?: () => JSX.Element testID?: string headerOffset?: number diff --git a/src/view/com/util/fab/FABInner.tsx b/src/view/com/util/fab/FABInner.tsx index 5eb4a6588..76824e575 100644 --- a/src/view/com/util/fab/FABInner.tsx +++ b/src/view/com/util/fab/FABInner.tsx @@ -47,7 +47,7 @@ const styles = StyleSheet.create({ outer: { position: 'absolute', zIndex: 1, - right: 28, + right: 24, bottom: 94, width: 60, height: 60, diff --git a/src/view/com/util/load-latest/LoadLatestBtnMobile.tsx b/src/view/com/util/load-latest/LoadLatestBtnMobile.tsx index 548d30d5a..5e03e2285 100644 --- a/src/view/com/util/load-latest/LoadLatestBtnMobile.tsx +++ b/src/view/com/util/load-latest/LoadLatestBtnMobile.tsx @@ -1,23 +1,25 @@ import React from 'react' import {StyleSheet, TouchableOpacity} from 'react-native' import {observer} from 'mobx-react-lite' -import LinearGradient from 'react-native-linear-gradient' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {useSafeAreaInsets} from 'react-native-safe-area-context' -import {Text} from '../text/Text' -import {colors, gradients} from 'lib/styles' import {clamp} from 'lodash' import {useStores} from 'state/index' +import {usePalette} from 'lib/hooks/usePalette' const HITSLOP = {left: 20, top: 20, right: 20, bottom: 20} export const LoadLatestBtn = observer( ({onPress, label}: {onPress: () => void; label: string}) => { const store = useStores() + const pal = usePalette('default') const safeAreaInsets = useSafeAreaInsets() return ( - - - {label} - - + accessibilityHint=""> + ) }, @@ -44,19 +38,14 @@ export const LoadLatestBtn = observer( const styles = StyleSheet.create({ loadLatest: { position: 'absolute', - left: 20, + left: 18, bottom: 35, - shadowColor: '#000', - shadowOpacity: 0.3, - shadowOffset: {width: 0, height: 1}, - }, - loadLatestInner: { + borderWidth: 1, + width: 52, + height: 52, + borderRadius: 26, flexDirection: 'row', - paddingHorizontal: 14, - paddingVertical: 10, - borderRadius: 30, - }, - loadLatestText: { - color: colors.white, + alignItems: 'center', + justifyContent: 'center', }, }) diff --git a/src/view/screens/CustomFeed.tsx b/src/view/screens/CustomFeed.tsx index dcb726873..1409762d1 100644 --- a/src/view/screens/CustomFeed.tsx +++ b/src/view/screens/CustomFeed.tsx @@ -20,13 +20,13 @@ import {ViewHeader} from 'view/com/util/ViewHeader' import {Button} from 'view/com/util/forms/Button' import {Text} from 'view/com/util/text/Text' import * as Toast from 'view/com/util/Toast' -import {isDesktopWeb, isWeb} from 'platform/detection' +import {isDesktopWeb} from 'platform/detection' import {useSetTitle} from 'lib/hooks/useSetTitle' import {shareUrl} from 'lib/sharing' import {toShareUrl} from 'lib/strings/url-helpers' import {Haptics} from 'lib/haptics' import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn' -import {OnScrollCb, onMomentumScrollEndCb} from 'lib/hooks/useOnMainScroll' +import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' type Props = NativeStackScreenProps export const CustomFeedScreen = withAuthRequired( @@ -48,7 +48,8 @@ export const CustomFeedScreen = withAuthRequired( return feed }, [store, uri]) const isPinned = store.me.savedFeeds.isPinned(uri) - const [allowScrollToTop, setAllowScrollToTop] = useState(false) + const [onMainScroll, isScrolledDown, resetMainScroll] = + useOnMainScroll(store) useSetTitle(currentFeed?.displayName) const onToggleSaved = React.useCallback(async () => { @@ -66,6 +67,7 @@ export const CustomFeedScreen = withAuthRequired( store.log.error('Failed up update feeds', {err}) } }, [store, currentFeed]) + const onToggleLiked = React.useCallback(async () => { Haptics.default() try { @@ -81,6 +83,7 @@ export const CustomFeedScreen = withAuthRequired( store.log.error('Failed up toggle like', {err}) } }, [store, currentFeed]) + const onTogglePinned = React.useCallback(async () => { Haptics.default() store.me.savedFeeds.togglePinnedFeed(currentFeed!).catch(e => { @@ -88,11 +91,17 @@ export const CustomFeedScreen = withAuthRequired( store.log.error('Failed to toggle pinned feed', {e}) }) }, [store, currentFeed]) + const onPressShare = React.useCallback(() => { const url = toShareUrl(`/profile/${name}/feed/${rkey}`) shareUrl(url) }, [name, rkey]) + const onScrollToTop = React.useCallback(() => { + scrollElRef.current?.scrollToOffset({offset: 0, animated: true}) + resetMainScroll() + }, [scrollElRef, resetMainScroll]) + const renderHeaderBtns = React.useCallback(() => { return ( @@ -220,15 +229,17 @@ export const CustomFeedScreen = withAuthRequired( ) : null} - + {currentFeed ? ( + + ) : null} diff --git a/src/view/screens/CustomFeed.tsx b/src/view/screens/CustomFeed.tsx index f406c43d5..0ade47c51 100644 --- a/src/view/screens/CustomFeed.tsx +++ b/src/view/screens/CustomFeed.tsx @@ -28,6 +28,7 @@ import {Haptics} from 'lib/haptics' import {ComposeIcon2} from 'lib/icons' import {FAB} from '../com/util/fab/FAB' import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn' +import {DropdownButton, DropdownItem} from 'view/com/util/forms/DropdownButton' import {useOnMainScroll} from 'lib/hooks/useOnMainScroll' type Props = NativeStackScreenProps @@ -108,6 +109,22 @@ export const CustomFeedScreen = withAuthRequired( store.shell.openComposer({}) }, [store]) + const dropdownItems: DropdownItem[] = React.useMemo(() => { + let items: DropdownItem[] = [ + { + testID: 'feedHeaderDropdownRemoveBtn', + label: 'Remove from my feeds', + onPress: onToggleSaved, + }, + { + testID: 'feedHeaderDropdownShareBtn', + label: 'Share link', + onPress: onPressShare, + }, + ] + return items + }, [onToggleSaved, onPressShare]) + const renderHeaderBtns = React.useCallback(() => { return ( @@ -132,25 +149,46 @@ export const CustomFeedScreen = withAuthRequired( )} + {currentFeed?.isSaved ? ( + + + + ) : ( + @@ -286,7 +325,7 @@ export const CustomFeedScreen = withAuthRequired( return ( - + Date: Wed, 24 May 2023 19:27:04 -0500 Subject: Tune the custom feeds header a bit more --- src/view/screens/CustomFeed.tsx | 70 +++++++++++++++++++++++------------------ 1 file changed, 39 insertions(+), 31 deletions(-) (limited to 'src') diff --git a/src/view/screens/CustomFeed.tsx b/src/view/screens/CustomFeed.tsx index 0ade47c51..0690a17d8 100644 --- a/src/view/screens/CustomFeed.tsx +++ b/src/view/screens/CustomFeed.tsx @@ -129,53 +129,57 @@ export const CustomFeedScreen = withAuthRequired( return ( - - + {currentFeed?.isSaved ? ( + + ) : undefined} {currentFeed?.isSaved ? ( - + ) : ( )} ) @@ -187,7 +191,6 @@ export const CustomFeedScreen = withAuthRequired( onToggleSaved, onTogglePinned, onToggleLiked, - onPressShare, dropdownItems, ]) @@ -361,8 +364,13 @@ const styles = StyleSheet.create({ }, headerBtns: { flexDirection: 'row', - gap: 12, - marginTop: 10, + alignItems: 'center', + }, + headerAddBtn: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + paddingLeft: 4, }, headerDetails: { paddingHorizontal: 16, -- cgit 1.4.1 From dfb39e7c4fcaff3effcc82b412191177fdfdaf22 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Wed, 24 May 2023 22:09:39 -0500 Subject: Add feed discovery page --- bskyweb/cmd/bskyweb/server.go | 1 + package.json | 2 +- src/Navigation.tsx | 6 ++ src/lib/routes/types.ts | 1 + src/routes.ts | 1 + src/state/models/discovery/feeds.ts | 97 ++++++++++++++++++++++++++++++++ src/view/com/feeds/SavedFeeds.tsx | 41 +++++++++----- src/view/screens/CustomFeed.tsx | 6 +- src/view/screens/DiscoverFeeds.tsx | 109 ++++++++++++++++++++++++++++++++++++ yarn.lock | 8 +-- 10 files changed, 252 insertions(+), 20 deletions(-) create mode 100644 src/state/models/discovery/feeds.ts create mode 100644 src/view/screens/DiscoverFeeds.tsx (limited to 'src') diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go index 07df85146..462740f54 100644 --- a/bskyweb/cmd/bskyweb/server.go +++ b/bskyweb/cmd/bskyweb/server.go @@ -105,6 +105,7 @@ func serve(cctx *cli.Context) error { // generic routes e.GET("/search", server.WebGeneric) + e.GET("/search/feeds", server.WebGeneric) e.GET("/notifications", server.WebGeneric) e.GET("/moderation", server.WebGeneric) e.GET("/moderation/mute-lists", server.WebGeneric) diff --git a/package.json b/package.json index 5c30a0cae..253c3b782 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "e2e:run": "detox test --configuration ios.sim.debug --take-screenshots all" }, "dependencies": { - "@atproto/api": "0.3.7", + "@atproto/api": "0.3.8", "@bam.tech/react-native-image-resizer": "^3.0.4", "@braintree/sanitize-url": "^6.0.2", "@expo/webpack-config": "^18.0.1", diff --git a/src/Navigation.tsx b/src/Navigation.tsx index ff7a5f5c2..0664ac526 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -35,6 +35,7 @@ import {SearchScreen} from './view/screens/Search' 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' @@ -103,6 +104,11 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) { component={ModerationBlockedAccounts} options={{title: title('Blocked Accounts')}} /> + 0 + } + + get hasError() { + return this.error !== '' + } + + get isEmpty() { + return this.hasLoaded && !this.hasContent + } + + // public api + // = + + refresh = bundleAsync(async () => { + this._xLoading() + try { + const res = + await this.rootStore.agent.app.bsky.unspecced.getPopularFeedGenerators( + {}, + ) + this._replaceAll(res) + this._xIdle() + } catch (e: any) { + this._xIdle(e) + } + }) + + clear() { + this.isLoading = false + this.isRefreshing = false + this.hasLoaded = false + this.error = '' + this.feeds = [] + } + + // state transitions + // = + + _xLoading() { + this.isLoading = true + this.isRefreshing = true + this.error = '' + } + + _xIdle(err?: any) { + this.isLoading = false + this.isRefreshing = false + this.hasLoaded = true + this.error = cleanError(err) + if (err) { + this.rootStore.log.error('Failed to fetch popular feeds', err) + } + } + + // helper functions + // = + + _replaceAll(res: AppBskyUnspeccedGetPopularFeedGenerators.Response) { + this.feeds = [] + for (const f of res.data.feeds) { + this.feeds.push(new CustomFeedModel(this.rootStore, f)) + } + } +} diff --git a/src/view/com/feeds/SavedFeeds.tsx b/src/view/com/feeds/SavedFeeds.tsx index e92e741da..610562c9d 100644 --- a/src/view/com/feeds/SavedFeeds.tsx +++ b/src/view/com/feeds/SavedFeeds.tsx @@ -53,14 +53,28 @@ export const SavedFeeds = observer( const renderListFooterComponent = useCallback(() => { return ( <> - - - - Change Order - - + + + + + Discover new feeds + + + {!store.me.savedFeeds.isEmpty && ( + + + + Change Order + + + )} + ) - }, [pal]) + }, [pal, store.me.savedFeeds.isEmpty]) const renderItem = useCallback( ({item}) => , @@ -118,14 +132,16 @@ export const SavedFeeds = observer( ) const styles = StyleSheet.create({ + footerLinks: { + marginTop: 8, + borderBottomWidth: 1, + }, footerLink: { flexDirection: 'row', borderTopWidth: 1, - borderBottomWidth: 1, paddingHorizontal: 26, paddingVertical: 18, gap: 18, - marginTop: 8, }, empty: { paddingHorizontal: 18, @@ -134,7 +150,4 @@ const styles = StyleSheet.create({ marginHorizontal: 18, marginTop: 10, }, - feedItem: { - borderTopWidth: 1, - }, }) diff --git a/src/view/screens/CustomFeed.tsx b/src/view/screens/CustomFeed.tsx index 0690a17d8..49798d758 100644 --- a/src/view/screens/CustomFeed.tsx +++ b/src/view/screens/CustomFeed.tsx @@ -220,7 +220,7 @@ export const CustomFeedScreen = withAuthRequired( )} {isDesktopWeb && ( - +