diff options
author | Paul Frazee <pfrazee@gmail.com> | 2023-11-01 16:15:40 -0700 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-11-01 16:15:40 -0700 |
commit | f57a8cf8ba0cd10a54abf35d960d8fb90266fa6b (patch) | |
tree | a9da6032bcbd587d92fd1030e698aea2dbef9f72 /src | |
parent | f9944b55e26fe6109bc2e7a25b88979111470ed9 (diff) | |
download | voidsky-f57a8cf8ba0cd10a54abf35d960d8fb90266fa6b.tar.zst |
Lists updates: curate lists and blocklists (#1689)
* Add lists screen * Update Lists screen and List create/edit modal to support curate lists * Rework the ProfileList screen and add curatelist support * More ProfileList progress * Update list modals * Rename mutelists to modlists * Layout updates/fixes * More layout fixes * Modal fixes * List list screen updates * Update feed page to give more info * Layout fixes to ListAddUser modal * Layout fixes to FlatList and Feed on desktop * Layout fix to LoadLatestBtn on Web * Handle did resolution before showing the ProfileList screen * Rename the CustomFeed routes to ProfileFeed for consistency * Fix layout issues with the pager and feeds * Factor out some common code * Fix UIs for mobile * Fix user list rendering * Fix: dont bubble custom feed errors in the merge feed * Refactor feed models to reduce usage of the SavedFeeds model * Replace CustomFeedModel with FeedSourceModel which abstracts feed-generators and lists * Add the ability to pin lists * Add pinned lists to mobile * Remove dead code * Rework the ProfileScreenHeader to create more real-estate for action buttons * Improve layout behavior on web mobile breakpoints * Refactor feed & list pages to use new Tabs layout component * Refactor to ProfileSubpageHeader * Implement modlist block and mute * Switch to new api and just modify state on modlist actions * Fix some UI overflows * Fix: dont show edit buttons on lists you dont own * Fix alignment issue on long titles * Improve loading and error states for feeds & lists * Update list dropdown icons for ios * Fetch feed display names in the mergefeed * Improve rendering off offline feeds in the feed-listing page * Update Feeds listing UI to react to changes in saved/pinned state * Refresh list and feed on posts tab press * Fix pinned feed ordering UI * Fixes to list pinning * Remove view=simple qp * Add list to feed tuners * Render richtext * Add list href * Add 'view avatar' * Remove unused import * Fix missing import * Correctly reflect block by list state * Replace the <Tabs> component with the more effective <PagerWithHeader> component * Improve the responsiveness of the PagerWithHeader * Fix visual jank in the feed loading state * Improve performance of the PagerWithHeader * Fix a case that would cause the header to animate too aggressively * Add the ability to scroll to top by tapping the selected tab * Fix unit test runner * Update modlists test * Add curatelist tests * Fix: remove link behavior in ListAddUser modal * Fix some layout jank in the PagerWithHeader on iOS * Simplify ListItems header rendering * Wait for the appview to recognize the list before proceeding with list creation * Fix glitch in the onPageSelecting index of the Pager * Fix until() * Copy fix Co-authored-by: Eric Bailey <git@esb.lol> --------- Co-authored-by: Eric Bailey <git@esb.lol>
Diffstat (limited to 'src')
82 files changed, 3768 insertions, 1919 deletions
diff --git a/src/Navigation.tsx b/src/Navigation.tsx index e481d966e..49a2c429d 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -43,16 +43,17 @@ import {HomeScreen} from './view/screens/Home' import {SearchScreen} from './view/screens/Search' import {FeedsScreen} from './view/screens/Feeds' import {NotificationsScreen} from './view/screens/Notifications' +import {ListsScreen} from './view/screens/Lists' import {ModerationScreen} from './view/screens/Moderation' -import {ModerationMuteListsScreen} from './view/screens/ModerationMuteLists' +import {ModerationModlistsScreen} from './view/screens/ModerationModlists' import {NotFoundScreen} from './view/screens/NotFound' import {SettingsScreen} from './view/screens/Settings' import {LanguageSettingsScreen} from './view/screens/LanguageSettings' import {ProfileScreen} from './view/screens/Profile' import {ProfileFollowersScreen} from './view/screens/ProfileFollowers' import {ProfileFollowsScreen} from './view/screens/ProfileFollows' -import {CustomFeedScreen} from './view/screens/CustomFeed' -import {CustomFeedLikedByScreen} from './view/screens/CustomFeedLikedBy' +import {ProfileFeedScreen} from './view/screens/ProfileFeed' +import {ProfileFeedLikedByScreen} from './view/screens/ProfileFeedLikedBy' import {ProfileListScreen} from './view/screens/ProfileList' import {PostThreadScreen} from './view/screens/PostThread' import {PostLikedByScreen} from './view/screens/PostLikedBy' @@ -96,14 +97,19 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) { options={{title: title('Not Found')}} /> <Stack.Screen + name="Lists" + component={ListsScreen} + options={{title: title('Lists')}} + /> + <Stack.Screen name="Moderation" getComponent={() => ModerationScreen} options={{title: title('Moderation')}} /> <Stack.Screen - name="ModerationMuteLists" - getComponent={() => ModerationMuteListsScreen} - options={{title: title('Mute Lists')}} + name="ModerationModlists" + getComponent={() => ModerationModlistsScreen} + options={{title: title('Moderation Lists')}} /> <Stack.Screen name="ModerationMutedAccounts" @@ -150,7 +156,7 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) { <Stack.Screen name="ProfileList" getComponent={() => ProfileListScreen} - options={{title: title('Mute List')}} + options={{title: title('List')}} /> <Stack.Screen name="PostThread" @@ -168,13 +174,13 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) { options={({route}) => ({title: title(`Post by @${route.params.name}`)})} /> <Stack.Screen - name="CustomFeed" - getComponent={() => CustomFeedScreen} + name="ProfileFeed" + getComponent={() => ProfileFeedScreen} options={{title: title('Feed')}} /> <Stack.Screen - name="CustomFeedLikedBy" - getComponent={() => CustomFeedLikedByScreen} + name="ProfileFeedLikedBy" + getComponent={() => ProfileFeedLikedByScreen} options={{title: title('Liked by')}} /> <Stack.Screen diff --git a/src/lib/analytics/types.ts b/src/lib/analytics/types.ts index d10475908..b2c6f15d6 100644 --- a/src/lib/analytics/types.ts +++ b/src/lib/analytics/types.ts @@ -97,10 +97,13 @@ interface TrackPropertiesMap { // LISTS events 'Lists:onRefresh': {} 'Lists:onEndReached': {} - 'CreateMuteList:AvatarSelected': {} - 'CreateMuteList:Save': {} // CAN BE SERVER - 'Lists:Subscribe': {} // CAN BE SERVER - 'Lists:Unsubscribe': {} // CAN BE SERVER + 'CreateList:AvatarSelected': {} + 'CreateList:SaveCurateList': {} // CAN BE SERVER + 'CreateList:SaveModList': {} // CAN BE SERVER + 'Lists:Mute': {} // CAN BE SERVER + 'Lists:Unmute': {} // CAN BE SERVER + 'Lists:Block': {} // CAN BE SERVER + 'Lists:Unblock': {} // CAN BE SERVER // CUSTOM FEED events 'CustomFeed:Save': {} 'CustomFeed:Unsave': {} diff --git a/src/lib/api/feed/list.ts b/src/lib/api/feed/list.ts new file mode 100644 index 000000000..e58494675 --- /dev/null +++ b/src/lib/api/feed/list.ts @@ -0,0 +1,45 @@ +import { + AppBskyFeedDefs, + AppBskyFeedGetListFeed as GetListFeed, +} from '@atproto/api' +import {RootStoreModel} from 'state/index' +import {FeedAPI, FeedAPIResponse} from './types' + +export class ListFeedAPI implements FeedAPI { + cursor: string | undefined + + constructor( + public rootStore: RootStoreModel, + public params: GetListFeed.QueryParams, + ) {} + + reset() { + this.cursor = undefined + } + + async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> { + const res = await this.rootStore.agent.app.bsky.feed.getListFeed({ + ...this.params, + limit: 1, + }) + return res.data.feed[0] + } + + async fetchNext({limit}: {limit: number}): Promise<FeedAPIResponse> { + const res = await this.rootStore.agent.app.bsky.feed.getListFeed({ + ...this.params, + cursor: this.cursor, + limit, + }) + if (res.success) { + this.cursor = res.data.cursor + return { + cursor: res.data.cursor, + feed: res.data.feed, + } + } + return { + feed: [], + } + } +} diff --git a/src/lib/api/feed/merge.ts b/src/lib/api/feed/merge.ts index 31e27fece..e0fbcecd8 100644 --- a/src/lib/api/feed/merge.ts +++ b/src/lib/api/feed/merge.ts @@ -114,13 +114,8 @@ export class MergeFeedAPI implements FeedAPI { } if (this.customFeeds.length === 0) { this.customFeeds = shuffle( - this.rootStore.me.savedFeeds.all.map( - feed => - new MergeFeedSource_Custom( - this.rootStore, - feed.uri, - feed.displayName, - ), + this.rootStore.preferences.savedFeeds.map( + feedUri => new MergeFeedSource_Custom(this.rootStore, feedUri), ), ) } @@ -213,43 +208,56 @@ class MergeFeedSource_Following extends MergeFeedSource { class MergeFeedSource_Custom extends MergeFeedSource { minDate: Date - constructor( - public rootStore: RootStoreModel, - public feedUri: string, - public feedDisplayName: string, - ) { + constructor(public rootStore: RootStoreModel, public feedUri: string) { super(rootStore) this.sourceInfo = { - displayName: feedDisplayName, + displayName: feedUri.split('/').pop() || '', uri: feedUriToHref(feedUri), } this.minDate = new Date(Date.now() - POST_AGE_CUTOFF) + this.rootStore.agent.app.bsky.feed + .getFeedGenerator({ + feed: feedUri, + }) + .then( + res => { + if (this.sourceInfo) { + this.sourceInfo.displayName = res.data.view.displayName + } + }, + _err => {}, + ) } protected async _getFeed( cursor: string | undefined, limit: number, ): Promise<AppBskyFeedGetTimeline.Response> { - const res = await this.rootStore.agent.app.bsky.feed.getFeed({ - cursor, - limit, - feed: this.feedUri, - }) - // NOTE - // some custom feeds fail to enforce the pagination limit - // so we manually truncate here - // -prf - if (limit && res.data.feed.length > limit) { - res.data.feed = res.data.feed.slice(0, limit) - } - // filter out older posts - res.data.feed = res.data.feed.filter( - post => new Date(post.post.indexedAt) > this.minDate, - ) - // attach source info - for (const post of res.data.feed) { - post.__source = this.sourceInfo + try { + const res = await this.rootStore.agent.app.bsky.feed.getFeed({ + cursor, + limit, + feed: this.feedUri, + }) + // NOTE + // some custom feeds fail to enforce the pagination limit + // so we manually truncate here + // -prf + if (limit && res.data.feed.length > limit) { + res.data.feed = res.data.feed.slice(0, limit) + } + // filter out older posts + res.data.feed = res.data.feed.filter( + post => new Date(post.post.indexedAt) > this.minDate, + ) + // attach source info + for (const post of res.data.feed) { + post.__source = this.sourceInfo + } + return res + } catch { + // dont bubble custom-feed errors + return {success: false, headers: {}, data: {feed: []}} } - return res } } diff --git a/src/lib/async/accumulate.ts b/src/lib/async/accumulate.ts new file mode 100644 index 000000000..99226418e --- /dev/null +++ b/src/lib/async/accumulate.ts @@ -0,0 +1,25 @@ +export interface AccumulateResponse<T> { + cursor?: string + items: T[] +} + +export type AccumulateFetchFn<T> = ( + cursor: string | undefined, +) => Promise<AccumulateResponse<T>> + +export async function accumulate<T>( + fn: AccumulateFetchFn<T>, + pageLimit = 100, +): Promise<T[]> { + let cursor: string | undefined + let acc: T[] = [] + for (let i = 0; i < pageLimit; i++) { + const res = await fn(cursor) + cursor = res.cursor + acc = acc.concat(res.items) + if (!cursor) { + break + } + } + return acc +} diff --git a/src/lib/async/until.ts b/src/lib/async/until.ts new file mode 100644 index 000000000..db53c9218 --- /dev/null +++ b/src/lib/async/until.ts @@ -0,0 +1,24 @@ +import {timeout} from './timeout' + +export async function until( + retries: number, + delay: number, + cond: (v: any, err: any) => boolean, + fn: () => Promise<any>, +): Promise<boolean> { + while (retries > 0) { + try { + const v = await fn() + if (cond(v, undefined)) { + return true + } + } catch (e: any) { + if (cond(undefined, e)) { + return true + } + } + await timeout(delay) + retries-- + } + return false +} diff --git a/src/lib/hooks/useCustomFeed.ts b/src/lib/hooks/useCustomFeed.ts index d7a27050d..04201b9a1 100644 --- a/src/lib/hooks/useCustomFeed.ts +++ b/src/lib/hooks/useCustomFeed.ts @@ -1,24 +1,15 @@ import {useEffect, useState} from 'react' import {useStores} from 'state/index' -import {CustomFeedModel} from 'state/models/feeds/custom-feed' +import {FeedSourceModel} from 'state/models/content/feed-source' -export function useCustomFeed(uri: string): CustomFeedModel | undefined { +export function useCustomFeed(uri: string): FeedSourceModel | undefined { const store = useStores() - const [item, setItem] = useState<CustomFeedModel | undefined>() + const [item, setItem] = useState<FeedSourceModel | undefined>() useEffect(() => { - async function fetchView() { - const res = await store.agent.app.bsky.feed.getFeedGenerator({ - feed: uri, - }) - const view = res.data.view - return view - } async function buildFeedItem() { - const view = await fetchView() - if (view) { - const temp = new CustomFeedModel(store, view) - setItem(temp) - } + const model = new FeedSourceModel(store, uri) + await model.setup() + setItem(model) } buildFeedItem() }, [store, uri]) diff --git a/src/lib/hooks/useDesktopRightNavItems.ts b/src/lib/hooks/useDesktopRightNavItems.ts new file mode 100644 index 000000000..f27efd28f --- /dev/null +++ b/src/lib/hooks/useDesktopRightNavItems.ts @@ -0,0 +1,51 @@ +import {useEffect, useState} from 'react' +import {useStores} from 'state/index' +import isEqual from 'lodash.isequal' +import {AtUri} from '@atproto/api' +import {FeedSourceModel} from 'state/models/content/feed-source' + +interface RightNavItem { + uri: string + href: string + hostname: string + collection: string + rkey: string + displayName: string +} + +export function useDesktopRightNavItems(uris: string[]): RightNavItem[] { + const store = useStores() + const [items, setItems] = useState<RightNavItem[]>([]) + const [lastUris, setLastUris] = useState<string[]>([]) + + useEffect(() => { + if (isEqual(uris, lastUris)) { + // no changes + return + } + + async function fetchFeedInfo() { + const models = uris + .slice(0, 25) + .map(uri => new FeedSourceModel(store, uri)) + await Promise.all(models.map(m => m.setup())) + setItems( + models.map(model => { + const {hostname, collection, rkey} = new AtUri(model.uri) + return { + uri: model.uri, + href: model.href, + hostname, + collection, + rkey, + displayName: model.displayName, + } + }), + ) + setLastUris(uris) + } + fetchFeedInfo() + }, [store, uris, lastUris, setLastUris, setItems]) + + return items +} diff --git a/src/lib/hooks/useHomeTabs.ts b/src/lib/hooks/useHomeTabs.ts new file mode 100644 index 000000000..69183e627 --- /dev/null +++ b/src/lib/hooks/useHomeTabs.ts @@ -0,0 +1,29 @@ +import {useEffect, useState} from 'react' +import {useStores} from 'state/index' +import isEqual from 'lodash.isequal' +import {FeedSourceModel} from 'state/models/content/feed-source' + +export function useHomeTabs(uris: string[]): string[] { + const store = useStores() + const [tabs, setTabs] = useState<string[]>(['Following']) + const [lastUris, setLastUris] = useState<string[]>([]) + + useEffect(() => { + if (isEqual(uris, lastUris)) { + // no changes + return + } + + async function fetchFeedInfo() { + const models = uris + .slice(0, 25) + .map(uri => new FeedSourceModel(store, uri)) + await Promise.all(models.map(m => m.setup())) + setTabs(['Following'].concat(models.map(f => f.displayName))) + setLastUris(uris) + } + fetchFeedInfo() + }, [store, uris, lastUris, setLastUris, setTabs]) + + return tabs +} diff --git a/src/lib/icons.tsx b/src/lib/icons.tsx index fef7be2f3..7ae88806f 100644 --- a/src/lib/icons.tsx +++ b/src/lib/icons.tsx @@ -947,3 +947,30 @@ export function ShieldExclamation({ </Svg> ) } + +export function ListIcon({ + style, + size, + strokeWidth = 1.5, +}: { + style?: StyleProp<TextStyle> + size?: string | number + strokeWidth?: number +}) { + return ( + <Svg + fill="none" + viewBox="0 0 24 24" + strokeWidth={strokeWidth || 1.5} + stroke="currentColor" + width={size} + height={size} + style={style}> + <Path + strokeLinecap="round" + strokeLinejoin="round" + d="M8.25 6.75h12M8.25 12h12m-12 5.25h12M3.75 6.75h.007v.008H3.75V6.75zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zM3.75 12h.007v.008H3.75V12zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm-.375 5.25h.007v.008H3.75v-.008zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z" + /> + </Svg> + ) +} diff --git a/src/lib/moderation.ts b/src/lib/moderation.ts index aadee0e74..6c08606ee 100644 --- a/src/lib/moderation.ts +++ b/src/lib/moderation.ts @@ -17,9 +17,18 @@ export function describeModerationCause( } } if (cause.type === 'blocking') { - return { - name: 'User Blocked', - description: 'You have blocked this user. You cannot view their content.', + if (cause.source.type === 'list') { + return { + name: `User Blocked by "${cause.source.list.name}"`, + description: + 'You have blocked this user. You cannot view their content.', + } + } else { + return { + name: 'User Blocked', + description: + 'You have blocked this user. You cannot view their content.', + } } } if (cause.type === 'blocked-by') { diff --git a/src/lib/routes/links.ts b/src/lib/routes/links.ts index cc543b6b7..397a5916c 100644 --- a/src/lib/routes/links.ts +++ b/src/lib/routes/links.ts @@ -13,3 +13,15 @@ export function makeProfileLink( ...segments, ].join('/') } + +export function makeCustomFeedLink( + did: string, + rkey: string, + ...segments: string[] +) { + return [`/profile`, did, 'feed', rkey, ...segments].join('/') +} + +export function makeListLink(did: string, rkey: string, ...segments: string[]) { + return [`/profile`, did, 'lists', rkey, ...segments].join('/') +} diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts index 35a379d48..c157c0ab3 100644 --- a/src/lib/routes/types.ts +++ b/src/lib/routes/types.ts @@ -5,8 +5,9 @@ export type {NativeStackScreenProps} from '@react-navigation/native-stack' export type CommonNavigatorParams = { NotFound: undefined + Lists: undefined Moderation: undefined - ModerationMuteLists: undefined + ModerationModlists: undefined ModerationMutedAccounts: undefined ModerationBlockedAccounts: undefined Settings: undefined @@ -18,8 +19,8 @@ export type CommonNavigatorParams = { PostThread: {name: string; rkey: string} PostLikedBy: {name: string; rkey: string} PostRepostedBy: {name: string; rkey: string} - CustomFeed: {name: string; rkey: string} - CustomFeedLikedBy: {name: string; rkey: string} + ProfileFeed: {name: string; rkey: string} + ProfileFeedLikedBy: {name: string; rkey: string} Debug: undefined Log: undefined Support: undefined diff --git a/src/routes.ts b/src/routes.ts index 7049d60ff..bb2421987 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -7,8 +7,9 @@ export const router = new Router({ Notifications: '/notifications', Settings: '/settings', LanguageSettings: '/settings/language', + Lists: '/lists', Moderation: '/moderation', - ModerationMuteLists: '/moderation/mute-lists', + ModerationModlists: '/moderation/modlists', ModerationMutedAccounts: '/moderation/muted-accounts', ModerationBlockedAccounts: '/moderation/blocked-accounts', Profile: '/profile/:name', @@ -18,8 +19,8 @@ export const router = new Router({ PostThread: '/profile/:name/post/:rkey', PostLikedBy: '/profile/:name/post/:rkey/liked-by', PostRepostedBy: '/profile/:name/post/:rkey/reposted-by', - CustomFeed: '/profile/:name/feed/:rkey', - CustomFeedLikedBy: '/profile/:name/feed/:rkey/liked-by', + ProfileFeed: '/profile/:name/feed/:rkey', + ProfileFeedLikedBy: '/profile/:name/feed/:rkey/liked-by', Debug: '/sys/debug', Log: '/sys/log', AppPasswords: '/settings/app-passwords', diff --git a/src/state/models/content/feed-source.ts b/src/state/models/content/feed-source.ts new file mode 100644 index 000000000..8dac9b56f --- /dev/null +++ b/src/state/models/content/feed-source.ts @@ -0,0 +1,223 @@ +import {AtUri, RichText, AppBskyFeedDefs, AppBskyGraphDefs} from '@atproto/api' +import {makeAutoObservable, runInAction} from 'mobx' +import {RootStoreModel} from 'state/models/root-store' +import {sanitizeDisplayName} from 'lib/strings/display-names' +import {sanitizeHandle} from 'lib/strings/handles' +import {bundleAsync} from 'lib/async/bundle' +import {cleanError} from 'lib/strings/errors' +import {track} from 'lib/analytics/analytics' + +export class FeedSourceModel { + // state + _reactKey: string + hasLoaded = false + error: string | undefined + + // data + uri: string + cid: string = '' + type: 'feed-generator' | 'list' | 'unsupported' = 'unsupported' + avatar: string | undefined = '' + displayName: string = '' + descriptionRT: RichText | null = null + creatorDid: string = '' + creatorHandle: string = '' + likeCount: number | undefined = 0 + likeUri: string | undefined = '' + + constructor(public rootStore: RootStoreModel, uri: string) { + this._reactKey = uri + this.uri = uri + + try { + const urip = new AtUri(uri) + if (urip.collection === 'app.bsky.feed.generator') { + this.type = 'feed-generator' + } else if (urip.collection === 'app.bsky.graph.list') { + this.type = 'list' + } + } catch {} + this.displayName = uri.split('/').pop() || '' + + makeAutoObservable( + this, + { + rootStore: false, + }, + {autoBind: true}, + ) + } + + get href() { + const urip = new AtUri(this.uri) + const collection = + urip.collection === 'app.bsky.feed.generator' ? 'feed' : 'lists' + return `/profile/${urip.hostname}/${collection}/${urip.rkey}` + } + + get isSaved() { + return this.rootStore.preferences.savedFeeds.includes(this.uri) + } + + get isPinned() { + return this.rootStore.preferences.isPinnedFeed(this.uri) + } + + get isLiked() { + return !!this.likeUri + } + + get isOwner() { + return this.creatorDid === this.rootStore.me.did + } + + setup = bundleAsync(async () => { + try { + if (this.type === 'feed-generator') { + const res = await this.rootStore.agent.app.bsky.feed.getFeedGenerator({ + feed: this.uri, + }) + this.hydrateFeedGenerator(res.data.view) + } else if (this.type === 'list') { + const res = await this.rootStore.agent.app.bsky.graph.getList({ + list: this.uri, + limit: 1, + }) + this.hydrateList(res.data.list) + } + } catch (e) { + runInAction(() => { + this.error = cleanError(e) + }) + } + }) + + hydrateFeedGenerator(view: AppBskyFeedDefs.GeneratorView) { + this.uri = view.uri + this.cid = view.cid + this.avatar = view.avatar + this.displayName = view.displayName + ? sanitizeDisplayName(view.displayName) + : `Feed by ${sanitizeHandle(view.creator.handle, '@')}` + this.descriptionRT = new RichText({ + text: view.description || '', + facets: (view.descriptionFacets || [])?.slice(), + }) + this.creatorDid = view.creator.did + this.creatorHandle = view.creator.handle + this.likeCount = view.likeCount + this.likeUri = view.viewer?.like + this.hasLoaded = true + } + + hydrateList(view: AppBskyGraphDefs.ListView) { + this.uri = view.uri + this.cid = view.cid + this.avatar = view.avatar + this.displayName = view.name + ? sanitizeDisplayName(view.name) + : `User List by ${sanitizeHandle(view.creator.handle, '@')}` + this.descriptionRT = new RichText({ + text: view.description || '', + facets: (view.descriptionFacets || [])?.slice(), + }) + this.creatorDid = view.creator.did + this.creatorHandle = view.creator.handle + this.likeCount = undefined + this.hasLoaded = true + } + + async save() { + if (this.type !== 'feed-generator') { + return + } + try { + await this.rootStore.preferences.addSavedFeed(this.uri) + } catch (error) { + this.rootStore.log.error('Failed to save feed', error) + } finally { + track('CustomFeed:Save') + } + } + + async unsave() { + if (this.type !== 'feed-generator') { + return + } + try { + await this.rootStore.preferences.removeSavedFeed(this.uri) + } catch (error) { + this.rootStore.log.error('Failed to unsave feed', error) + } finally { + track('CustomFeed:Unsave') + } + } + + async pin() { + try { + await this.rootStore.preferences.addPinnedFeed(this.uri) + } catch (error) { + this.rootStore.log.error('Failed to pin feed', error) + } finally { + track('CustomFeed:Pin', { + name: this.displayName, + uri: this.uri, + }) + } + } + + async togglePin() { + if (!this.isPinned) { + track('CustomFeed:Pin', { + name: this.displayName, + uri: this.uri, + }) + return this.rootStore.preferences.addPinnedFeed(this.uri) + } else { + track('CustomFeed:Unpin', { + name: this.displayName, + uri: this.uri, + }) + return this.rootStore.preferences.removePinnedFeed(this.uri) + } + } + + async like() { + if (this.type !== 'feed-generator') { + return + } + try { + this.likeUri = 'pending' + this.likeCount = (this.likeCount || 0) + 1 + const res = await this.rootStore.agent.like(this.uri, this.cid) + this.likeUri = res.uri + } catch (e: any) { + this.likeUri = undefined + this.likeCount = (this.likeCount || 1) - 1 + this.rootStore.log.error('Failed to like feed', e) + } finally { + track('CustomFeed:Like') + } + } + + async unlike() { + if (this.type !== 'feed-generator') { + return + } + if (!this.likeUri) { + return + } + const uri = this.likeUri + try { + this.likeUri = undefined + this.likeCount = (this.likeCount || 1) - 1 + await this.rootStore.agent.deleteLike(uri!) + } catch (e: any) { + this.likeUri = uri + this.likeCount = (this.likeCount || 0) + 1 + this.rootStore.log.error('Failed to unlike feed', e) + } finally { + track('CustomFeed:Unlike') + } + } +} diff --git a/src/state/models/content/list-membership.ts b/src/state/models/content/list-membership.ts index 20d9b60af..135d34dd5 100644 --- a/src/state/models/content/list-membership.ts +++ b/src/state/models/content/list-membership.ts @@ -110,14 +110,21 @@ export class ListMembershipModel { }) } - async updateTo(uris: string[]) { + async updateTo( + uris: string[], + ): Promise<{added: string[]; removed: string[]}> { + const added = [] + const removed = [] for (const uri of uris) { await this.add(uri) + added.push(uri) } for (const membership of this.memberships) { if (!uris.includes(membership.value.list)) { await this.remove(membership.value.list) + removed.push(membership.value.list) } } + return {added, removed} } } diff --git a/src/state/models/content/list.ts b/src/state/models/content/list.ts index fd5074d8c..0331f58bd 100644 --- a/src/state/models/content/list.ts +++ b/src/state/models/content/list.ts @@ -1,10 +1,12 @@ import {makeAutoObservable, runInAction} from 'mobx' import { AtUri, + AppBskyActorDefs, AppBskyGraphGetList as GetList, AppBskyGraphDefs as GraphDefs, AppBskyGraphList, AppBskyGraphListitem, + RichText, } from '@atproto/api' import {Image as RNImage} from 'react-native-image-crop-picker' import chunk from 'lodash.chunk' @@ -13,6 +15,7 @@ import * as apilib from 'lib/api/index' import {cleanError} from 'lib/strings/errors' import {bundleAsync} from 'lib/async/bundle' import {track} from 'lib/analytics/analytics' +import {until} from 'lib/async/until' const PAGE_SIZE = 30 @@ -37,19 +40,32 @@ export class ListModel { loadMoreCursor?: string // data - list: GraphDefs.ListView | null = null + data: GraphDefs.ListView | null = null items: GraphDefs.ListItemView[] = [] + descriptionRT: RichText | null = null - static async createModList( + static async createList( rootStore: RootStoreModel, { + purpose, name, description, avatar, - }: {name: string; description: string; avatar: RNImage | null | undefined}, + }: { + purpose: string + name: string + description: string + avatar: RNImage | null | undefined + }, ) { + if ( + purpose !== 'app.bsky.graph.defs#curatelist' && + purpose !== 'app.bsky.graph.defs#modlist' + ) { + throw new Error('Invalid list purpose: must be curatelist or modlist') + } const record: AppBskyGraphList.Record = { - purpose: 'app.bsky.graph.defs#modlist', + purpose, name, description, avatar: undefined, @@ -69,7 +85,20 @@ export class ListModel { }, record, ) - await rootStore.agent.app.bsky.graph.muteActorList({list: res.uri}) + + // wait for the appview to update + await until( + 5, // 5 tries + 1e3, // 1s delay between tries + (v: GetList.Response, _e: any) => { + return typeof v?.data?.list.uri === 'string' + }, + () => + rootStore.agent.app.bsky.graph.getList({ + list: res.uri, + limit: 1, + }), + ) return res } @@ -95,16 +124,40 @@ export class ListModel { return this.hasLoaded && !this.hasContent } + get isCuratelist() { + return this.data?.purpose === 'app.bsky.graph.defs#curatelist' + } + + get isModlist() { + return this.data?.purpose === 'app.bsky.graph.defs#modlist' + } + get isOwner() { - return this.list?.creator.did === this.rootStore.me.did + return this.data?.creator.did === this.rootStore.me.did + } + + get isBlocking() { + return !!this.data?.viewer?.blocked } - get isSubscribed() { - return this.list?.viewer?.muted + get isMuting() { + return !!this.data?.viewer?.muted + } + + get isPinned() { + return this.rootStore.preferences.isPinnedFeed(this.uri) } get creatorDid() { - return this.list?.creator.did + return this.data?.creator.did + } + + getMembership(did: string) { + return this.items.find(item => item.subject.did === did) + } + + isMember(did: string) { + return !!this.getMembership(did) } // public api @@ -137,6 +190,15 @@ export class ListModel { } }) + async loadAll() { + for (let i = 0; i < 1000; i++) { + if (!this.hasMore) { + break + } + await this.loadMore() + } + } + async updateMetadata({ name, description, @@ -146,7 +208,7 @@ export class ListModel { description: string avatar: RNImage | null | undefined }) { - if (!this.list) { + if (!this.data) { return } if (!this.isOwner) { @@ -183,7 +245,7 @@ export class ListModel { } async delete() { - if (!this.list) { + if (!this.data) { return } await this._resolveUri() @@ -231,28 +293,140 @@ export class ListModel { this.rootStore.emitListDeleted(this.uri) } - async subscribe() { - if (!this.list) { + async addMember(profile: AppBskyActorDefs.ProfileViewBasic) { + if (this.isMember(profile.did)) { + return + } + await this.rootStore.agent.app.bsky.graph.listitem.create( + { + repo: this.rootStore.me.did, + }, + { + subject: profile.did, + list: this.uri, + createdAt: new Date().toISOString(), + }, + ) + runInAction(() => { + this.items = this.items.concat([ + {_reactKey: profile.did, subject: profile}, + ]) + }) + } + + /** + * Just adds to local cache; used to reflect changes affected elsewhere + */ + cacheAddMember(profile: AppBskyActorDefs.ProfileViewBasic) { + if (!this.isMember(profile.did)) { + this.items = this.items.concat([ + {_reactKey: profile.did, subject: profile}, + ]) + } + } + + /** + * Just removes from local cache; used to reflect changes affected elsewhere + */ + cacheRemoveMember(profile: AppBskyActorDefs.ProfileViewBasic) { + if (this.isMember(profile.did)) { + this.items = this.items.filter(item => item.subject.did !== profile.did) + } + } + + async pin() { + try { + await this.rootStore.preferences.addPinnedFeed(this.uri) + } catch (error) { + this.rootStore.log.error('Failed to pin feed', error) + } finally { + track('CustomFeed:Pin', { + name: this.data?.name || '', + uri: this.uri, + }) + } + } + + async togglePin() { + if (!this.isPinned) { + track('CustomFeed:Pin', { + name: this.data?.name || '', + uri: this.uri, + }) + return this.rootStore.preferences.addPinnedFeed(this.uri) + } else { + track('CustomFeed:Unpin', { + name: this.data?.name || '', + uri: this.uri, + }) + // TEMPORARY + // lists are temporarily piggybacking on the saved/pinned feeds preferences + // we'll eventually replace saved feeds with the bookmarks API + // until then, we need to unsave lists instead of just unpin them + // -prf + // return this.rootStore.preferences.removePinnedFeed(this.uri) + return this.rootStore.preferences.removeSavedFeed(this.uri) + } + } + + async mute() { + if (!this.data) { + return + } + await this._resolveUri() + await this.rootStore.agent.muteModList(this.data.uri) + track('Lists:Mute') + runInAction(() => { + if (this.data) { + const d = this.data + this.data = {...d, viewer: {...(d.viewer || {}), muted: true}} + } + }) + } + + async unmute() { + if (!this.data) { return } await this._resolveUri() - await this.rootStore.agent.app.bsky.graph.muteActorList({ - list: this.list.uri, + await this.rootStore.agent.unmuteModList(this.data.uri) + track('Lists:Unmute') + runInAction(() => { + if (this.data) { + const d = this.data + this.data = {...d, viewer: {...(d.viewer || {}), muted: false}} + } }) - track('Lists:Subscribe') - await this.refresh() } - async unsubscribe() { - if (!this.list) { + async block() { + if (!this.data) { return } await this._resolveUri() - await this.rootStore.agent.app.bsky.graph.unmuteActorList({ - list: this.list.uri, + const res = await this.rootStore.agent.blockModList(this.data.uri) + track('Lists:Block') + runInAction(() => { + if (this.data) { + const d = this.data + this.data = {...d, viewer: {...(d.viewer || {}), blocked: res.uri}} + } + }) + } + + async unblock() { + if (!this.data || !this.data.viewer?.blocked) { + return + } + await this._resolveUri() + await this.rootStore.agent.unblockModList(this.data.uri) + track('Lists:Unblock') + runInAction(() => { + if (this.data) { + const d = this.data + this.data = {...d, viewer: {...(d.viewer || {}), blocked: undefined}} + } }) - track('Lists:Unsubscribe') - await this.refresh() } /** @@ -314,9 +488,17 @@ export class ListModel { _appendAll(res: GetList.Response) { this.loadMoreCursor = res.data.cursor this.hasMore = !!this.loadMoreCursor - this.list = res.data.list + this.data = res.data.list this.items = this.items.concat( res.data.items.map(item => ({...item, _reactKey: item.subject.did})), ) + if (this.data.description) { + this.descriptionRT = new RichText({ + text: this.data.description, + facets: (this.data.descriptionFacets || [])?.slice(), + }) + } else { + this.descriptionRT = null + } } } diff --git a/src/state/models/content/profile.ts b/src/state/models/content/profile.ts index 906f84c28..5333e7116 100644 --- a/src/state/models/content/profile.ts +++ b/src/state/models/content/profile.ts @@ -22,7 +22,8 @@ export class ProfileViewerModel { following?: string followedBy?: string blockedBy?: boolean - blocking?: string; + blocking?: string + blockingByList?: AppBskyGraphDefs.ListViewBasic; [key: string]: unknown constructor() { diff --git a/src/state/models/discovery/feeds.ts b/src/state/models/discovery/feeds.ts index fa4054ff0..1a00f802c 100644 --- a/src/state/models/discovery/feeds.ts +++ b/src/state/models/discovery/feeds.ts @@ -3,7 +3,7 @@ import {AppBskyUnspeccedGetPopularFeedGenerators} from '@atproto/api' import {RootStoreModel} from '../root-store' import {bundleAsync} from 'lib/async/bundle' import {cleanError} from 'lib/strings/errors' -import {CustomFeedModel} from '../feeds/custom-feed' +import {FeedSourceModel} from '../content/feed-source' const DEFAULT_LIMIT = 50 @@ -16,7 +16,7 @@ export class FeedsDiscoveryModel { loadMoreCursor: string | undefined = undefined // data - feeds: CustomFeedModel[] = [] + feeds: FeedSourceModel[] = [] constructor(public rootStore: RootStoreModel) { makeAutoObservable( @@ -137,7 +137,9 @@ export class FeedsDiscoveryModel { _append(res: AppBskyUnspeccedGetPopularFeedGenerators.Response) { // 1. push data into feeds array for (const f of res.data.feeds) { - this.feeds.push(new CustomFeedModel(this.rootStore, f)) + const model = new FeedSourceModel(this.rootStore, f.uri) + model.hydrateFeedGenerator(f) + this.feeds.push(model) } // 2. set loadMoreCursor this.loadMoreCursor = res.data.cursor diff --git a/src/state/models/feeds/custom-feed.ts b/src/state/models/feeds/custom-feed.ts deleted file mode 100644 index 2de4534e7..000000000 --- a/src/state/models/feeds/custom-feed.ts +++ /dev/null @@ -1,151 +0,0 @@ -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 {sanitizeHandle} from 'lib/strings/handles' -import {updateDataOptimistically} from 'lib/async/revertible' -import {track} from 'lib/analytics/analytics' - -export class CustomFeedModel { - // data - _reactKey: string - data: AppBskyFeedDefs.GeneratorView - isOnline: boolean - isValid: boolean - - constructor( - public rootStore: RootStoreModel, - view: AppBskyFeedDefs.GeneratorView, - isOnline?: boolean, - isValid?: boolean, - ) { - this._reactKey = view.uri - this.data = view - this.isOnline = isOnline ?? true - this.isValid = isValid ?? true - makeAutoObservable( - this, - { - rootStore: false, - }, - {autoBind: true}, - ) - } - - // local actions - // = - - get uri() { - return this.data.uri - } - - get displayName() { - if (this.data.displayName) { - return sanitizeDisplayName(this.data.displayName) - } - return `Feed by ${sanitizeHandle(this.data.creator.handle, '@')}` - } - - get isSaved() { - return this.rootStore.preferences.savedFeeds.includes(this.uri) - } - - get isLiked() { - return this.data.viewer?.like - } - - // public apis - // = - - async save() { - try { - await this.rootStore.preferences.addSavedFeed(this.uri) - } catch (error) { - this.rootStore.log.error('Failed to save feed', error) - } finally { - track('CustomFeed:Save') - } - } - - async pin() { - try { - await this.rootStore.preferences.addPinnedFeed(this.uri) - } catch (error) { - this.rootStore.log.error('Failed to pin feed', error) - } finally { - track('CustomFeed:Pin', { - name: this.data.displayName, - uri: this.uri, - }) - } - } - - async unsave() { - try { - await this.rootStore.preferences.removeSavedFeed(this.uri) - } catch (error) { - this.rootStore.log.error('Failed to unsave feed', error) - } finally { - track('CustomFeed:Unsave') - } - } - - async like() { - try { - 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) - } finally { - track('CustomFeed:Like') - } - } - - async unlike() { - if (!this.data.viewer?.like) { - return - } - try { - 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) - } finally { - track('CustomFeed:Unlike') - } - } - - async reload() { - const res = await this.rootStore.agent.app.bsky.feed.getFeedGenerator({ - feed: this.data.uri, - }) - runInAction(() => { - this.data = res.data.view - this.isOnline = res.data.isOnline - this.isValid = res.data.isValid - }) - } - - serialize() { - return JSON.stringify(this.data) - } -} diff --git a/src/state/models/feeds/posts.ts b/src/state/models/feeds/posts.ts index 2462689b1..169eedac8 100644 --- a/src/state/models/feeds/posts.ts +++ b/src/state/models/feeds/posts.ts @@ -4,6 +4,7 @@ import { AppBskyFeedGetAuthorFeed as GetAuthorFeed, AppBskyFeedGetFeed as GetCustomFeed, AppBskyFeedGetActorLikes as GetActorLikes, + AppBskyFeedGetListFeed as GetListFeed, } from '@atproto/api' import AwaitLock from 'await-lock' import {bundleAsync} from 'lib/async/bundle' @@ -19,6 +20,7 @@ import {FollowingFeedAPI} from 'lib/api/feed/following' import {AuthorFeedAPI} from 'lib/api/feed/author' import {LikesFeedAPI} from 'lib/api/feed/likes' import {CustomFeedAPI} from 'lib/api/feed/custom' +import {ListFeedAPI} from 'lib/api/feed/list' import {MergeFeedAPI} from 'lib/api/feed/merge' const PAGE_SIZE = 30 @@ -36,6 +38,7 @@ type QueryParams = | GetAuthorFeed.QueryParams | GetActorLikes.QueryParams | GetCustomFeed.QueryParams + | GetListFeed.QueryParams export class PostsFeedModel { // state @@ -66,7 +69,13 @@ export class PostsFeedModel { constructor( public rootStore: RootStoreModel, - public feedType: 'home' | 'following' | 'author' | 'custom' | 'likes', + public feedType: + | 'home' + | 'following' + | 'author' + | 'custom' + | 'likes' + | 'list', params: QueryParams, options?: Options, ) { @@ -99,11 +108,26 @@ export class PostsFeedModel { rootStore, params as GetCustomFeed.QueryParams, ) + } else if (feedType === 'list') { + this.api = new ListFeedAPI(rootStore, params as GetListFeed.QueryParams) } else { this.api = new FollowingFeedAPI(rootStore) } } + get reactKey() { + if (this.feedType === 'author') { + return (this.params as GetAuthorFeed.QueryParams).actor + } + if (this.feedType === 'custom') { + return (this.params as GetCustomFeed.QueryParams).feed + } + if (this.feedType === 'list') { + return (this.params as GetListFeed.QueryParams).list + } + return this.feedType + } + get hasContent() { return this.slices.length !== 0 } @@ -117,7 +141,7 @@ export class PostsFeedModel { } get isLoadingMore() { - return this.isLoading && !this.isRefreshing + return this.isLoading && !this.isRefreshing && this.hasContent } setHasNewLatest(v: boolean) { diff --git a/src/state/models/lists/actor-feeds.ts b/src/state/models/lists/actor-feeds.ts index 0f2060581..d2bd7680b 100644 --- a/src/state/models/lists/actor-feeds.ts +++ b/src/state/models/lists/actor-feeds.ts @@ -3,7 +3,7 @@ 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 {CustomFeedModel} from '../feeds/custom-feed' +import {FeedSourceModel} from '../content/feed-source' const PAGE_SIZE = 30 @@ -17,7 +17,7 @@ export class ActorFeedsModel { loadMoreCursor?: string // data - feeds: CustomFeedModel[] = [] + feeds: FeedSourceModel[] = [] constructor( public rootStore: RootStoreModel, @@ -114,7 +114,9 @@ export class ActorFeedsModel { this.loadMoreCursor = res.data.cursor this.hasMore = !!this.loadMoreCursor for (const f of res.data.feeds) { - this.feeds.push(new CustomFeedModel(this.rootStore, f)) + const model = new FeedSourceModel(this.rootStore, f.uri) + model.hydrateFeedGenerator(f) + this.feeds.push(model) } } } diff --git a/src/state/models/lists/lists-list.ts b/src/state/models/lists/lists-list.ts index 54e2f5fde..42638757a 100644 --- a/src/state/models/lists/lists-list.ts +++ b/src/state/models/lists/lists-list.ts @@ -1,12 +1,9 @@ import {makeAutoObservable} from 'mobx' -import { - AppBskyGraphGetLists as GetLists, - AppBskyGraphGetListMutes as GetListMutes, - AppBskyGraphDefs as GraphDefs, -} from '@atproto/api' +import {AppBskyGraphDefs as GraphDefs} from '@atproto/api' import {RootStoreModel} from '../root-store' import {cleanError} from 'lib/strings/errors' import {bundleAsync} from 'lib/async/bundle' +import {accumulate} from 'lib/async/accumulate' const PAGE_SIZE = 30 @@ -25,7 +22,7 @@ export class ListsListModel { constructor( public rootStore: RootStoreModel, - public source: 'my-modlists' | string, + public source: 'mine' | 'my-curatelists' | 'my-modlists' | string, ) { makeAutoObservable( this, @@ -48,6 +45,26 @@ export class ListsListModel { return this.hasLoaded && !this.hasContent } + get curatelists() { + return this.lists.filter( + list => list.purpose === 'app.bsky.graph.defs#curatelist', + ) + } + + get isCuratelistsEmpty() { + return this.hasLoaded && this.curatelists.length === 0 + } + + get modlists() { + return this.lists.filter( + list => list.purpose === 'app.bsky.graph.defs#modlist', + ) + } + + get isModlistsEmpty() { + return this.hasLoaded && this.modlists.length === 0 + } + /** * Removes posts from the feed upon deletion. */ @@ -76,44 +93,85 @@ export class ListsListModel { } this._xLoading(replace) try { - let res: GetLists.Response - if (this.source === 'my-modlists') { - res = { - success: true, - headers: {}, - data: { - subject: undefined, - lists: [], - }, - } - const [res1, res2] = await Promise.all([ - fetchAllUserLists(this.rootStore, this.rootStore.me.did), - fetchAllMyMuteLists(this.rootStore), - ]) - for (let list of res1.data.lists) { - if (list.purpose === 'app.bsky.graph.defs#modlist') { - res.data.lists.push(list) - } + let cursor: string | undefined + let lists: GraphDefs.ListView[] = [] + if ( + this.source === 'mine' || + this.source === 'my-curatelists' || + this.source === 'my-modlists' + ) { + const promises = [ + accumulate(cursor => + this.rootStore.agent.app.bsky.graph + .getLists({ + actor: this.rootStore.me.did, + cursor, + limit: 50, + }) + .then(res => ({cursor: res.data.cursor, items: res.data.lists})), + ), + ] + if (this.source === 'my-modlists') { + promises.push( + accumulate(cursor => + this.rootStore.agent.app.bsky.graph + .getListMutes({ + cursor, + limit: 50, + }) + .then(res => ({ + cursor: res.data.cursor, + items: res.data.lists, + })), + ), + ) + promises.push( + accumulate(cursor => + this.rootStore.agent.app.bsky.graph + .getListBlocks({ + cursor, + limit: 50, + }) + .then(res => ({ + cursor: res.data.cursor, + items: res.data.lists, + })), + ), + ) } - for (let list of res2.data.lists) { - if ( - list.purpose === 'app.bsky.graph.defs#modlist' && - !res.data.lists.find(l => l.uri === list.uri) - ) { - res.data.lists.push(list) + const resultset = await Promise.all(promises) + for (const res of resultset) { + for (let list of res) { + if ( + this.source === 'my-curatelists' && + list.purpose !== 'app.bsky.graph.defs#curatelist' + ) { + continue + } + if ( + this.source === 'my-modlists' && + list.purpose !== 'app.bsky.graph.defs#modlist' + ) { + continue + } + if (!lists.find(l => l.uri === list.uri)) { + lists.push(list) + } } } } else { - res = await this.rootStore.agent.app.bsky.graph.getLists({ + const res = await this.rootStore.agent.app.bsky.graph.getLists({ actor: this.source, limit: PAGE_SIZE, cursor: replace ? undefined : this.loadMoreCursor, }) + lists = res.data.lists + cursor = res.data.cursor } if (replace) { - this._replaceAll(res) + this._replaceAll({lists, cursor}) } else { - this._appendAll(res) + this._appendAll({lists, cursor}) } this._xIdle() } catch (e: any) { @@ -156,75 +214,28 @@ export class ListsListModel { // helper functions // = - _replaceAll(res: GetLists.Response | GetListMutes.Response) { + _replaceAll({ + lists, + cursor, + }: { + lists: GraphDefs.ListView[] + cursor: string | undefined + }) { this.lists = [] - this._appendAll(res) + this._appendAll({lists, cursor}) } - _appendAll(res: GetLists.Response | GetListMutes.Response) { - this.loadMoreCursor = res.data.cursor + _appendAll({ + lists, + cursor, + }: { + lists: GraphDefs.ListView[] + cursor: string | undefined + }) { + this.loadMoreCursor = cursor this.hasMore = !!this.loadMoreCursor this.lists = this.lists.concat( - res.data.lists.map(list => ({...list, _reactKey: list.uri})), + lists.map(list => ({...list, _reactKey: list.uri})), ) } } - -async function fetchAllUserLists( - store: RootStoreModel, - did: string, -): Promise<GetLists.Response> { - let acc: GetLists.Response = { - success: true, - headers: {}, - data: { - subject: undefined, - lists: [], - }, - } - - let cursor - for (let i = 0; i < 100; i++) { - const res: GetLists.Response = await store.agent.app.bsky.graph.getLists({ - actor: did, - cursor, - limit: 50, - }) - cursor = res.data.cursor - acc.data.lists = acc.data.lists.concat(res.data.lists) - if (!cursor) { - break - } - } - - return acc -} - -async function fetchAllMyMuteLists( - store: RootStoreModel, -): Promise<GetListMutes.Response> { - let acc: GetListMutes.Response = { - success: true, - headers: {}, - data: { - subject: undefined, - lists: [], - }, - } - - let cursor - for (let i = 0; i < 100; i++) { - const res: GetListMutes.Response = - await store.agent.app.bsky.graph.getListMutes({ - cursor, - limit: 50, - }) - cursor = res.data.cursor - acc.data.lists = acc.data.lists.concat(res.data.lists) - if (!cursor) { - break - } - } - - return acc -} diff --git a/src/state/models/me.ts b/src/state/models/me.ts index 186e61cf6..75c87d765 100644 --- a/src/state/models/me.ts +++ b/src/state/models/me.ts @@ -8,7 +8,6 @@ import {PostsFeedModel} from './feeds/posts' import {NotificationsFeedModel} from './feeds/notifications' import {MyFollowsCache} from './cache/my-follows' import {isObj, hasProp} from 'lib/type-guards' -import {SavedFeedsModel} from './ui/saved-feeds' const PROFILE_UPDATE_INTERVAL = 10 * 60 * 1e3 // 10min const NOTIFS_UPDATE_INTERVAL = 30 * 1e3 // 30sec @@ -22,7 +21,6 @@ export class MeModel { followsCount: number | undefined followersCount: number | undefined mainFeed: PostsFeedModel - savedFeeds: SavedFeedsModel notifications: NotificationsFeedModel follows: MyFollowsCache invites: ComAtprotoServerDefs.InviteCode[] = [] @@ -45,7 +43,6 @@ export class MeModel { }) this.notifications = new NotificationsFeedModel(this.rootStore) this.follows = new MyFollowsCache(this.rootStore) - this.savedFeeds = new SavedFeedsModel(this.rootStore) } clear() { diff --git a/src/state/models/ui/my-feeds.ts b/src/state/models/ui/my-feeds.ts index 6b017709e..58f2e7f65 100644 --- a/src/state/models/ui/my-feeds.ts +++ b/src/state/models/ui/my-feeds.ts @@ -1,6 +1,7 @@ -import {makeAutoObservable} from 'mobx' +import {makeAutoObservable, reaction} from 'mobx' +import {SavedFeedsModel} from './saved-feeds' import {FeedsDiscoveryModel} from '../discovery/feeds' -import {CustomFeedModel} from '../feeds/custom-feed' +import {FeedSourceModel} from '../content/feed-source' import {RootStoreModel} from '../root-store' export type MyFeedsItem = @@ -29,7 +30,7 @@ export type MyFeedsItem = | { _reactKey: string type: 'saved-feed' - feed: CustomFeedModel + feed: FeedSourceModel } | { _reactKey: string @@ -46,21 +47,19 @@ export type MyFeedsItem = | { _reactKey: string type: 'discover-feed' - feed: CustomFeedModel + feed: FeedSourceModel } export class MyFeedsUIModel { + saved: SavedFeedsModel discovery: FeedsDiscoveryModel constructor(public rootStore: RootStoreModel) { makeAutoObservable(this) + this.saved = new SavedFeedsModel(this.rootStore) this.discovery = new FeedsDiscoveryModel(this.rootStore) } - get saved() { - return this.rootStore.me.savedFeeds - } - get isRefreshing() { return !this.saved.isLoading && this.saved.isRefreshing } @@ -78,6 +77,21 @@ export class MyFeedsUIModel { } } + registerListeners() { + const dispose1 = reaction( + () => this.rootStore.preferences.savedFeeds, + () => this.saved.refresh(), + ) + const dispose2 = reaction( + () => this.rootStore.preferences.pinnedFeeds, + () => this.saved.refresh(), + ) + return () => { + dispose1() + dispose2() + } + } + async refresh() { return Promise.all([this.saved.refresh(), this.discovery.refresh()]) } diff --git a/src/state/models/ui/preferences.ts b/src/state/models/ui/preferences.ts index 6ca19b4b7..7714d65df 100644 --- a/src/state/models/ui/preferences.ts +++ b/src/state/models/ui/preferences.ts @@ -194,7 +194,7 @@ export class PreferencesModel { /** * This function fetches preferences and sets defaults for missing items. */ - async sync({clearCache}: {clearCache?: boolean} = {}) { + async sync() { await this.lock.acquireAsync() try { // fetch preferences @@ -252,8 +252,6 @@ export class PreferencesModel { } finally { this.lock.release() } - - await this.rootStore.me.savedFeeds.updateCache(clearCache) } async syncLegacyPreferences() { @@ -286,6 +284,9 @@ export class PreferencesModel { } } + // languages + // = + hasContentLanguage(code2: string) { return this.contentLanguages.includes(code2) } @@ -358,6 +359,9 @@ export class PreferencesModel { return all.join(', ') } + // moderation + // = + async setContentLabelPref( key: keyof LabelPreferencesModel, value: LabelPreference, @@ -409,6 +413,13 @@ export class PreferencesModel { } } + // feeds + // = + + isPinnedFeed(uri: string) { + return this.pinnedFeeds.includes(uri) + } + async _optimisticUpdateSavedFeeds( saved: string[], pinned: string[], @@ -474,6 +485,9 @@ export class PreferencesModel { ) } + // other + // = + async setBirthDate(birthDate: Date) { this.birthDate = birthDate await this.lock.acquireAsync() @@ -602,7 +616,7 @@ export class PreferencesModel { } getFeedTuners( - feedType: 'home' | 'following' | 'author' | 'custom' | 'likes', + feedType: 'home' | 'following' | 'author' | 'custom' | 'list' | 'likes', ) { if (feedType === 'custom') { return [ @@ -610,6 +624,9 @@ export class PreferencesModel { FeedTuner.preferredLangOnly(this.contentLanguages), ] } + if (feedType === 'list') { + return [FeedTuner.dedupReposts] + } if (feedType === 'home' || feedType === 'following') { const feedTuners = [] diff --git a/src/state/models/ui/saved-feeds.ts b/src/state/models/ui/saved-feeds.ts index 2dd72980d..4156f792a 100644 --- a/src/state/models/ui/saved-feeds.ts +++ b/src/state/models/ui/saved-feeds.ts @@ -2,7 +2,7 @@ import {makeAutoObservable, runInAction} from 'mobx' import {RootStoreModel} from '../root-store' import {bundleAsync} from 'lib/async/bundle' import {cleanError} from 'lib/strings/errors' -import {CustomFeedModel} from '../feeds/custom-feed' +import {FeedSourceModel} from '../content/feed-source' import {track} from 'lib/analytics/analytics' export class SavedFeedsModel { @@ -13,7 +13,7 @@ export class SavedFeedsModel { error = '' // data - _feedModelCache: Record<string, CustomFeedModel> = {} + all: FeedSourceModel[] = [] constructor(public rootStore: RootStoreModel) { makeAutoObservable( @@ -38,20 +38,11 @@ export class SavedFeedsModel { } get pinned() { - return this.rootStore.preferences.pinnedFeeds - .map(uri => this._feedModelCache[uri] as CustomFeedModel) - .filter(Boolean) + return this.all.filter(feed => feed.isPinned) } get unpinned() { - return this.rootStore.preferences.savedFeeds - .filter(uri => !this.isPinned(uri)) - .map(uri => this._feedModelCache[uri] as CustomFeedModel) - .filter(Boolean) - } - - get all() { - return [...this.pinned, ...this.unpinned] + return this.all.filter(feed => !feed.isPinned) } get pinnedFeedNames() { @@ -62,120 +53,38 @@ export class SavedFeedsModel { // = /** - * Syncs the cached models against the current state - * - Should only be called by the preferences model after syncing state - */ - updateCache = bundleAsync(async (clearCache?: boolean) => { - let newFeedModels: Record<string, CustomFeedModel> = {} - if (!clearCache) { - newFeedModels = {...this._feedModelCache} - } - - // collect the feed URIs that havent been synced yet - const neededFeedUris = [] - for (const feedUri of this.rootStore.preferences.savedFeeds) { - if (!(feedUri in newFeedModels)) { - neededFeedUris.push(feedUri) - } - } - - // early exit if no feeds need to be fetched - if (!neededFeedUris.length || neededFeedUris.length === 0) { - return - } - - // fetch the missing models - try { - for (let i = 0; i < neededFeedUris.length; i += 25) { - const res = await this.rootStore.agent.app.bsky.feed.getFeedGenerators({ - feeds: neededFeedUris.slice(i, 25), - }) - for (const feedInfo of res.data.feeds) { - newFeedModels[feedInfo.uri] = new CustomFeedModel( - this.rootStore, - feedInfo, - ) - } - } - } catch (error) { - console.error('Failed to fetch feed models', error) - this.rootStore.log.error('Failed to fetch feed models', error) - } - - // merge into the cache - runInAction(() => { - this._feedModelCache = newFeedModels - }) - }) - - /** * Refresh the preferences then reload all feed infos */ refresh = bundleAsync(async () => { this._xLoading(true) try { - await this.rootStore.preferences.sync({clearCache: true}) + await this.rootStore.preferences.sync() + const uris = dedup( + this.rootStore.preferences.pinnedFeeds.concat( + this.rootStore.preferences.savedFeeds, + ), + ) + const feeds = uris.map(uri => new FeedSourceModel(this.rootStore, uri)) + await Promise.all(feeds.map(f => f.setup())) + runInAction(() => { + this.all = feeds + this._updatePinSortOrder() + }) this._xIdle() } catch (e: any) { this._xIdle(e) } }) - async save(feed: CustomFeedModel) { - try { - await feed.save() - await this.updateCache() - } catch (e: any) { - this.rootStore.log.error('Failed to save feed', e) - } - } - - async unsave(feed: CustomFeedModel) { - const uri = feed.uri - try { - if (this.isPinned(feed)) { - await this.rootStore.preferences.removePinnedFeed(uri) - } - await feed.unsave() - } catch (e: any) { - this.rootStore.log.error('Failed to unsave feed', e) - } - } - - async togglePinnedFeed(feed: CustomFeedModel) { - if (!this.isPinned(feed)) { - track('CustomFeed:Pin', { - name: feed.data.displayName, - uri: feed.uri, - }) - return this.rootStore.preferences.addPinnedFeed(feed.uri) - } else { - track('CustomFeed:Unpin', { - name: feed.data.displayName, - uri: feed.uri, - }) - return this.rootStore.preferences.removePinnedFeed(feed.uri) - } - } - - async reorderPinnedFeeds(feeds: CustomFeedModel[]) { - return this.rootStore.preferences.setSavedFeeds( + async reorderPinnedFeeds(feeds: FeedSourceModel[]) { + this._updatePinSortOrder(feeds.map(f => f.uri)) + await this.rootStore.preferences.setSavedFeeds( this.rootStore.preferences.savedFeeds, - feeds.filter(feed => this.isPinned(feed)).map(feed => feed.uri), + feeds.filter(feed => feed.isPinned).map(feed => 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') { + async movePinnedFeed(item: FeedSourceModel, direction: 'up' | 'down') { const pinned = this.rootStore.preferences.pinnedFeeds.slice() const index = pinned.indexOf(item.uri) if (index === -1) { @@ -194,8 +103,9 @@ export class SavedFeedsModel { this.rootStore.preferences.savedFeeds, pinned, ) + this._updatePinSortOrder() track('CustomFeed:Reorder', { - name: item.data.displayName, + name: item.displayName, uri: item.uri, index: pinned.indexOf(item.uri), }) @@ -219,4 +129,20 @@ export class SavedFeedsModel { this.rootStore.log.error('Failed to fetch user feeds', err) } } + + // helpers + // = + + _updatePinSortOrder(order?: string[]) { + order ??= this.rootStore.preferences.pinnedFeeds.concat( + this.rootStore.preferences.savedFeeds, + ) + this.all.sort((a, b) => { + return order!.indexOf(a.uri) - order!.indexOf(b.uri) + }) + } +} + +function dedup(strings: string[]): string[] { + return Array.from(new Set(strings)) } diff --git a/src/state/models/ui/shell.ts b/src/state/models/ui/shell.ts index a8937b84c..9c0cc6e30 100644 --- a/src/state/models/ui/shell.ts +++ b/src/state/models/ui/shell.ts @@ -1,4 +1,4 @@ -import {AppBskyEmbedRecord, ModerationUI} from '@atproto/api' +import {AppBskyEmbedRecord, AppBskyActorDefs, ModerationUI} from '@atproto/api' import {RootStoreModel} from '../root-store' import {makeAutoObservable, runInAction} from 'mobx' import {ProfileModel} from '../content/profile' @@ -60,17 +60,25 @@ export type ReportModal = { | {did: string} ) -export interface CreateOrEditMuteListModal { - name: 'create-or-edit-mute-list' +export interface CreateOrEditListModal { + name: 'create-or-edit-list' + purpose?: string list?: ListModel onSave?: (uri: string) => void } -export interface ListAddRemoveUserModal { - name: 'list-add-remove-user' +export interface UserAddRemoveListsModal { + name: 'user-add-remove-lists' subject: string displayName: string - onUpdate?: () => void + onAdd?: (listUri: string) => void + onRemove?: (listUri: string) => void +} + +export interface ListAddUserModal { + name: 'list-add-user' + list: ListModel + onAdd?: (profile: AppBskyActorDefs.ProfileViewBasic) => void } export interface EditImageModal { @@ -180,8 +188,11 @@ export type Modal = // Moderation | ModerationDetailsModal | ReportModal - | CreateOrEditMuteListModal - | ListAddRemoveUserModal + + // Lists + | CreateOrEditListModal + | UserAddRemoveListsModal + | ListAddUserModal // Posts | AltTextImageModal diff --git a/src/view/com/auth/onboarding/RecommendedFeeds.tsx b/src/view/com/auth/onboarding/RecommendedFeeds.tsx index aaba19c80..400b836d0 100644 --- a/src/view/com/auth/onboarding/RecommendedFeeds.tsx +++ b/src/view/com/auth/onboarding/RecommendedFeeds.tsx @@ -12,7 +12,7 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {usePalette} from 'lib/hooks/usePalette' import {useQuery} from '@tanstack/react-query' import {useStores} from 'state/index' -import {CustomFeedModel} from 'state/models/feeds/custom-feed' +import {FeedSourceModel} from 'state/models/content/feed-source' import {ErrorMessage} from 'view/com/util/error/ErrorMessage' type Props = { @@ -39,7 +39,9 @@ export const RecommendedFeeds = observer(function RecommendedFeedsImpl({ } return (feeds.length ? feeds : []).map(feed => { - return new CustomFeedModel(store, feed) + const model = new FeedSourceModel(store, feed.uri) + model.hydrateFeedGenerator(feed) + return model }) } catch (e) { return [] diff --git a/src/view/com/auth/onboarding/RecommendedFeedsItem.tsx b/src/view/com/auth/onboarding/RecommendedFeedsItem.tsx index 6796c64db..bee23c953 100644 --- a/src/view/com/auth/onboarding/RecommendedFeedsItem.tsx +++ b/src/view/com/auth/onboarding/RecommendedFeedsItem.tsx @@ -3,6 +3,7 @@ import {View} from 'react-native' import {observer} from 'mobx-react-lite' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {Text} from 'view/com/util/text/Text' +import {RichText} from 'view/com/util/text/RichText' import {Button} from 'view/com/util/forms/Button' import {UserAvatar} from 'view/com/util/UserAvatar' import * as Toast from 'view/com/util/Toast' @@ -10,12 +11,12 @@ import {HeartIcon} from 'lib/icons' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {sanitizeHandle} from 'lib/strings/handles' -import {CustomFeedModel} from 'state/models/feeds/custom-feed' +import {FeedSourceModel} from 'state/models/content/feed-source' export const RecommendedFeedsItem = observer(function RecommendedFeedsItemImpl({ item, }: { - item: CustomFeedModel + item: FeedSourceModel }) { const {isMobile} = useWebMediaQueries() const pal = usePalette('default') @@ -54,7 +55,7 @@ export const RecommendedFeedsItem = observer(function RecommendedFeedsItemImpl({ }, ]}> <View style={{marginTop: 2}}> - <UserAvatar type="algo" size={42} avatar={item.data.avatar} /> + <UserAvatar type="algo" size={42} avatar={item.avatar} /> </View> <View style={{flex: isMobile ? 1 : undefined}}> <Text @@ -65,11 +66,11 @@ export const RecommendedFeedsItem = observer(function RecommendedFeedsItemImpl({ </Text> <Text style={[pal.textLight, {marginBottom: 8}]} numberOfLines={1}> - by {sanitizeHandle(item.data.creator.handle, '@')} + by {sanitizeHandle(item.creatorHandle, '@')} </Text> - {item.data.description ? ( - <Text + {item.descriptionRT ? ( + <RichText type="xl" style={[ pal.text, @@ -79,9 +80,9 @@ export const RecommendedFeedsItem = observer(function RecommendedFeedsItemImpl({ marginBottom: 18, }, ]} - numberOfLines={6}> - {item.data.description} - </Text> + richText={item.descriptionRT} + numberOfLines={6} + /> ) : null} <View style={{flexDirection: 'row', alignItems: 'center', gap: 12}}> @@ -129,7 +130,7 @@ export const RecommendedFeedsItem = observer(function RecommendedFeedsItemImpl({ style={[pal.textLight, {position: 'relative', top: 2}]} /> <Text type="lg-medium" style={[pal.text, pal.textLight]}> - {item.data.likeCount || 0} + {item.likeCount || 0} </Text> </View> </View> diff --git a/src/view/com/feeds/FeedPage.tsx b/src/view/com/feeds/FeedPage.tsx index 725106d59..72b83b801 100644 --- a/src/view/com/feeds/FeedPage.tsx +++ b/src/view/com/feeds/FeedPage.tsx @@ -111,10 +111,6 @@ export const FeedPage = observer(function FeedPageImpl({ store.shell.openComposer({}) }, [store, track]) - const onPressTryAgain = React.useCallback(() => { - feed.refresh() - }, [feed]) - const onPressLoadLatest = React.useCallback(() => { scrollToTop() feed.refresh() @@ -179,10 +175,8 @@ export const FeedPage = observer(function FeedPageImpl({ <View testID={testID} style={s.h100pct}> <Feed testID={testID ? `${testID}-feed` : undefined} - key="default" feed={feed} scrollElRef={scrollElRef} - onPressTryAgain={onPressTryAgain} onScroll={onMainScroll} scrollEventThrottle={100} renderEmptyState={renderEmptyState} diff --git a/src/view/com/feeds/CustomFeed.tsx b/src/view/com/feeds/FeedSourceCard.tsx index e6df15a15..6b5a572b4 100644 --- a/src/view/com/feeds/CustomFeed.tsx +++ b/src/view/com/feeds/FeedSourceCard.tsx @@ -2,11 +2,12 @@ import React from 'react' import {Pressable, StyleProp, StyleSheet, View, ViewStyle} from 'react-native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {Text} from '../util/text/Text' +import {RichText} from '../util/text/RichText' import {usePalette} from 'lib/hooks/usePalette' import {s} from 'lib/styles' import {UserAvatar} from '../util/UserAvatar' import {observer} from 'mobx-react-lite' -import {CustomFeedModel} from 'state/models/feeds/custom-feed' +import {FeedSourceModel} from 'state/models/content/feed-source' import {useNavigation} from '@react-navigation/native' import {NavigationProp} from 'lib/routes/types' import {useStores} from 'state/index' @@ -15,14 +16,14 @@ import {AtUri} from '@atproto/api' import * as Toast from 'view/com/util/Toast' import {sanitizeHandle} from 'lib/strings/handles' -export const CustomFeed = observer(function CustomFeedImpl({ +export const FeedSourceCard = observer(function FeedSourceCardImpl({ item, style, showSaveBtn = false, showDescription = false, showLikes = false, }: { - item: CustomFeedModel + item: FeedSourceModel style?: StyleProp<ViewStyle> showSaveBtn?: boolean showDescription?: boolean @@ -40,7 +41,7 @@ export const CustomFeed = observer(function CustomFeedImpl({ message: `Remove ${item.displayName} from my feeds?`, onPressConfirm: async () => { try { - await store.me.savedFeeds.unsave(item) + await item.unsave() Toast.show('Removed from my feeds') } catch (e) { Toast.show('There was an issue contacting your server') @@ -50,7 +51,7 @@ export const CustomFeed = observer(function CustomFeedImpl({ }) } else { try { - await store.me.savedFeeds.save(item) + await item.save() Toast.show('Added to my feeds') } catch (e) { Toast.show('There was an issue contacting your server') @@ -65,22 +66,29 @@ export const CustomFeed = observer(function CustomFeedImpl({ accessibilityRole="button" style={[styles.container, pal.border, style]} onPress={() => { - navigation.push('CustomFeed', { - name: item.data.creator.did, - rkey: new AtUri(item.data.uri).rkey, - }) + if (item.type === 'feed-generator') { + navigation.push('ProfileFeed', { + name: item.creatorDid, + rkey: new AtUri(item.uri).rkey, + }) + } else if (item.type === 'list') { + navigation.push('ProfileList', { + name: item.creatorDid, + rkey: new AtUri(item.uri).rkey, + }) + } }} - key={item.data.uri}> + key={item.uri}> <View style={[styles.headerContainer]}> <View style={[s.mr10]}> - <UserAvatar type="algo" size={36} avatar={item.data.avatar} /> + <UserAvatar type="algo" size={36} avatar={item.avatar} /> </View> <View style={[styles.headerTextContainer]}> <Text style={[pal.text, s.bold]} numberOfLines={3}> {item.displayName} </Text> <Text style={[pal.textLight]} numberOfLines={3}> - by {sanitizeHandle(item.data.creator.handle, '@')} + by {sanitizeHandle(item.creatorHandle, '@')} </Text> </View> {showSaveBtn && ( @@ -112,16 +120,18 @@ export const CustomFeed = observer(function CustomFeedImpl({ )} </View> - {showDescription && item.data.description ? ( - <Text style={[pal.textLight, styles.description]} numberOfLines={3}> - {item.data.description} - </Text> + {showDescription && item.descriptionRT ? ( + <RichText + style={[pal.textLight, styles.description]} + richText={item.descriptionRT} + numberOfLines={3} + /> ) : null} {showLikes ? ( <Text type="sm-medium" style={[pal.text, pal.textLight]}> - Liked by {item.data.likeCount || 0}{' '} - {pluralize(item.data.likeCount || 0, 'user')} + Liked by {item.likeCount || 0}{' '} + {pluralize(item.likeCount || 0, 'user')} </Text> ) : null} </Pressable> diff --git a/src/view/com/lists/ListActions.tsx b/src/view/com/lists/ListActions.tsx deleted file mode 100644 index 353338198..000000000 --- a/src/view/com/lists/ListActions.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import React from 'react' -import {StyleSheet, View} from 'react-native' -import {Button} from '../util/forms/Button' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {usePalette} from 'lib/hooks/usePalette' - -export const ListActions = ({ - muted, - onToggleSubscribed, - onPressEditList, - isOwner, - onPressDeleteList, - onPressShareList, - onPressReportList, - reversed = false, // Default value of reversed is false -}: { - isOwner: boolean - muted?: boolean - onToggleSubscribed?: () => void - onPressEditList?: () => void - onPressDeleteList?: () => void - onPressShareList?: () => void - onPressReportList?: () => void - reversed?: boolean // New optional prop -}) => { - const pal = usePalette('default') - - let buttons = [ - <Button - key="subscribeListBtn" - testID={muted ? 'unsubscribeListBtn' : 'subscribeListBtn'} - type={muted ? 'inverted' : 'primary'} - label={muted ? 'Unsubscribe' : 'Subscribe & Mute'} - accessibilityLabel={muted ? 'Unsubscribe' : 'Subscribe and mute'} - accessibilityHint="" - onPress={onToggleSubscribed} - />, - isOwner && ( - <Button - key="editListBtn" - testID="editListBtn" - type="default" - label="Edit List" - accessibilityLabel="Edit list" - accessibilityHint="" - onPress={onPressEditList} - /> - ), - isOwner && ( - <Button - key="deleteListBtn" - testID="deleteListBtn" - type="default" - accessibilityLabel="Delete list" - accessibilityHint="" - onPress={onPressDeleteList}> - <FontAwesomeIcon icon={['far', 'trash-can']} style={[pal.text]} /> - </Button> - ), - <Button - key="shareListBtn" - testID="shareListBtn" - type="default" - accessibilityLabel="Share list" - accessibilityHint="" - onPress={onPressShareList}> - <FontAwesomeIcon icon={'share'} style={[pal.text]} /> - </Button>, - !isOwner && ( - <Button - key="reportListBtn" - testID="reportListBtn" - type="default" - accessibilityLabel="Report list" - accessibilityHint="" - onPress={onPressReportList}> - <FontAwesomeIcon icon={'circle-exclamation'} style={[pal.text]} /> - </Button> - ), - ] - - // If reversed is true, reverse the array to reverse the order of the buttons - if (reversed) { - buttons = buttons.filter(Boolean).reverse() // filterting out any falsey values and reversing the array - } else { - buttons = buttons.filter(Boolean) // filterting out any falsey values - } - - return <View style={styles.headerBtns}>{buttons}</View> -} - -const styles = StyleSheet.create({ - headerBtns: { - flexDirection: 'row', - gap: 8, - marginTop: 12, - }, -}) diff --git a/src/view/com/lists/ListCard.tsx b/src/view/com/lists/ListCard.tsx index 159d966eb..a481902d8 100644 --- a/src/view/com/lists/ListCard.tsx +++ b/src/view/com/lists/ListCard.tsx @@ -76,7 +76,10 @@ export const ListCard = ({ {sanitizeDisplayName(list.name)} </Text> <Text type="md" style={[pal.textLight]} numberOfLines={1}> - {list.purpose === 'app.bsky.graph.defs#modlist' && 'Mute list'} by{' '} + {list.purpose === 'app.bsky.graph.defs#curatelist' && 'User list '} + {list.purpose === 'app.bsky.graph.defs#modlist' && + 'Moderation list '} + by{' '} {list.creator.did === store.me.did ? 'you' : sanitizeHandle(list.creator.handle, '@')} diff --git a/src/view/com/lists/ListItems.tsx b/src/view/com/lists/ListItems.tsx index c5ea13169..855c07d14 100644 --- a/src/view/com/lists/ListItems.tsx +++ b/src/view/com/lists/ListItems.tsx @@ -3,34 +3,26 @@ import { ActivityIndicator, RefreshControl, StyleProp, - StyleSheet, View, ViewStyle, - FlatList, } from 'react-native' -import {AppBskyActorDefs, AppBskyGraphDefs, RichText} from '@atproto/api' +import {AppBskyActorDefs, AppBskyGraphDefs} from '@atproto/api' +import {FlatList} from '../util/Views' import {observer} from 'mobx-react-lite' import {ProfileCardFeedLoadingPlaceholder} from '../util/LoadingPlaceholder' import {ErrorMessage} from '../util/error/ErrorMessage' import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn' import {ProfileCard} from '../profile/ProfileCard' import {Button} from '../util/forms/Button' -import {Text} from '../util/text/Text' -import {RichText as RichTextCom} from '../util/text/RichText' -import {UserAvatar} from '../util/UserAvatar' -import {TextLink} from '../util/Link' import {ListModel} from 'state/models/content/list' import {useAnalytics} from 'lib/analytics/analytics' import {usePalette} from 'lib/hooks/usePalette' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useStores} from 'state/index' +import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {s} from 'lib/styles' -import {ListActions} from './ListActions' -import {makeProfileLink} from 'lib/routes/links' -import {sanitizeHandle} from 'lib/strings/handles' +import {OnScrollCb} from 'lib/hooks/useOnMainScroll' const LOADING_ITEM = {_reactKey: '__loading__'} -const HEADER_ITEM = {_reactKey: '__header__'} const EMPTY_ITEM = {_reactKey: '__empty__'} const ERROR_ITEM = {_reactKey: '__error__'} const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'} @@ -39,36 +31,35 @@ export const ListItems = observer(function ListItemsImpl({ list, style, scrollElRef, + onScroll, onPressTryAgain, - onToggleSubscribed, - onPressEditList, - onPressDeleteList, - onPressShareList, - onPressReportList, + renderHeader, renderEmptyState, testID, + scrollEventThrottle, headerOffset = 0, + desktopFixedHeightOffset, }: { list: ListModel style?: StyleProp<ViewStyle> scrollElRef?: MutableRefObject<FlatList<any> | null> + onScroll?: OnScrollCb onPressTryAgain?: () => void - onToggleSubscribed: () => void - onPressEditList: () => void - onPressDeleteList: () => void - onPressShareList: () => void - onPressReportList: () => void - renderEmptyState?: () => JSX.Element + renderHeader: () => JSX.Element + renderEmptyState: () => JSX.Element testID?: string + scrollEventThrottle?: number headerOffset?: number + desktopFixedHeightOffset?: number }) { const pal = usePalette('default') const store = useStores() const {track} = useAnalytics() const [isRefreshing, setIsRefreshing] = React.useState(false) + const {isMobile} = useWebMediaQueries() const data = React.useMemo(() => { - let items: any[] = [HEADER_ITEM] + let items: any[] = [] if (list.hasLoaded) { if (list.hasError) { items = items.concat([ERROR_ITEM]) @@ -124,11 +115,18 @@ export const ListItems = observer(function ListItemsImpl({ const onPressEditMembership = React.useCallback( (profile: AppBskyActorDefs.ProfileViewBasic) => { store.shell.openModal({ - name: 'list-add-remove-user', + name: 'user-add-remove-lists', subject: profile.did, displayName: profile.displayName || profile.handle, - onUpdate() { - list.refresh() + onAdd(listUri: string) { + if (listUri === list.uri) { + list.cacheAddMember(profile) + } + }, + onRemove(listUri: string) { + if (listUri === list.uri) { + list.cacheRemoveMember(profile) + } }, }) }, @@ -145,6 +143,7 @@ export const ListItems = observer(function ListItemsImpl({ } return ( <Button + testID={`user-${profile.handle}-editBtn`} type="default" label="Edit" onPress={() => onPressEditMembership(profile)} @@ -157,22 +156,7 @@ export const ListItems = observer(function ListItemsImpl({ const renderItem = React.useCallback( ({item}: {item: any}) => { if (item === EMPTY_ITEM) { - if (renderEmptyState) { - return renderEmptyState() - } - return <View /> - } else if (item === HEADER_ITEM) { - return list.list ? ( - <ListHeader - list={list.list} - isOwner={list.isOwner} - onToggleSubscribed={onToggleSubscribed} - onPressEditList={onPressEditList} - onPressDeleteList={onPressDeleteList} - onPressShareList={onPressShareList} - onPressReportList={onPressReportList} - /> - ) : null + return renderEmptyState() } else if (item === ERROR_ITEM) { return ( <ErrorMessage @@ -197,178 +181,59 @@ export const ListItems = observer(function ListItemsImpl({ }`} profile={(item as AppBskyGraphDefs.ListItemView).subject} renderButton={renderMemberButton} + style={{paddingHorizontal: isMobile ? 8 : 14, paddingVertical: 4}} /> ) }, [ renderMemberButton, renderEmptyState, - list.list, - list.isOwner, list.error, - onToggleSubscribed, - onPressEditList, - onPressDeleteList, - onPressShareList, - onPressReportList, onPressTryAgain, onPressRetryLoadMore, + isMobile, ], ) const Footer = React.useCallback( - () => - list.isLoading ? ( - <View style={styles.feedFooter}> - <ActivityIndicator /> - </View> - ) : ( - <View /> - ), - [list], + () => ( + <View style={{paddingTop: 20, paddingBottom: 200}}> + {list.isLoading && <ActivityIndicator />} + </View> + ), + [list.isLoading], ) return ( <View testID={testID} style={style}> - {data.length > 0 && ( - <FlatList - testID={testID ? `${testID}-flatlist` : undefined} - ref={scrollElRef} - data={data} - keyExtractor={item => item._reactKey} - renderItem={renderItem} - ListFooterComponent={Footer} - refreshControl={ - <RefreshControl - refreshing={isRefreshing} - onRefresh={onRefresh} - tintColor={pal.colors.text} - titleColor={pal.colors.text} - progressViewOffset={headerOffset} - /> - } - contentContainerStyle={s.contentContainer} - style={{paddingTop: headerOffset}} - onEndReached={onEndReached} - onEndReachedThreshold={0.6} - removeClippedSubviews={true} - contentOffset={{x: 0, y: headerOffset * -1}} - // @ts-ignore our .web version only -prf - desktopFixedHeight - /> - )} + <FlatList + testID={testID ? `${testID}-flatlist` : undefined} + ref={scrollElRef} + data={data} + keyExtractor={(item: any) => item._reactKey} + renderItem={renderItem} + ListHeaderComponent={renderHeader} + ListFooterComponent={Footer} + refreshControl={ + <RefreshControl + refreshing={isRefreshing} + onRefresh={onRefresh} + tintColor={pal.colors.text} + titleColor={pal.colors.text} + progressViewOffset={headerOffset} + /> + } + contentContainerStyle={s.contentContainer} + style={{paddingTop: headerOffset}} + onScroll={onScroll} + onEndReached={onEndReached} + onEndReachedThreshold={0.6} + scrollEventThrottle={scrollEventThrottle} + removeClippedSubviews={true} + contentOffset={{x: 0, y: headerOffset * -1}} + // @ts-ignore our .web version only -prf + desktopFixedHeight={desktopFixedHeightOffset || true} + /> </View> ) }) - -const ListHeader = observer(function ListHeaderImpl({ - list, - isOwner, - onToggleSubscribed, - onPressEditList, - onPressDeleteList, - onPressShareList, - onPressReportList, -}: { - list: AppBskyGraphDefs.ListView - isOwner: boolean - onToggleSubscribed: () => void - onPressEditList: () => void - onPressDeleteList: () => void - onPressShareList: () => void - onPressReportList: () => void -}) { - const pal = usePalette('default') - const store = useStores() - const {isDesktop} = useWebMediaQueries() - const descriptionRT = React.useMemo( - () => - list?.description && - new RichText({ - text: list.description, - facets: (list.descriptionFacets || [])?.slice(), - }), - [list], - ) - return ( - <> - <View style={[styles.header, pal.border]}> - <View style={s.flex1}> - <Text testID="listName" type="title-xl" style={[pal.text, s.bold]}> - {list.name} - </Text> - {list && ( - <Text type="md" style={[pal.textLight]} numberOfLines={1}> - {list.purpose === 'app.bsky.graph.defs#modlist' && 'Mute list '} - by{' '} - {list.creator.did === store.me.did ? ( - 'you' - ) : ( - <TextLink - text={sanitizeHandle(list.creator.handle, '@')} - href={makeProfileLink(list.creator)} - style={pal.textLight} - /> - )} - </Text> - )} - {descriptionRT && ( - <RichTextCom - testID="listDescription" - style={[pal.text, styles.headerDescription]} - richText={descriptionRT} - /> - )} - {isDesktop && ( - <ListActions - isOwner={isOwner} - muted={list.viewer?.muted} - onPressDeleteList={onPressDeleteList} - onPressEditList={onPressEditList} - onToggleSubscribed={onToggleSubscribed} - onPressShareList={onPressShareList} - onPressReportList={onPressReportList} - /> - )} - </View> - <View> - <UserAvatar type="list" avatar={list.avatar} size={64} /> - </View> - </View> - <View - style={{flexDirection: 'row', paddingHorizontal: isDesktop ? 16 : 6}}> - <View style={[styles.fakeSelectorItem, {borderColor: pal.colors.link}]}> - <Text type="md-medium" style={[pal.text]}> - Muted users - </Text> - </View> - </View> - </> - ) -}) - -const styles = StyleSheet.create({ - header: { - flexDirection: 'row', - gap: 12, - paddingHorizontal: 16, - paddingTop: 12, - paddingBottom: 16, - borderTopWidth: 1, - }, - headerDescription: { - flex: 1, - marginTop: 8, - }, - headerBtns: { - flexDirection: 'row', - gap: 8, - marginTop: 12, - }, - fakeSelectorItem: { - paddingHorizontal: 12, - paddingBottom: 8, - borderBottomWidth: 3, - }, - feedFooter: {paddingTop: 20}, -}) diff --git a/src/view/com/lists/ListsList.tsx b/src/view/com/lists/ListsList.tsx index 4c8befa1f..efc874ef3 100644 --- a/src/view/com/lists/ListsList.tsx +++ b/src/view/com/lists/ListsList.tsx @@ -1,57 +1,44 @@ -import React, {MutableRefObject} from 'react' +import React from 'react' import { + ActivityIndicator, + FlatList as RNFlatList, RefreshControl, StyleProp, StyleSheet, View, ViewStyle, - FlatList, } from 'react-native' import {observer} from 'mobx-react-lite' -import { - FontAwesomeIcon, - FontAwesomeIconStyle, -} from '@fortawesome/react-native-fontawesome' import {AppBskyGraphDefs as GraphDefs} from '@atproto/api' import {ListCard} from './ListCard' -import {ProfileCardFeedLoadingPlaceholder} from '../util/LoadingPlaceholder' import {ErrorMessage} from '../util/error/ErrorMessage' import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn' -import {Button} from '../util/forms/Button' import {Text} from '../util/text/Text' import {ListsListModel} from 'state/models/lists/lists-list' import {useAnalytics} from 'lib/analytics/analytics' import {usePalette} from 'lib/hooks/usePalette' +import {FlatList} from '../util/Views.web' import {s} from 'lib/styles' -const LOADING_ITEM = {_reactKey: '__loading__'} -const CREATENEW_ITEM = {_reactKey: '__loading__'} -const EMPTY_ITEM = {_reactKey: '__empty__'} +const LOADING = {_reactKey: '__loading__'} +const EMPTY = {_reactKey: '__empty__'} const ERROR_ITEM = {_reactKey: '__error__'} const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'} export const ListsList = observer(function ListsListImpl({ listsList, - showAddBtns, + inline, style, - scrollElRef, onPressTryAgain, - onPressCreateNew, renderItem, - renderEmptyState, testID, - headerOffset = 0, }: { listsList: ListsListModel - showAddBtns?: boolean + inline?: boolean style?: StyleProp<ViewStyle> - scrollElRef?: MutableRefObject<FlatList<any> | null> - onPressCreateNew: () => void onPressTryAgain?: () => void - renderItem?: (list: GraphDefs.ListView) => JSX.Element - renderEmptyState?: () => JSX.Element + renderItem?: (list: GraphDefs.ListView, index: number) => JSX.Element testID?: string - headerOffset?: number }) { const pal = usePalette('default') const {track} = useAnalytics() @@ -59,33 +46,27 @@ export const ListsList = observer(function ListsListImpl({ const data = React.useMemo(() => { let items: any[] = [] - if (listsList.hasLoaded) { - if (listsList.hasError) { - items = items.concat([ERROR_ITEM]) - } - if (listsList.isEmpty) { - items = items.concat([EMPTY_ITEM]) - } else { - if (showAddBtns) { - items = items.concat([CREATENEW_ITEM]) - } - items = items.concat(listsList.lists) - } - if (listsList.loadMoreError) { - items = items.concat([LOAD_MORE_ERROR_ITEM]) - } - } else if (listsList.isLoading) { - items = items.concat([LOADING_ITEM]) + if (listsList.hasError) { + items = items.concat([ERROR_ITEM]) + } + if (!listsList.hasLoaded && listsList.isLoading) { + items = items.concat([LOADING]) + } else if (listsList.isEmpty) { + items = items.concat([EMPTY]) + } else { + items = items.concat(listsList.lists) + } + if (listsList.loadMoreError) { + items = items.concat([LOAD_MORE_ERROR_ITEM]) } return items }, [ listsList.hasError, listsList.hasLoaded, listsList.isLoading, - listsList.isEmpty, listsList.lists, + listsList.isEmpty, listsList.loadMoreError, - showAddBtns, ]) // events @@ -119,14 +100,15 @@ export const ListsList = observer(function ListsListImpl({ // = const renderItemInner = React.useCallback( - ({item}: {item: any}) => { - if (item === EMPTY_ITEM) { - if (renderEmptyState) { - return renderEmptyState() - } - return <View /> - } else if (item === CREATENEW_ITEM) { - return <CreateNewItem onPress={onPressCreateNew} /> + ({item, index}: {item: any; index: number}) => { + if (item === EMPTY) { + return ( + <View + testID="listsEmpty" + style={[{padding: 18, borderTopWidth: 1}, pal.border]}> + <Text style={pal.textLight}>You have no lists.</Text> + </View> + ) } else if (item === ERROR_ITEM) { return ( <ErrorMessage @@ -141,11 +123,15 @@ export const ListsList = observer(function ListsListImpl({ onPress={onPressRetryLoadMore} /> ) - } else if (item === LOADING_ITEM) { - return <ProfileCardFeedLoadingPlaceholder /> + } else if (item === LOADING) { + return ( + <View style={{padding: 20}}> + <ActivityIndicator /> + </View> + ) } return renderItem ? ( - renderItem(item) + renderItem(item, index) ) : ( <ListCard list={item} @@ -154,24 +140,17 @@ export const ListsList = observer(function ListsListImpl({ /> ) }, - [ - listsList, - onPressTryAgain, - onPressRetryLoadMore, - onPressCreateNew, - renderItem, - renderEmptyState, - ], + [listsList, onPressTryAgain, onPressRetryLoadMore, renderItem, pal], ) + const FlatListCom = inline ? RNFlatList : FlatList return ( <View testID={testID} style={style}> {data.length > 0 && ( - <FlatList + <FlatListCom testID={testID ? `${testID}-flatlist` : undefined} - ref={scrollElRef} data={data} - keyExtractor={item => item._reactKey} + keyExtractor={(item: any) => item._reactKey} renderItem={renderItemInner} refreshControl={ <RefreshControl @@ -179,15 +158,12 @@ export const ListsList = observer(function ListsListImpl({ onRefresh={onRefresh} tintColor={pal.colors.text} titleColor={pal.colors.text} - progressViewOffset={headerOffset} /> } contentContainerStyle={[s.contentContainer]} - style={{paddingTop: headerOffset}} onEndReached={onEndReached} onEndReachedThreshold={0.6} removeClippedSubviews={true} - contentOffset={{x: 0, y: headerOffset * -1}} // @ts-ignore our .web version only -prf desktopFixedHeight /> @@ -196,36 +172,9 @@ export const ListsList = observer(function ListsListImpl({ ) }) -function CreateNewItem({onPress}: {onPress: () => void}) { - const pal = usePalette('default') - - return ( - <View style={[styles.createNewContainer]}> - <Button type="default" onPress={onPress} style={styles.createNewButton}> - <FontAwesomeIcon icon="plus" style={pal.text as FontAwesomeIconStyle} /> - <Text type="button" style={pal.text}> - New Mute List - </Text> - </Button> - </View> - ) -} - const styles = StyleSheet.create({ - createNewContainer: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 18, - paddingTop: 18, - paddingBottom: 16, - }, - createNewButton: { - flexDirection: 'row', - alignItems: 'center', - gap: 8, - }, - feedFooter: {paddingTop: 20}, item: { paddingHorizontal: 18, + paddingVertical: 4, }, }) diff --git a/src/view/com/modals/CreateOrEditMuteList.tsx b/src/view/com/modals/CreateOrEditList.tsx index 4a440afeb..1ea12695f 100644 --- a/src/view/com/modals/CreateOrEditMuteList.tsx +++ b/src/view/com/modals/CreateOrEditList.tsx @@ -1,4 +1,4 @@ -import React, {useState, useCallback} from 'react' +import React, {useState, useCallback, useMemo} from 'react' import * as Toast from '../util/Toast' import { ActivityIndicator, @@ -31,9 +31,11 @@ const MAX_DESCRIPTION = 300 // todo export const snapPoints = ['fullscreen'] export function Component({ + purpose, onSave, list, }: { + purpose?: string onSave?: (uri: string) => void list?: ListModel }) { @@ -44,12 +46,24 @@ export function Component({ const theme = useTheme() const {track} = useAnalytics() + const activePurpose = useMemo(() => { + if (list?.data?.purpose) { + return list.data.purpose + } + if (purpose) { + return purpose + } + return 'app.bsky.graph.defs#curatelist' + }, [list, purpose]) + const isCurateList = activePurpose === 'app.bsky.graph.defs#curatelist' + const purposeLabel = isCurateList ? 'User' : 'Moderation' + const [isProcessing, setProcessing] = useState<boolean>(false) - const [name, setName] = useState<string>(list?.list?.name || '') + const [name, setName] = useState<string>(list?.data?.name || '') const [description, setDescription] = useState<string>( - list?.list?.description || '', + list?.data?.description || '', ) - const [avatar, setAvatar] = useState<string | undefined>(list?.list?.avatar) + const [avatar, setAvatar] = useState<string | undefined>(list?.data?.avatar) const [newAvatar, setNewAvatar] = useState<RNImage | undefined | null>() const onPressCancel = useCallback(() => { @@ -63,7 +77,7 @@ export function Component({ setAvatar(undefined) return } - track('CreateMuteList:AvatarSelected') + track('CreateList:AvatarSelected') try { const finalImg = await compressIfNeeded(img, 1000000) setNewAvatar(finalImg) @@ -76,7 +90,11 @@ export function Component({ ) const onPressSave = useCallback(async () => { - track('CreateMuteList:Save') + if (isCurateList) { + track('CreateList:SaveCurateList') + } else { + track('CreateList:SaveModList') + } const nameTrimmed = name.trim() if (!nameTrimmed) { setError('Name is required') @@ -93,22 +111,23 @@ export function Component({ description: description.trim(), avatar: newAvatar, }) - Toast.show('Mute list updated') + Toast.show(`${purposeLabel} list updated`) onSave?.(list.uri) } else { - const res = await ListModel.createModList(store, { + const res = await ListModel.createList(store, { + purpose: activePurpose, name, description, avatar: newAvatar, }) - Toast.show('Mute list created') + Toast.show(`${purposeLabel} list created`) onSave?.(res.uri) } store.shell.closeModal() } catch (e: any) { if (isNetworkError(e)) { setError( - 'Failed to create the mute list. Check your internet connection and try again.', + 'Failed to create the list. Check your internet connection and try again.', ) } else { setError(cleanError(e)) @@ -122,6 +141,9 @@ export function Component({ error, onSave, store, + activePurpose, + isCurateList, + purposeLabel, name, description, newAvatar, @@ -137,9 +159,9 @@ export function Component({ paddingHorizontal: isMobile ? 16 : 0, }, ]} - testID="createOrEditMuteListModal"> + testID="createOrEditListModal"> <Text style={[styles.title, pal.text]}> - {list ? 'Edit Mute List' : 'New Mute List'} + {list ? 'Edit' : 'New'} {purposeLabel} List </Text> {error !== '' && ( <View style={styles.errorContainer}> @@ -163,7 +185,9 @@ export function Component({ <TextInput testID="editNameInput" style={[styles.textInput, pal.border, pal.text]} - placeholder="e.g. spammers" + placeholder={ + isCurateList ? 'e.g. Great Posters' : 'e.g. Spammers' + } placeholderTextColor={colors.gray4} value={name} onChangeText={v => setName(enforceLen(v, MAX_NAME))} @@ -180,7 +204,11 @@ export function Component({ <TextInput testID="editDescriptionInput" style={[styles.textArea, pal.border, pal.text]} - placeholder="e.g. users that repeatedly reply with ads." + placeholder={ + isCurateList + ? 'e.g. The posters who never miss.' + : 'e.g. Users that repeatedly reply with ads.' + } placeholderTextColor={colors.gray4} keyboardAppearance={theme.colorScheme} multiline @@ -203,7 +231,7 @@ export function Component({ onPress={onPressSave} accessibilityRole="button" accessibilityLabel="Save" - accessibilityHint="Creates the mute list"> + accessibilityHint=""> <LinearGradient colors={[gradients.blueLight.start, gradients.blueLight.end]} start={{x: 0, y: 0}} diff --git a/src/view/com/modals/ListAddUser.tsx b/src/view/com/modals/ListAddUser.tsx new file mode 100644 index 000000000..6ee20ff13 --- /dev/null +++ b/src/view/com/modals/ListAddUser.tsx @@ -0,0 +1,281 @@ +import React, {useEffect, useCallback, useState, useMemo} from 'react' +import { + ActivityIndicator, + Pressable, + SafeAreaView, + StyleSheet, + View, +} from 'react-native' +import {AppBskyActorDefs} from '@atproto/api' +import {ScrollView, TextInput} from './util' +import {observer} from 'mobx-react-lite' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {Text} from '../util/text/Text' +import {Button} from '../util/forms/Button' +import {UserAvatar} from '../util/UserAvatar' +import * as Toast from '../util/Toast' +import {useStores} from 'state/index' +import {ListModel} from 'state/models/content/list' +import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete' +import {s, colors} from 'lib/styles' +import {usePalette} from 'lib/hooks/usePalette' +import {isWeb} from 'platform/detection' +import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {cleanError} from 'lib/strings/errors' +import {sanitizeDisplayName} from 'lib/strings/display-names' +import {sanitizeHandle} from 'lib/strings/handles' + +export const snapPoints = ['90%'] + +export const Component = observer(function Component({ + list, + onAdd, +}: { + list: ListModel + onAdd?: (profile: AppBskyActorDefs.ProfileViewBasic) => void +}) { + const pal = usePalette('default') + const store = useStores() + const {isMobile} = useWebMediaQueries() + const [query, setQuery] = useState('') + const autocompleteView = useMemo<UserAutocompleteModel>( + () => new UserAutocompleteModel(store), + [store], + ) + + // initial setup + useEffect(() => { + autocompleteView.setup().then(() => { + autocompleteView.setPrefix('') + }) + autocompleteView.setActive(true) + list.loadAll() + }, [autocompleteView, list]) + + const onChangeQuery = useCallback( + (text: string) => { + setQuery(text) + autocompleteView.setPrefix(text) + }, + [setQuery, autocompleteView], + ) + + const onPressCancelSearch = useCallback( + () => onChangeQuery(''), + [onChangeQuery], + ) + + return ( + <SafeAreaView + testID="listAddUserModal" + style={[pal.view, isWeb ? styles.fixedHeight : s.flex1]}> + <View + style={[ + s.flex1, + isMobile && {paddingHorizontal: 18, paddingBottom: 40}, + ]}> + <View style={styles.titleSection}> + <Text type="title-lg" style={[pal.text, styles.title]}> + Add User to List + </Text> + </View> + <View style={[styles.searchContainer, pal.border]}> + <FontAwesomeIcon icon="search" size={16} /> + <TextInput + testID="searchInput" + style={[styles.searchInput, pal.border, pal.text]} + placeholder="Search for users" + placeholderTextColor={pal.colors.textLight} + value={query} + onChangeText={onChangeQuery} + accessible={true} + accessibilityLabel="Search" + accessibilityHint="" + autoCapitalize="none" + autoComplete="off" + autoCorrect={false} + /> + {query ? ( + <Pressable + onPress={onPressCancelSearch} + accessibilityRole="button" + accessibilityLabel="Cancel search" + accessibilityHint="Exits inputting search query" + onAccessibilityEscape={onPressCancelSearch}> + <FontAwesomeIcon + icon="xmark" + size={16} + color={pal.colors.textLight} + /> + </Pressable> + ) : undefined} + </View> + <ScrollView style={[s.flex1]}> + {autocompleteView.suggestions.length ? ( + <> + {autocompleteView.suggestions.slice(0, 40).map((item, i) => ( + <UserResult + key={item.did} + list={list} + profile={item} + noBorder={i === 0} + onAdd={onAdd} + /> + ))} + </> + ) : ( + <Text + type="xl" + style={[ + pal.textLight, + {paddingHorizontal: 12, paddingVertical: 16}, + ]}> + No results found for {autocompleteView.prefix} + </Text> + )} + </ScrollView> + <View style={[styles.btnContainer]}> + <Button + testID="doneBtn" + type="primary" + onPress={() => store.shell.closeModal()} + accessibilityLabel="Done" + accessibilityHint="" + label="Done" + labelContainerStyle={{justifyContent: 'center', padding: 4}} + labelStyle={[s.f18]} + /> + </View> + </View> + </SafeAreaView> + ) +}) + +function UserResult({ + profile, + list, + noBorder, + onAdd, +}: { + profile: AppBskyActorDefs.ProfileViewBasic + list: ListModel + noBorder: boolean + onAdd?: (profile: AppBskyActorDefs.ProfileViewBasic) => void | undefined +}) { + const pal = usePalette('default') + const [isProcessing, setIsProcessing] = useState(false) + const [isAdded, setIsAdded] = useState(list.isMember(profile.did)) + + const onPressAdd = useCallback(async () => { + setIsProcessing(true) + try { + await list.addMember(profile) + Toast.show('Added to list') + setIsAdded(true) + onAdd?.(profile) + } catch (e) { + Toast.show(cleanError(e)) + } finally { + setIsProcessing(false) + } + }, [list, profile, setIsProcessing, setIsAdded, onAdd]) + + return ( + <View + style={[ + pal.border, + { + flexDirection: 'row', + alignItems: 'center', + borderTopWidth: noBorder ? 0 : 1, + paddingVertical: 8, + paddingHorizontal: 8, + }, + ]}> + <View + style={{ + alignSelf: 'baseline', + width: 54, + paddingLeft: 4, + paddingTop: 10, + }}> + <UserAvatar size={40} avatar={profile.avatar} /> + </View> + <View + style={{ + flex: 1, + paddingRight: 10, + paddingTop: 10, + paddingBottom: 10, + }}> + <Text + type="lg" + style={[s.bold, pal.text]} + numberOfLines={1} + lineHeight={1.2}> + {sanitizeDisplayName( + profile.displayName || sanitizeHandle(profile.handle), + )} + </Text> + <Text type="md" style={[pal.textLight]} numberOfLines={1}> + {sanitizeHandle(profile.handle, '@')} + </Text> + {!!profile.viewer?.followedBy && <View style={s.flexRow} />} + </View> + <View> + {isAdded ? ( + <FontAwesomeIcon icon="check" /> + ) : isProcessing ? ( + <ActivityIndicator /> + ) : ( + <Button + testID={`user-${profile.handle}-addBtn`} + type="default" + label="Add" + onPress={onPressAdd} + /> + )} + </View> + </View> + ) +} + +const styles = StyleSheet.create({ + fixedHeight: { + // @ts-ignore web only -prf + height: '80vh', + }, + titleSection: { + paddingTop: isWeb ? 0 : 4, + paddingBottom: isWeb ? 14 : 10, + }, + title: { + textAlign: 'center', + fontWeight: '600', + marginBottom: 5, + }, + searchContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + borderWidth: 1, + borderRadius: 24, + paddingHorizontal: 16, + paddingVertical: 10, + }, + searchInput: { + fontSize: 16, + flex: 1, + }, + btn: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + borderRadius: 32, + padding: 14, + backgroundColor: colors.blue3, + }, + btnContainer: { + paddingTop: 20, + }, +}) diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx index 1fe1299d7..5aaa09e87 100644 --- a/src/view/com/modals/Modal.tsx +++ b/src/view/com/modals/Modal.tsx @@ -16,8 +16,9 @@ import * as ProfilePreviewModal from './ProfilePreview' import * as ServerInputModal from './ServerInput' import * as RepostModal from './Repost' import * as SelfLabelModal from './SelfLabel' -import * as CreateOrEditMuteListModal from './CreateOrEditMuteList' -import * as ListAddRemoveUserModal from './ListAddRemoveUser' +import * as CreateOrEditListModal from './CreateOrEditList' +import * as UserAddRemoveListsModal from './UserAddRemoveLists' +import * as ListAddUserModal from './ListAddUser' import * as AltImageModal from './AltImage' import * as EditImageModal from './AltImage' import * as ReportModal from './report/Modal' @@ -101,12 +102,15 @@ export const ModalsContainer = observer(function ModalsContainer() { } else if (activeModal?.name === 'report') { snapPoints = ReportModal.snapPoints element = <ReportModal.Component {...activeModal} /> - } else if (activeModal?.name === 'create-or-edit-mute-list') { - snapPoints = CreateOrEditMuteListModal.snapPoints - element = <CreateOrEditMuteListModal.Component {...activeModal} /> - } else if (activeModal?.name === 'list-add-remove-user') { - snapPoints = ListAddRemoveUserModal.snapPoints - element = <ListAddRemoveUserModal.Component {...activeModal} /> + } else if (activeModal?.name === 'create-or-edit-list') { + snapPoints = CreateOrEditListModal.snapPoints + element = <CreateOrEditListModal.Component {...activeModal} /> + } else if (activeModal?.name === 'user-add-remove-lists') { + snapPoints = UserAddRemoveListsModal.snapPoints + element = <UserAddRemoveListsModal.Component {...activeModal} /> + } else if (activeModal?.name === 'list-add-user') { + snapPoints = ListAddUserModal.snapPoints + element = <ListAddUserModal.Component {...activeModal} /> } else if (activeModal?.name === 'delete-account') { snapPoints = DeleteAccountModal.snapPoints element = <DeleteAccountModal.Component /> diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx index ee778d17d..ede845378 100644 --- a/src/view/com/modals/Modal.web.tsx +++ b/src/view/com/modals/Modal.web.tsx @@ -11,8 +11,9 @@ import * as EditProfileModal from './EditProfile' import * as ProfilePreviewModal from './ProfilePreview' import * as ServerInputModal from './ServerInput' import * as ReportModal from './report/Modal' -import * as CreateOrEditMuteListModal from './CreateOrEditMuteList' -import * as ListAddRemoveUserModal from './ListAddRemoveUser' +import * as CreateOrEditListModal from './CreateOrEditList' +import * as UserAddRemoveLists from './UserAddRemoveLists' +import * as ListAddUserModal from './ListAddUser' import * as DeleteAccountModal from './DeleteAccount' import * as RepostModal from './Repost' import * as SelfLabelModal from './SelfLabel' @@ -79,10 +80,12 @@ function Modal({modal}: {modal: ModalIface}) { element = <ServerInputModal.Component {...modal} /> } else if (modal.name === 'report') { element = <ReportModal.Component {...modal} /> - } else if (modal.name === 'create-or-edit-mute-list') { - element = <CreateOrEditMuteListModal.Component {...modal} /> - } else if (modal.name === 'list-add-remove-user') { - element = <ListAddRemoveUserModal.Component {...modal} /> + } else if (modal.name === 'create-or-edit-list') { + element = <CreateOrEditListModal.Component {...modal} /> + } else if (modal.name === 'user-add-remove-lists') { + element = <UserAddRemoveLists.Component {...modal} /> + } else if (modal.name === 'list-add-user') { + element = <ListAddUserModal.Component {...modal} /> } else if (modal.name === 'crop-image') { element = <CropImageModal.Component {...modal} /> } else if (modal.name === 'delete-account') { diff --git a/src/view/com/modals/ModerationDetails.tsx b/src/view/com/modals/ModerationDetails.tsx index bd51845c6..c01312d69 100644 --- a/src/view/com/modals/ModerationDetails.tsx +++ b/src/view/com/modals/ModerationDetails.tsx @@ -31,8 +31,25 @@ export function Component({ description = 'Moderator has chosen to set a general warning on the content.' } else if (moderation.cause.type === 'blocking') { - name = 'User Blocked' - description = 'You have blocked this user. You cannot view their content.' + if (moderation.cause.source.type === 'list') { + const list = moderation.cause.source.list + name = 'User Blocked by List' + description = ( + <> + This user is included in the{' '} + <TextLink + type="2xl" + href={listUriToHref(list.uri)} + text={list.name} + style={pal.link} + />{' '} + list which you have blocked. + </> + ) + } else { + name = 'User Blocked' + description = 'You have blocked this user. You cannot view their content.' + } } else if (moderation.cause.type === 'blocked-by') { name = 'User Blocks You' description = 'This user has blocked you. You cannot view their content.' diff --git a/src/view/com/modals/ListAddRemoveUser.tsx b/src/view/com/modals/UserAddRemoveLists.tsx index 58d6a529c..ff048ca29 100644 --- a/src/view/com/modals/ListAddRemoveUser.tsx +++ b/src/view/com/modals/UserAddRemoveLists.tsx @@ -1,6 +1,6 @@ import React, {useCallback} from 'react' import {observer} from 'mobx-react-lite' -import {Pressable, StyleSheet, View, ActivityIndicator} from 'react-native' +import {ActivityIndicator, Pressable, StyleSheet, View} from 'react-native' import {AppBskyGraphDefs as GraphDefs} from '@atproto/api' import { FontAwesomeIcon, @@ -11,7 +11,6 @@ import {UserAvatar} from '../util/UserAvatar' import {ListsList} from '../lists/ListsList' import {ListsListModel} from 'state/models/lists/lists-list' import {ListMembershipModel} from 'state/models/content/list-membership' -import {EmptyStateWithButton} from '../util/EmptyStateWithButton' import {Button} from '../util/forms/Button' import * as Toast from '../util/Toast' import {useStores} from 'state/index' @@ -24,14 +23,16 @@ import isEqual from 'lodash.isequal' export const snapPoints = ['fullscreen'] -export const Component = observer(function ListAddRemoveUserImpl({ +export const Component = observer(function UserAddRemoveListsImpl({ subject, displayName, - onUpdate, + onAdd, + onRemove, }: { subject: string displayName: string - onUpdate?: () => void + onAdd?: (listUri: string) => void + onRemove?: (listUri: string) => void }) { const store = useStores() const pal = usePalette('default') @@ -71,25 +72,22 @@ export const Component = observer(function ListAddRemoveUserImpl({ }, [store]) const onPressSave = useCallback(async () => { + let changes try { - await memberships.updateTo(selected) + changes = await memberships.updateTo(selected) } catch (err) { store.log.error('Failed to update memberships', {err}) return } Toast.show('Lists updated') - onUpdate?.() + for (const uri of changes.added) { + onAdd?.(uri) + } + for (const uri of changes.removed) { + onRemove?.(uri) + } store.shell.closeModal() - }, [store, selected, memberships, onUpdate]) - - const onPressNewMuteList = useCallback(() => { - store.shell.openModal({ - name: 'create-or-edit-mute-list', - onSave: (_uri: string) => { - listsList.refresh() - }, - }) - }, [store, listsList]) + }, [store, selected, memberships, onAdd, onRemove]) const onToggleSelected = useCallback( (uri: string) => { @@ -103,7 +101,7 @@ export const Component = observer(function ListAddRemoveUserImpl({ ) const renderItem = useCallback( - (list: GraphDefs.ListView) => { + (list: GraphDefs.ListView, index: number) => { const isSelected = selected.includes(list.uri) return ( <Pressable @@ -111,7 +109,10 @@ export const Component = observer(function ListAddRemoveUserImpl({ style={[ styles.listItem, pal.border, - {opacity: membershipsLoaded ? 1 : 0.5}, + { + opacity: membershipsLoaded ? 1 : 0.5, + borderTopWidth: index === 0 ? 0 : 1, + }, ]} accessibilityLabel={`${isSelected ? 'Remove from' : 'Add to'} ${ list.name @@ -131,7 +132,11 @@ export const Component = observer(function ListAddRemoveUserImpl({ {sanitizeDisplayName(list.name)} </Text> <Text type="md" style={[pal.textLight]} numberOfLines={1}> - {list.purpose === 'app.bsky.graph.defs#modlist' && 'Mute list'} by{' '} + {list.purpose === 'app.bsky.graph.defs#curatelist' && + 'User list '} + {list.purpose === 'app.bsky.graph.defs#modlist' && + 'Moderation list '} + by{' '} {list.creator.did === store.me.did ? 'you' : sanitizeHandle(list.creator.handle, '@')} @@ -166,30 +171,19 @@ export const Component = observer(function ListAddRemoveUserImpl({ ], ) - const renderEmptyState = React.useCallback(() => { - return ( - <EmptyStateWithButton - icon="users-slash" - message="You can subscribe to mute lists to automatically mute all of the users they include. Mute lists are public but your subscription to a mute list is private." - buttonLabel="New Mute List" - onPress={onPressNewMuteList} - /> - ) - }, [onPressNewMuteList]) - // Only show changes button if there are some items on the list to choose from AND user has made changes in selection const canSaveChanges = !listsList.isEmpty && !isEqual(selected, originalSelections) return ( - <View testID="listAddRemoveUserModal" style={s.hContentRegion}> - <Text style={[styles.title, pal.text]}>Add {displayName} to Lists</Text> + <View testID="userAddRemoveListsModal" style={s.hContentRegion}> + <Text style={[styles.title, pal.text]}> + Update {displayName} in Lists + </Text> <ListsList listsList={listsList} - showAddBtns - onPressCreateNew={onPressNewMuteList} + inline renderItem={renderItem} - renderEmptyState={renderEmptyState} style={[styles.list, pal.border]} /> <View style={[styles.btns, pal.border]}> @@ -258,7 +252,6 @@ const styles = StyleSheet.create({ listItem: { flexDirection: 'row', alignItems: 'center', - borderTopWidth: 1, paddingHorizontal: 14, paddingVertical: 10, }, diff --git a/src/view/com/pager/FeedsTabBar.web.tsx b/src/view/com/pager/FeedsTabBar.web.tsx index dc91bd296..25755bafe 100644 --- a/src/view/com/pager/FeedsTabBar.web.tsx +++ b/src/view/com/pager/FeedsTabBar.web.tsx @@ -1,10 +1,11 @@ -import React, {useMemo} from 'react' +import React from 'react' import {StyleSheet} from 'react-native' import Animated from 'react-native-reanimated' import {observer} from 'mobx-react-lite' import {TabBar} from 'view/com/pager/TabBar' import {RenderTabBarFnProps} from 'view/com/pager/Pager' import {useStores} from 'state/index' +import {useHomeTabs} from 'lib/hooks/useHomeTabs' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {FeedsTabBar as FeedsTabBarMobile} from './FeedsTabBarMobile' @@ -27,10 +28,7 @@ const FeedsTabBarTablet = observer(function FeedsTabBarTabletImpl( props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void}, ) { const store = useStores() - const items = useMemo( - () => ['Following', ...store.me.savedFeeds.pinnedFeedNames], - [store.me.savedFeeds.pinnedFeedNames], - ) + const items = useHomeTabs(store.preferences.pinnedFeeds) const pal = usePalette('default') const {headerMinimalShellTransform} = useMinimalShellMode() diff --git a/src/view/com/pager/FeedsTabBarMobile.tsx b/src/view/com/pager/FeedsTabBarMobile.tsx index d8579badc..d5de87081 100644 --- a/src/view/com/pager/FeedsTabBarMobile.tsx +++ b/src/view/com/pager/FeedsTabBarMobile.tsx @@ -1,9 +1,10 @@ -import React, {useMemo} from 'react' +import React from 'react' import {StyleSheet, TouchableOpacity, View} from 'react-native' import {observer} from 'mobx-react-lite' import {TabBar} from 'view/com/pager/TabBar' import {RenderTabBarFnProps} from 'view/com/pager/Pager' import {useStores} from 'state/index' +import {useHomeTabs} from 'lib/hooks/useHomeTabs' import {usePalette} from 'lib/hooks/usePalette' import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle' import {Link} from '../util/Link' @@ -18,9 +19,9 @@ import Animated from 'react-native-reanimated' export const FeedsTabBar = observer(function FeedsTabBarImpl( props: RenderTabBarFnProps & {testID?: string; onPressSelected: () => void}, ) { - const store = useStores() const pal = usePalette('default') - + const store = useStores() + const items = useHomeTabs(store.preferences.pinnedFeeds) const brandBlue = useColorSchemeStyle(s.brandBlue, s.blue3) const {headerMinimalShellTransform} = useMinimalShellMode() @@ -28,15 +29,6 @@ export const FeedsTabBar = observer(function FeedsTabBarImpl( store.shell.openDrawer() }, [store]) - const items = useMemo( - () => ['Following', ...store.me.savedFeeds.pinnedFeedNames], - [store.me.savedFeeds.pinnedFeedNames], - ) - - const tabBarKey = useMemo(() => { - return items.join(',') - }, [items]) - return ( <Animated.View style={[ @@ -81,7 +73,7 @@ export const FeedsTabBar = observer(function FeedsTabBarImpl( </View> </View> <TabBar - key={tabBarKey} + key={items.join(',')} onPressSelected={props.onPressSelected} selectedPage={props.selectedPage} onSelect={props.onSelect} diff --git a/src/view/com/pager/Pager.tsx b/src/view/com/pager/Pager.tsx index 39ba29bda..531a41ee2 100644 --- a/src/view/com/pager/Pager.tsx +++ b/src/view/com/pager/Pager.tsx @@ -1,6 +1,10 @@ import React, {forwardRef} from 'react' import {Animated, View} from 'react-native' -import PagerView, {PagerViewOnPageSelectedEvent} from 'react-native-pager-view' +import PagerView, { + PagerViewOnPageSelectedEvent, + PagerViewOnPageScrollEvent, + PageScrollStateChangedNativeEvent, +} from 'react-native-pager-view' import {s} from 'lib/styles' export type PageSelectedEvent = PagerViewOnPageSelectedEvent @@ -21,6 +25,7 @@ interface Props { initialPage?: number renderTabBar: RenderTabBarFn onPageSelected?: (index: number) => void + onPageSelecting?: (index: number) => void testID?: string } export const Pager = forwardRef<PagerRef, React.PropsWithChildren<Props>>( @@ -31,11 +36,15 @@ export const Pager = forwardRef<PagerRef, React.PropsWithChildren<Props>>( initialPage = 0, renderTabBar, onPageSelected, + onPageSelecting, testID, }: React.PropsWithChildren<Props>, ref, ) { const [selectedPage, setSelectedPage] = React.useState(0) + const lastOffset = React.useRef(0) + const lastDirection = React.useRef(0) + const scrollState = React.useRef('') const pagerView = React.useRef<PagerView>(null) React.useImperativeHandle(ref, () => ({ @@ -50,15 +59,61 @@ export const Pager = forwardRef<PagerRef, React.PropsWithChildren<Props>>( [setSelectedPage, onPageSelected], ) + const onPageScroll = React.useCallback( + (e: PagerViewOnPageScrollEvent) => { + const {position, offset} = e.nativeEvent + if (offset === 0) { + // offset hits 0 in some awkward spots so we ignore it + return + } + // NOTE + // we want to call `onPageSelecting` as soon as the scroll-gesture + // enters the "settling" phase, which means the user has released it + // we can't infer directionality from the scroll information, so we + // track the offset changes. if the offset delta is consistent with + // the existing direction during the settling phase, we can say for + // certain where it's going and can fire + // -prf + if (scrollState.current === 'settling') { + if (lastDirection.current === -1 && offset < lastOffset.current) { + onPageSelecting?.(position) + lastDirection.current = 0 + } else if ( + lastDirection.current === 1 && + offset > lastOffset.current + ) { + onPageSelecting?.(position + 1) + lastDirection.current = 0 + } + } else { + if (offset < lastOffset.current) { + lastDirection.current = -1 + } else if (offset > lastOffset.current) { + lastDirection.current = 1 + } + } + lastOffset.current = offset + }, + [lastOffset, lastDirection, onPageSelecting], + ) + + const onPageScrollStateChanged = React.useCallback( + (e: PageScrollStateChangedNativeEvent) => { + scrollState.current = e.nativeEvent.pageScrollState + }, + [scrollState], + ) + const onTabBarSelect = React.useCallback( (index: number) => { pagerView.current?.setPage(index) + onPageSelecting?.(index) }, - [pagerView], + [pagerView, onPageSelecting], ) return ( - <View testID={testID}> + <View testID={testID} style={s.flex1}> {tabBarPosition === 'top' && renderTabBar({ selectedPage, @@ -66,9 +121,11 @@ export const Pager = forwardRef<PagerRef, React.PropsWithChildren<Props>>( })} <AnimatedPagerView ref={pagerView} - style={s.h100pct} + style={s.flex1} initialPage={initialPage} - onPageSelected={onPageSelectedInner}> + onPageScrollStateChanged={onPageScrollStateChanged} + onPageSelected={onPageSelectedInner} + onPageScroll={onPageScroll}> {children} </AnimatedPagerView> {tabBarPosition === 'bottom' && diff --git a/src/view/com/pager/Pager.web.tsx b/src/view/com/pager/Pager.web.tsx index fe4febbb7..7ec292667 100644 --- a/src/view/com/pager/Pager.web.tsx +++ b/src/view/com/pager/Pager.web.tsx @@ -13,6 +13,7 @@ interface Props { initialPage?: number renderTabBar: RenderTabBarFn onPageSelected?: (index: number) => void + onPageSelecting?: (index: number) => void } export const Pager = React.forwardRef(function PagerImpl( { @@ -21,6 +22,7 @@ export const Pager = React.forwardRef(function PagerImpl( initialPage = 0, renderTabBar, onPageSelected, + onPageSelecting, }: React.PropsWithChildren<Props>, ref, ) { @@ -34,21 +36,20 @@ export const Pager = React.forwardRef(function PagerImpl( (index: number) => { setSelectedPage(index) onPageSelected?.(index) + onPageSelecting?.(index) }, - [setSelectedPage, onPageSelected], + [setSelectedPage, onPageSelected, onPageSelecting], ) return ( - <View> + <View style={s.hContentRegion}> {tabBarPosition === 'top' && renderTabBar({ selectedPage, onSelect: onTabBarSelect, })} {React.Children.map(children, (child, i) => ( - <View - style={selectedPage === i ? undefined : s.hidden} - key={`page-${i}`}> + <View style={selectedPage === i ? s.flex1 : s.hidden} key={`page-${i}`}> {child} </View> ))} diff --git a/src/view/com/pager/PagerWithHeader.tsx b/src/view/com/pager/PagerWithHeader.tsx new file mode 100644 index 000000000..3cdd3ab2e --- /dev/null +++ b/src/view/com/pager/PagerWithHeader.tsx @@ -0,0 +1,212 @@ +import * as React from 'react' +import {LayoutChangeEvent, StyleSheet} from 'react-native' +import Animated, { + Easing, + useAnimatedReaction, + useAnimatedScrollHandler, + useAnimatedStyle, + useSharedValue, + withTiming, + runOnJS, +} from 'react-native-reanimated' +import {Pager, PagerRef, RenderTabBarFnProps} from 'view/com/pager/Pager' +import {TabBar} from './TabBar' +import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {OnScrollCb} from 'lib/hooks/useOnMainScroll' + +const SCROLLED_DOWN_LIMIT = 200 + +interface PagerWithHeaderChildParams { + headerHeight: number + onScroll: OnScrollCb + isScrolledDown: boolean +} + +export interface PagerWithHeaderProps { + testID?: string + children: + | (((props: PagerWithHeaderChildParams) => JSX.Element) | null)[] + | ((props: PagerWithHeaderChildParams) => JSX.Element) + items: string[] + renderHeader?: () => JSX.Element + initialPage?: number + onPageSelected?: (index: number) => void + onCurrentPageSelected?: (index: number) => void +} +export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>( + function PageWithHeaderImpl( + { + children, + testID, + items, + renderHeader, + initialPage, + onPageSelected, + onCurrentPageSelected, + }: PagerWithHeaderProps, + ref, + ) { + const {isMobile} = useWebMediaQueries() + const [currentPage, setCurrentPage] = React.useState(0) + const scrollYs = React.useRef<Record<number, number>>({}) + const scrollY = useSharedValue(scrollYs.current[currentPage] || 0) + const [tabBarHeight, setTabBarHeight] = React.useState(0) + const [headerHeight, setHeaderHeight] = React.useState(0) + const [isScrolledDown, setIsScrolledDown] = React.useState( + scrollYs.current[currentPage] > SCROLLED_DOWN_LIMIT, + ) + + // react to scroll updates + function onScrollUpdate(v: number) { + // track each page's current scroll position + scrollYs.current[currentPage] = Math.min(v, headerHeight - tabBarHeight) + // update the 'is scrolled down' value + setIsScrolledDown(v > SCROLLED_DOWN_LIMIT) + } + useAnimatedReaction( + () => scrollY.value, + v => runOnJS(onScrollUpdate)(v), + ) + + // capture the header bar sizing + const onTabBarLayout = React.useCallback( + (evt: LayoutChangeEvent) => { + setTabBarHeight(evt.nativeEvent.layout.height) + }, + [setTabBarHeight], + ) + const onHeaderLayout = React.useCallback( + (evt: LayoutChangeEvent) => { + setHeaderHeight(evt.nativeEvent.layout.height) + }, + [setHeaderHeight], + ) + + // render the the header and tab bar + const headerTransform = useAnimatedStyle( + () => ({ + transform: [ + { + translateY: Math.min( + Math.min(scrollY.value, headerHeight - tabBarHeight) * -1, + 0, + ), + }, + ], + }), + [scrollY, headerHeight, tabBarHeight], + ) + const renderTabBar = React.useCallback( + (props: RenderTabBarFnProps) => { + return ( + <Animated.View + onLayout={onHeaderLayout} + style={[ + isMobile ? styles.tabBarMobile : styles.tabBarDesktop, + headerTransform, + ]}> + {renderHeader?.()} + <TabBar + items={items} + selectedPage={currentPage} + onSelect={props.onSelect} + onPressSelected={onCurrentPageSelected} + onLayout={onTabBarLayout} + /> + </Animated.View> + ) + }, + [ + items, + renderHeader, + headerTransform, + currentPage, + onCurrentPageSelected, + isMobile, + onTabBarLayout, + onHeaderLayout, + ], + ) + + // props to pass into children render functions + const onScroll = useAnimatedScrollHandler({ + onScroll(e) { + scrollY.value = e.contentOffset.y + }, + }) + const childProps = React.useMemo<PagerWithHeaderChildParams>(() => { + return { + headerHeight, + onScroll, + isScrolledDown, + } + }, [headerHeight, onScroll, isScrolledDown]) + + const onPageSelectedInner = React.useCallback( + (index: number) => { + setCurrentPage(index) + onPageSelected?.(index) + }, + [onPageSelected, setCurrentPage], + ) + + const onPageSelecting = React.useCallback( + (index: number) => { + setCurrentPage(index) + if (scrollY.value > headerHeight) { + scrollY.value = headerHeight + } + scrollY.value = withTiming(scrollYs.current[index] || 0, { + duration: 170, + easing: Easing.inOut(Easing.quad), + }) + }, + [scrollY, setCurrentPage, scrollYs, headerHeight], + ) + + return ( + <Pager + ref={ref} + testID={testID} + initialPage={initialPage} + onPageSelected={onPageSelectedInner} + onPageSelecting={onPageSelecting} + renderTabBar={renderTabBar} + tabBarPosition="top"> + {toArray(children) + .filter(Boolean) + .map(child => { + if (child) { + return child(childProps) + } + return null + })} + </Pager> + ) + }, +) + +const styles = StyleSheet.create({ + tabBarMobile: { + position: 'absolute', + zIndex: 1, + top: 0, + left: 0, + width: '100%', + }, + tabBarDesktop: { + position: 'absolute', + zIndex: 1, + top: 0, + // @ts-ignore Web only -prf + left: 'calc(50% - 299px)', + width: 598, + }, +}) + +function toArray<T>(v: T | T[]): T[] { + if (Array.isArray(v)) { + return v + } + return [v] +} diff --git a/src/view/com/pager/TabBar.tsx b/src/view/com/pager/TabBar.tsx index 8614bdf64..662d73668 100644 --- a/src/view/com/pager/TabBar.tsx +++ b/src/view/com/pager/TabBar.tsx @@ -13,7 +13,8 @@ export interface TabBarProps { items: string[] indicatorColor?: string onSelect?: (index: number) => void - onPressSelected?: () => void + onPressSelected?: (index: number) => void + onLayout?: (evt: LayoutChangeEvent) => void } export function TabBar({ @@ -23,6 +24,7 @@ export function TabBar({ indicatorColor, onSelect, onPressSelected, + onLayout, }: TabBarProps) { const pal = usePalette('default') const scrollElRef = useRef<ScrollView>(null) @@ -44,7 +46,7 @@ export function TabBar({ (index: number) => { onSelect?.(index) if (index === selectedPage) { - onPressSelected?.() + onPressSelected?.(index) } }, [onSelect, selectedPage, onPressSelected], @@ -66,7 +68,7 @@ export function TabBar({ const styles = isDesktop || isTablet ? desktopStyles : mobileStyles return ( - <View testID={testID} style={[pal.view, styles.outer]}> + <View testID={testID} style={[pal.view, styles.outer]} onLayout={onLayout}> <DraggableScrollView horizontal={true} showsHorizontalScrollIndicator={false} @@ -118,10 +120,7 @@ const desktopStyles = StyleSheet.create({ const mobileStyles = StyleSheet.create({ outer: { - flex: 1, flexDirection: 'row', - backgroundColor: 'transparent', - maxWidth: '100%', }, contentContainer: { columnGap: isWeb ? 0 : 20, diff --git a/src/view/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx index 74883f82a..591afe3a3 100644 --- a/src/view/com/posts/Feed.tsx +++ b/src/view/com/posts/Feed.tsx @@ -29,26 +29,26 @@ export const Feed = observer(function Feed({ feed, style, scrollElRef, - onPressTryAgain, onScroll, scrollEventThrottle, renderEmptyState, renderEndOfFeed, testID, headerOffset = 0, + desktopFixedHeightOffset, ListHeaderComponent, extraData, }: { feed: PostsFeedModel style?: StyleProp<ViewStyle> scrollElRef?: MutableRefObject<FlatList<any> | null> - onPressTryAgain?: () => void onScroll?: OnScrollCb scrollEventThrottle?: number renderEmptyState: () => JSX.Element renderEndOfFeed?: () => JSX.Element testID?: string headerOffset?: number + desktopFixedHeightOffset?: number ListHeaderComponent?: () => JSX.Element extraData?: any }) { @@ -71,6 +71,8 @@ export const Feed = observer(function Feed({ if (feed.loadMoreError) { feedItems = feedItems.concat([LOAD_MORE_ERROR_ITEM]) } + } else { + feedItems.push(LOADING_ITEM) } return feedItems }, [ @@ -106,6 +108,10 @@ export const Feed = observer(function Feed({ } }, [feed, track]) + const onPressTryAgain = React.useCallback(() => { + feed.refresh() + }, [feed]) + const onPressRetryLoadMore = React.useCallback(() => { feed.retryLoadMore() }, [feed]) @@ -158,7 +164,7 @@ export const Feed = observer(function Feed({ <FlatList testID={testID ? `${testID}-flatlist` : undefined} ref={scrollElRef} - data={!feed.hasLoaded ? [LOADING_ITEM] : data} + data={data} keyExtractor={item => item._reactKey} renderItem={renderItem} ListFooterComponent={FeedFooter} @@ -183,7 +189,9 @@ export const Feed = observer(function Feed({ contentOffset={{x: 0, y: headerOffset * -1}} extraData={extraData} // @ts-ignore our .web version only -prf - desktopFixedHeight + desktopFixedHeight={ + desktopFixedHeightOffset ? desktopFixedHeightOffset : true + } /> </View> ) diff --git a/src/view/com/profile/ProfileCard.tsx b/src/view/com/profile/ProfileCard.tsx index d1aed8934..f7340fd6f 100644 --- a/src/view/com/profile/ProfileCard.tsx +++ b/src/view/com/profile/ProfileCard.tsx @@ -1,5 +1,5 @@ import * as React from 'react' -import {StyleSheet, View} from 'react-native' +import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' import {observer} from 'mobx-react-lite' import { AppBskyActorDefs, @@ -29,6 +29,7 @@ export const ProfileCard = observer(function ProfileCardImpl({ noBorder, followers, renderButton, + style, }: { testID?: string profile: AppBskyActorDefs.ProfileViewBasic @@ -36,6 +37,7 @@ export const ProfileCard = observer(function ProfileCardImpl({ noBorder?: boolean followers?: AppBskyActorDefs.ProfileView[] | undefined renderButton?: (profile: AppBskyActorDefs.ProfileViewBasic) => React.ReactNode + style?: StyleProp<ViewStyle> }) { const store = useStores() const pal = usePalette('default') @@ -50,6 +52,7 @@ export const ProfileCard = observer(function ProfileCardImpl({ pal.border, noBorder && styles.outerNoBorder, !noBg && pal.view, + style, ]} href={makeProfileLink(profile)} title={profile.handle} @@ -93,7 +96,7 @@ export const ProfileCard = observer(function ProfileCardImpl({ {profile.description as string} </Text> </View> - ) : undefined} + ) : null} <FollowersList followers={followers} /> </Link> ) @@ -220,10 +223,10 @@ const styles = StyleSheet.create({ alignItems: 'center', }, layoutAvi: { + alignSelf: 'baseline', width: 54, paddingLeft: 4, - paddingTop: 8, - paddingBottom: 10, + paddingTop: 10, }, avi: { width: 40, diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx index 6bb3bc5f6..082fbc0bc 100644 --- a/src/view/com/profile/ProfileHeader.tsx +++ b/src/view/com/profile/ProfileHeader.tsx @@ -181,7 +181,7 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({ const onPressAddRemoveLists = React.useCallback(() => { track('ProfileHeader:AddToListsButtonClicked') store.shell.openModal({ - name: 'list-add-remove-user', + name: 'user-add-remove-lists', subject: view.did, displayName: view.displayName || view.handle, }) @@ -276,21 +276,20 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({ }, }, ] - if (!isMe) { - items.push({label: 'separator'}) - // Only add "Add to Lists" on other user's profiles, doesn't make sense to mute my own self! - items.push({ - testID: 'profileHeaderDropdownListAddRemoveBtn', - label: 'Add to Lists', - onPress: onPressAddRemoveLists, - icon: { - ios: { - name: 'list.bullet', - }, - android: 'ic_menu_add', - web: 'list', + items.push({label: 'separator'}) + items.push({ + testID: 'profileHeaderDropdownListAddRemoveBtn', + label: 'Add to Lists', + onPress: onPressAddRemoveLists, + icon: { + ios: { + name: 'list.bullet', }, - }) + android: 'ic_menu_add', + web: 'list', + }, + }) + if (!isMe) { if (!view.viewer.blocking) { items.push({ testID: 'profileHeaderDropdownMuteBtn', @@ -307,20 +306,22 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({ }, }) } - items.push({ - testID: 'profileHeaderDropdownBlockBtn', - label: view.viewer.blocking ? 'Unblock Account' : 'Block Account', - onPress: view.viewer.blocking - ? onPressUnblockAccount - : onPressBlockAccount, - icon: { - ios: { - name: 'person.fill.xmark', + if (!view.viewer.blockingByList) { + items.push({ + testID: 'profileHeaderDropdownBlockBtn', + label: view.viewer.blocking ? 'Unblock Account' : 'Block Account', + onPress: view.viewer.blocking + ? onPressUnblockAccount + : onPressBlockAccount, + icon: { + ios: { + name: 'person.fill.xmark', + }, + android: 'ic_menu_close_clear_cancel', + web: 'user-slash', }, - android: 'ic_menu_close_clear_cancel', - web: 'user-slash', - }, - }) + }) + } items.push({ testID: 'profileHeaderDropdownReportBtn', label: 'Report Account', @@ -339,6 +340,7 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({ isMe, view.viewer.muted, view.viewer.blocking, + view.viewer.blockingByList, onPressShare, onPressUnmuteAccount, onPressMuteAccount, @@ -371,17 +373,19 @@ const ProfileHeaderLoaded = observer(function ProfileHeaderLoadedImpl({ </Text> </TouchableOpacity> ) : view.viewer.blocking ? ( - <TouchableOpacity - testID="unblockBtn" - onPress={onPressUnblockAccount} - style={[styles.btn, styles.mainBtn, pal.btn]} - accessibilityRole="button" - accessibilityLabel="Unblock" - accessibilityHint=""> - <Text type="button" style={[pal.text, s.bold]}> - Unblock - </Text> - </TouchableOpacity> + view.viewer.blockingByList ? null : ( + <TouchableOpacity + testID="unblockBtn" + onPress={onPressUnblockAccount} + style={[styles.btn, styles.mainBtn, pal.btn]} + accessibilityRole="button" + accessibilityLabel="Unblock" + accessibilityHint=""> + <Text type="button" style={[pal.text, s.bold]}> + Unblock + </Text> + </TouchableOpacity> + ) ) : !view.viewer.blockedBy ? ( <> {!isProfilePreview && ( diff --git a/src/view/com/profile/ProfileSubpageHeader.tsx b/src/view/com/profile/ProfileSubpageHeader.tsx new file mode 100644 index 000000000..8e957728b --- /dev/null +++ b/src/view/com/profile/ProfileSubpageHeader.tsx @@ -0,0 +1,194 @@ +import React from 'react' +import {Pressable, StyleSheet, View} from 'react-native' +import {observer} from 'mobx-react-lite' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {useNavigation} from '@react-navigation/native' +import {usePalette} from 'lib/hooks/usePalette' +import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {Text} from '../util/text/Text' +import {TextLink} from '../util/Link' +import {UserAvatar, UserAvatarType} from '../util/UserAvatar' +import {LoadingPlaceholder} from '../util/LoadingPlaceholder' +import {CenteredView} from '../util/Views' +import {sanitizeHandle} from 'lib/strings/handles' +import {makeProfileLink} from 'lib/routes/links' +import {useStores} from 'state/index' +import {NavigationProp} from 'lib/routes/types' +import {BACK_HITSLOP} from 'lib/constants' +import {isNative} from 'platform/detection' +import {ImagesLightbox} from 'state/models/ui/shell' + +export const ProfileSubpageHeader = observer(function HeaderImpl({ + isLoading, + href, + title, + avatar, + isOwner, + creator, + avatarType, + children, +}: React.PropsWithChildren<{ + isLoading?: boolean + href: string + title: string | undefined + avatar: string | undefined + isOwner: boolean | undefined + creator: + | { + did: string + handle: string + } + | undefined + avatarType: UserAvatarType +}>) { + const store = useStores() + const navigation = useNavigation<NavigationProp>() + const {isMobile} = useWebMediaQueries() + const pal = usePalette('default') + const canGoBack = navigation.canGoBack() + + const onPressBack = React.useCallback(() => { + if (navigation.canGoBack()) { + navigation.goBack() + } else { + navigation.navigate('Home') + } + }, [navigation]) + + const onPressMenu = React.useCallback(() => { + store.shell.openDrawer() + }, [store]) + + const onPressAvi = React.useCallback(() => { + if ( + avatar // TODO && !(view.moderation.avatar.blur && view.moderation.avatar.noOverride) + ) { + store.shell.openLightbox(new ImagesLightbox([{uri: avatar}], 0)) + } + }, [store, avatar]) + + return ( + <CenteredView style={pal.view}> + {isMobile && ( + <View + style={[ + { + flexDirection: 'row', + alignItems: 'center', + borderBottomWidth: 1, + paddingTop: isNative ? 0 : 8, + paddingBottom: 8, + paddingHorizontal: isMobile ? 12 : 14, + }, + pal.border, + ]}> + <Pressable + testID="headerDrawerBtn" + onPress={canGoBack ? onPressBack : onPressMenu} + hitSlop={BACK_HITSLOP} + style={canGoBack ? styles.backBtn : styles.backBtnWide} + accessibilityRole="button" + accessibilityLabel={canGoBack ? 'Back' : 'Menu'} + accessibilityHint=""> + {canGoBack ? ( + <FontAwesomeIcon + size={18} + icon="angle-left" + style={[styles.backIcon, pal.text]} + /> + ) : ( + <FontAwesomeIcon + size={18} + icon="bars" + style={[styles.backIcon, pal.textLight]} + /> + )} + </Pressable> + <View style={{flex: 1}} /> + {children} + </View> + )} + <View + style={{ + flexDirection: 'row', + alignItems: 'flex-start', + gap: 10, + paddingTop: 14, + paddingBottom: 6, + paddingHorizontal: isMobile ? 12 : 14, + }}> + <Pressable + testID="headerAviButton" + onPress={onPressAvi} + accessibilityRole="image" + accessibilityLabel="View the avatar" + accessibilityHint="" + style={{width: 58}}> + <UserAvatar type={avatarType} size={58} avatar={avatar} /> + </Pressable> + <View style={{flex: 1}}> + {isLoading ? ( + <LoadingPlaceholder + width={200} + height={32} + style={{marginVertical: 6}} + /> + ) : ( + <TextLink + testID="headerTitle" + type="title-xl" + href={href} + style={[pal.text, {fontWeight: 'bold'}]} + text={title || ''} + onPress={() => store.emitScreenSoftReset()} + numberOfLines={4} + /> + )} + + {isLoading ? ( + <LoadingPlaceholder width={50} height={8} /> + ) : ( + <Text type="xl" style={[pal.textLight]} numberOfLines={1}> + by{' '} + {!creator ? ( + '—' + ) : isOwner ? ( + 'you' + ) : ( + <TextLink + text={sanitizeHandle(creator.handle, '@')} + href={makeProfileLink(creator)} + style={pal.textLight} + /> + )} + </Text> + )} + </View> + {!isMobile && ( + <View + style={{ + flexDirection: 'row', + alignItems: 'center', + }}> + {children} + </View> + )} + </View> + </CenteredView> + ) +}) + +const styles = StyleSheet.create({ + backBtn: { + width: 20, + height: 30, + }, + backBtnWide: { + width: 20, + height: 30, + paddingHorizontal: 6, + }, + backIcon: { + marginTop: 6, + }, +}) diff --git a/src/view/com/testing/TestCtrls.e2e.tsx b/src/view/com/testing/TestCtrls.e2e.tsx index 3f2b2fd00..db9b6b4bf 100644 --- a/src/view/com/testing/TestCtrls.e2e.tsx +++ b/src/view/com/testing/TestCtrls.e2e.tsx @@ -66,6 +66,12 @@ export function TestCtrls() { style={BTN} /> <Pressable + testID="e2eGotoLists" + onPress={() => navigate('Lists')} + accessibilityRole="button" + style={BTN} + /> + <Pressable testID="e2eToggleMergefeed" onPress={() => store.preferences.toggleHomeFeedMergeFeedEnabled()} accessibilityRole="button" diff --git a/src/view/com/util/AccountDropdownBtn.tsx b/src/view/com/util/AccountDropdownBtn.tsx index 761fec216..29571696b 100644 --- a/src/view/com/util/AccountDropdownBtn.tsx +++ b/src/view/com/util/AccountDropdownBtn.tsx @@ -25,7 +25,7 @@ export function AccountDropdownBtn({handle}: {handle: string}) { name: 'trash', }, android: 'ic_delete', - web: 'trash', + web: ['far', 'trash-can'], }, }, ] diff --git a/src/view/com/util/LoadingPlaceholder.tsx b/src/view/com/util/LoadingPlaceholder.tsx index d7ab1be54..461cbcbe5 100644 --- a/src/view/com/util/LoadingPlaceholder.tsx +++ b/src/view/com/util/LoadingPlaceholder.tsx @@ -83,19 +83,14 @@ export function PostLoadingPlaceholder({ export function PostFeedLoadingPlaceholder() { return ( - <> - <PostLoadingPlaceholder /> - <PostLoadingPlaceholder /> - <PostLoadingPlaceholder /> + <View> <PostLoadingPlaceholder /> <PostLoadingPlaceholder /> <PostLoadingPlaceholder /> <PostLoadingPlaceholder /> <PostLoadingPlaceholder /> <PostLoadingPlaceholder /> - <PostLoadingPlaceholder /> - <PostLoadingPlaceholder /> - </> + </View> ) } diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx index fbc0b5e11..7b23547c6 100644 --- a/src/view/com/util/UserAvatar.tsx +++ b/src/view/com/util/UserAvatar.tsx @@ -17,10 +17,10 @@ import {Image as RNImage} from 'react-native-image-crop-picker' import {UserPreviewLink} from './UserPreviewLink' import {DropdownItem, NativeDropdown} from './forms/NativeDropdown' -type Type = 'user' | 'algo' | 'list' +export type UserAvatarType = 'user' | 'algo' | 'list' interface BaseUserAvatarProps { - type?: Type + type?: UserAvatarType size: number avatar?: string | null } @@ -41,7 +41,7 @@ interface PreviewableUserAvatarProps extends BaseUserAvatarProps { const BLUR_AMOUNT = isWeb ? 5 : 100 -function DefaultAvatar({type, size}: {type: Type; size: number}) { +function DefaultAvatar({type, size}: {type: UserAvatarType; size: number}) { if (type === 'algo') { // Font Awesome Pro 6.4.0 by @fontawesome -https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. return ( @@ -261,7 +261,7 @@ export function EditableUserAvatar({ name: 'trash', }, android: 'ic_delete', - web: 'trash', + web: ['far', 'trash-can'], }, onPress: async () => { onSelectNewAvatar(null) diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx index 9a99dc5ad..4bdfad06c 100644 --- a/src/view/com/util/UserBanner.tsx +++ b/src/view/com/util/UserBanner.tsx @@ -91,7 +91,7 @@ export function UserBanner({ name: 'trash', }, android: 'ic_delete', - web: 'trash', + web: ['far', 'trash-can'], }, onPress: () => { onSelectNewBanner?.(null) diff --git a/src/view/com/util/ViewHeader.tsx b/src/view/com/util/ViewHeader.tsx index ec459b4eb..4cc9efb78 100644 --- a/src/view/com/util/ViewHeader.tsx +++ b/src/view/com/util/ViewHeader.tsx @@ -124,7 +124,6 @@ function DesktopWebHeader({ <CenteredView style={[ styles.header, - styles.headerFixed, styles.desktopHeader, pal.border, { @@ -158,7 +157,6 @@ const Container = observer(function ContainerImpl({ <View style={[ styles.header, - styles.headerFixed, pal.view, pal.border, showBorder && styles.border, @@ -190,11 +188,6 @@ const styles = StyleSheet.create({ paddingVertical: 6, width: '100%', }, - headerFixed: { - maxWidth: 600, - marginLeft: 'auto', - marginRight: 'auto', - }, headerFloating: { position: 'absolute', top: 0, @@ -202,6 +195,9 @@ const styles = StyleSheet.create({ }, desktopHeader: { paddingVertical: 12, + maxWidth: 600, + marginLeft: 'auto', + marginRight: 'auto', }, border: { borderBottomWidth: 1, diff --git a/src/view/com/util/Views.d.ts b/src/view/com/util/Views.d.ts new file mode 100644 index 000000000..292a985cd --- /dev/null +++ b/src/view/com/util/Views.d.ts @@ -0,0 +1 @@ +export {FlatList, ScrollView, View as CenteredView} from 'react-native' diff --git a/src/view/com/util/Views.jsx b/src/view/com/util/Views.jsx new file mode 100644 index 000000000..8a93ce511 --- /dev/null +++ b/src/view/com/util/Views.jsx @@ -0,0 +1,9 @@ +import React from 'react' +import {View} from 'react-native' +import Animated from 'react-native-reanimated' + +export const FlatList = Animated.FlatList +export const ScrollView = Animated.ScrollView +export function CenteredView(props) { + return <View {...props} /> +} diff --git a/src/view/com/util/Views.tsx b/src/view/com/util/Views.tsx deleted file mode 100644 index 07dcc4deb..000000000 --- a/src/view/com/util/Views.tsx +++ /dev/null @@ -1 +0,0 @@ -export {View as CenteredView, FlatList, ScrollView} from 'react-native' diff --git a/src/view/com/util/Views.web.tsx b/src/view/com/util/Views.web.tsx index fda0a9b86..1c2edc0cc 100644 --- a/src/view/com/util/Views.web.tsx +++ b/src/view/com/util/Views.web.tsx @@ -14,9 +14,7 @@ import React from 'react' import { - FlatList as RNFlatList, FlatListProps, - ScrollView as RNScrollView, ScrollViewProps, StyleSheet, View, @@ -25,16 +23,29 @@ import { import {addStyle} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import Animated from 'react-native-reanimated' interface AddedProps { - desktopFixedHeight?: boolean + desktopFixedHeight?: boolean | number } export function CenteredView({ style, + sideBorders, ...props -}: React.PropsWithChildren<ViewProps>) { - style = addStyle(style, styles.container) +}: React.PropsWithChildren<ViewProps & {sideBorders?: boolean}>) { + const pal = usePalette('default') + const {isMobile} = useWebMediaQueries() + if (!isMobile) { + style = addStyle(style, styles.container) + } + if (sideBorders) { + style = addStyle(style, { + borderLeftWidth: 1, + borderRightWidth: 1, + }) + style = addStyle(style, pal.border) + } return <View style={style} {...props} /> } @@ -46,14 +57,16 @@ export const FlatList = React.forwardRef(function FlatListImpl<ItemT>( desktopFixedHeight, ...props }: React.PropsWithChildren<FlatListProps<ItemT> & AddedProps>, - ref: React.Ref<RNFlatList>, + ref: React.Ref<Animated.FlatList<ItemT>>, ) { const pal = usePalette('default') const {isMobile} = useWebMediaQueries() - contentContainerStyle = addStyle( - contentContainerStyle, - styles.containerScroll, - ) + if (!isMobile) { + contentContainerStyle = addStyle( + contentContainerStyle, + styles.containerScroll, + ) + } if (contentOffset && contentOffset?.y !== 0) { // NOTE // we use paddingTop & contentOffset to space around the floating header @@ -68,7 +81,14 @@ export const FlatList = React.forwardRef(function FlatListImpl<ItemT>( }) } if (desktopFixedHeight) { - style = addStyle(style, styles.fixedHeight) + if (typeof desktopFixedHeight === 'number') { + // @ts-ignore Web only -prf + style = addStyle(style, { + height: `calc(100vh - ${desktopFixedHeight}px)`, + }) + } else { + style = addStyle(style, styles.fixedHeight) + } if (!isMobile) { // NOTE // react native web produces *three* wrapping divs @@ -85,7 +105,7 @@ export const FlatList = React.forwardRef(function FlatListImpl<ItemT>( } } return ( - <RNFlatList + <Animated.FlatList ref={ref} contentContainerStyle={[ contentContainerStyle, @@ -101,21 +121,25 @@ export const FlatList = React.forwardRef(function FlatListImpl<ItemT>( export const ScrollView = React.forwardRef(function ScrollViewImpl( {contentContainerStyle, ...props}: React.PropsWithChildren<ScrollViewProps>, - ref: React.Ref<RNScrollView>, + ref: React.Ref<Animated.ScrollView>, ) { const pal = usePalette('default') - contentContainerStyle = addStyle( - contentContainerStyle, - styles.containerScroll, - ) + const {isMobile} = useWebMediaQueries() + if (!isMobile) { + contentContainerStyle = addStyle( + contentContainerStyle, + styles.containerScroll, + ) + } return ( - <RNScrollView + <Animated.ScrollView contentContainerStyle={[ contentContainerStyle, pal.border, styles.contentContainer, ]} + // @ts-ignore something is wrong with the reanimated types -prf ref={ref} {...props} /> diff --git a/src/view/com/util/load-latest/LoadLatestBtn.tsx b/src/view/com/util/load-latest/LoadLatestBtn.tsx index b16a42396..f9a9387bb 100644 --- a/src/view/com/util/load-latest/LoadLatestBtn.tsx +++ b/src/view/com/util/load-latest/LoadLatestBtn.tsx @@ -10,6 +10,7 @@ import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode' import Animated from 'react-native-reanimated' const AnimatedTouchableOpacity = Animated.createAnimatedComponent(TouchableOpacity) +import {isWeb} from 'platform/detection' export const LoadLatestBtn = observer(function LoadLatestBtnImpl({ onPress, @@ -47,7 +48,8 @@ export const LoadLatestBtn = observer(function LoadLatestBtnImpl({ const styles = StyleSheet.create({ loadLatest: { - position: 'absolute', + // @ts-ignore 'fixed' is web only -prf + position: isWeb ? 'fixed' : 'absolute', left: 18, bottom: 44, borderWidth: 1, diff --git a/src/view/com/util/moderation/PostHider.tsx b/src/view/com/util/moderation/PostHider.tsx index 443885dfa..d224286b0 100644 --- a/src/view/com/util/moderation/PostHider.tsx +++ b/src/view/com/util/moderation/PostHider.tsx @@ -74,7 +74,7 @@ export function PostHider({ accessibilityHint=""> <ShieldExclamation size={18} style={pal.text} /> </Pressable> - <Text type="lg" style={pal.text}> + <Text type="lg" style={[{flex: 1}, pal.text]} numberOfLines={1}> {desc.name} </Text> {!moderation.noOverride && ( diff --git a/src/view/com/util/moderation/ProfileHeaderAlerts.tsx b/src/view/com/util/moderation/ProfileHeaderAlerts.tsx index b7781e06d..6b7f4e7ec 100644 --- a/src/view/com/util/moderation/ProfileHeaderAlerts.tsx +++ b/src/view/com/util/moderation/ProfileHeaderAlerts.tsx @@ -45,7 +45,7 @@ export function ProfileHeaderAlerts({ accessibilityHint="" style={[styles.container, pal.viewLight, style]}> <ShieldExclamation style={pal.text} size={24} /> - <Text type="lg" style={pal.text}> + <Text type="lg" style={[{flex: 1}, pal.text]}> {desc.name} </Text> <Text type="lg" style={[pal.link, styles.learnMoreBtn]}> diff --git a/src/view/com/util/post-embeds/CustomFeedEmbed.tsx b/src/view/com/util/post-embeds/CustomFeedEmbed.tsx index 5abdf2f77..624157436 100644 --- a/src/view/com/util/post-embeds/CustomFeedEmbed.tsx +++ b/src/view/com/util/post-embeds/CustomFeedEmbed.tsx @@ -3,8 +3,8 @@ import {AppBskyFeedDefs} from '@atproto/api' import {usePalette} from 'lib/hooks/usePalette' import {StyleSheet} from 'react-native' import {useStores} from 'state/index' -import {CustomFeedModel} from 'state/models/feeds/custom-feed' -import {CustomFeed} from 'view/com/feeds/CustomFeed' +import {FeedSourceModel} from 'state/models/content/feed-source' +import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard' export function CustomFeedEmbed({ record, @@ -13,12 +13,13 @@ export function CustomFeedEmbed({ }) { const pal = usePalette('default') const store = useStores() - const item = useMemo( - () => new CustomFeedModel(store, record), - [store, record], - ) + const item = useMemo(() => { + const model = new FeedSourceModel(store, record.uri) + model.hydrateFeedGenerator(record) + return model + }, [store, record]) return ( - <CustomFeed + <FeedSourceCard item={item} style={[pal.view, pal.border, styles.customFeedOuter]} showLikes diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx index 2d79eed8f..6c13bc2bb 100644 --- a/src/view/com/util/post-embeds/index.tsx +++ b/src/view/com/util/post-embeds/index.tsx @@ -75,7 +75,7 @@ export function PostEmbeds({ return <CustomFeedEmbed record={embed.record} /> } - // list embed (e.g. mute lists; i.e. ListView) + // list embed if (AppBskyGraphDefs.isListView(embed.record)) { return <ListEmbed item={embed.record} /> } diff --git a/src/view/screens/CustomFeed.tsx b/src/view/screens/CustomFeed.tsx deleted file mode 100644 index f9383639c..000000000 --- a/src/view/screens/CustomFeed.tsx +++ /dev/null @@ -1,495 +0,0 @@ -import React, {useMemo, useRef} from 'react' -import {NativeStackScreenProps} from '@react-navigation/native-stack' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {useNavigation, useIsFocused} from '@react-navigation/native' -import {usePalette} from 'lib/hooks/usePalette' -import {HeartIcon, HeartIconSolid} from 'lib/icons' -import {CommonNavigatorParams} from 'lib/routes/types' -import {makeRecordUri} from 'lib/strings/url-helpers' -import {colors, s} from 'lib/styles' -import {observer} from 'mobx-react-lite' -import {FlatList, StyleSheet, View, ActivityIndicator} from 'react-native' -import {useStores} from 'state/index' -import {PostsFeedModel} from 'state/models/feeds/posts' -import {useCustomFeed} from 'lib/hooks/useCustomFeed' -import {withAuthRequired} from 'view/com/auth/withAuthRequired' -import {Feed} from 'view/com/posts/Feed' -import {TextLink} from 'view/com/util/Link' -import {SimpleViewHeader} from 'view/com/util/SimpleViewHeader' -import {Button} from 'view/com/util/forms/Button' -import {Text} from 'view/com/util/text/Text' -import * as Toast from 'view/com/util/Toast' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {useSetTitle} from 'lib/hooks/useSetTitle' -import {shareUrl} from 'lib/sharing' -import {toShareUrl} from 'lib/strings/url-helpers' -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 {useOnMainScroll} from 'lib/hooks/useOnMainScroll' -import {EmptyState} from 'view/com/util/EmptyState' -import {useAnalytics} from 'lib/analytics/analytics' -import {NativeDropdown, DropdownItem} from 'view/com/util/forms/NativeDropdown' -import {resolveName} from 'lib/api' -import {CenteredView} from 'view/com/util/Views' -import {NavigationProp} from 'lib/routes/types' - -type Props = NativeStackScreenProps<CommonNavigatorParams, 'CustomFeed'> - -export const CustomFeedScreen = withAuthRequired( - observer(function CustomFeedScreenImpl(props: Props) { - const pal = usePalette('default') - const store = useStores() - const navigation = useNavigation<NavigationProp>() - - const {name: handleOrDid} = props.route.params - - const [feedOwnerDid, setFeedOwnerDid] = React.useState<string | undefined>() - const [error, setError] = React.useState<string | undefined>() - - const onPressBack = React.useCallback(() => { - if (navigation.canGoBack()) { - navigation.goBack() - } else { - navigation.navigate('Home') - } - }, [navigation]) - - React.useEffect(() => { - /* - * We must resolve the DID of the feed owner before we can fetch the feed. - */ - async function fetchDid() { - try { - const did = await resolveName(store, handleOrDid) - setFeedOwnerDid(did) - } catch (e) { - setError( - `We're sorry, but we were unable to resolve this feed. If this persists, please contact the feed creator, @${handleOrDid}.`, - ) - } - } - - fetchDid() - }, [store, handleOrDid, setFeedOwnerDid]) - - if (error) { - return ( - <CenteredView> - <View style={[pal.view, pal.border, styles.notFoundContainer]}> - <Text type="title-lg" style={[pal.text, s.mb10]}> - Could not load feed - </Text> - <Text type="md" style={[pal.text, s.mb20]}> - {error} - </Text> - - <View style={{flexDirection: 'row'}}> - <Button - type="default" - accessibilityLabel="Go Back" - accessibilityHint="Return to previous page" - onPress={onPressBack} - style={{flexShrink: 1}}> - <Text type="button" style={pal.text}> - Go Back - </Text> - </Button> - </View> - </View> - </CenteredView> - ) - } - - return feedOwnerDid ? ( - <CustomFeedScreenInner {...props} feedOwnerDid={feedOwnerDid} /> - ) : ( - <CenteredView> - <View style={s.p20}> - <ActivityIndicator size="large" /> - </View> - </CenteredView> - ) - }), -) - -export const CustomFeedScreenInner = observer( - function CustomFeedScreenInnerImpl({ - route, - feedOwnerDid, - }: Props & {feedOwnerDid: string}) { - const store = useStores() - const pal = usePalette('default') - const palInverted = usePalette('inverted') - const navigation = useNavigation<NavigationProp>() - const isScreenFocused = useIsFocused() - const {isMobile, isTabletOrDesktop} = useWebMediaQueries() - const {track} = useAnalytics() - const {rkey, name: handleOrDid} = route.params - const uri = useMemo( - () => makeRecordUri(feedOwnerDid, 'app.bsky.feed.generator', rkey), - [rkey, feedOwnerDid], - ) - const scrollElRef = useRef<FlatList>(null) - const currentFeed = useCustomFeed(uri) - const algoFeed: PostsFeedModel = useMemo(() => { - const feed = new PostsFeedModel(store, 'custom', { - feed: uri, - }) - feed.setup() - return feed - }, [store, uri]) - const isPinned = store.me.savedFeeds.isPinned(uri) - const [onMainScroll, isScrolledDown, resetMainScroll] = - useOnMainScroll(store) - useSetTitle(currentFeed?.displayName) - - const onToggleSaved = React.useCallback(async () => { - try { - Haptics.default() - if (currentFeed?.isSaved) { - await currentFeed?.unsave() - } else { - await currentFeed?.save() - } - } catch (err) { - Toast.show( - 'There was an an issue updating your feeds, please check your internet connection and try again.', - ) - store.log.error('Failed up update feeds', {err}) - } - }, [store, currentFeed]) - - const onToggleLiked = React.useCallback(async () => { - Haptics.default() - try { - if (currentFeed?.isLiked) { - await currentFeed?.unlike() - } else { - await currentFeed?.like() - } - } catch (err) { - Toast.show( - 'There was an an issue contacting the server, please check your internet connection and try again.', - ) - 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 onPressAbout = React.useCallback(() => { - store.shell.openModal({ - name: 'confirm', - title: currentFeed?.displayName || '', - message: - currentFeed?.data.description || 'This feed has no description.', - confirmBtnText: 'Close', - onPressConfirm() {}, - }) - }, [store, currentFeed]) - - const onPressViewAuthor = React.useCallback(() => { - navigation.navigate('Profile', {name: handleOrDid}) - }, [handleOrDid, navigation]) - - const onPressShare = React.useCallback(() => { - const url = toShareUrl(`/profile/${handleOrDid}/feed/${rkey}`) - shareUrl(url) - track('CustomFeed:Share') - }, [handleOrDid, rkey, track]) - - const onPressReport = React.useCallback(() => { - if (!currentFeed) return - store.shell.openModal({ - name: 'report', - uri: currentFeed.uri, - cid: currentFeed.data.cid, - }) - }, [store, currentFeed]) - - const onScrollToTop = React.useCallback(() => { - scrollElRef.current?.scrollToOffset({offset: 0, animated: true}) - resetMainScroll() - }, [scrollElRef, resetMainScroll]) - - const onPressCompose = React.useCallback(() => { - store.shell.openComposer({}) - }, [store]) - - const onSoftReset = React.useCallback(() => { - if (isScreenFocused) { - onScrollToTop() - algoFeed.refresh() - } - }, [isScreenFocused, onScrollToTop, algoFeed]) - - // fires when page within screen is activated/deactivated - React.useEffect(() => { - if (!isScreenFocused) { - return - } - - const softResetSub = store.onScreenSoftReset(onSoftReset) - return () => { - softResetSub.remove() - } - }, [store, onSoftReset, isScreenFocused]) - - const dropdownItems: DropdownItem[] = React.useMemo(() => { - return [ - currentFeed - ? { - testID: 'feedHeaderDropdownAboutBtn', - label: 'About this feed', - onPress: onPressAbout, - icon: { - ios: { - name: 'info.circle', - }, - android: '', - web: 'info', - }, - } - : undefined, - { - testID: 'feedHeaderDropdownViewAuthorBtn', - label: 'View author', - onPress: onPressViewAuthor, - icon: { - ios: { - name: 'person', - }, - android: '', - web: ['far', 'user'], - }, - }, - { - testID: 'feedHeaderDropdownToggleSavedBtn', - label: currentFeed?.isSaved - ? 'Remove from my feeds' - : 'Add to my feeds', - onPress: onToggleSaved, - icon: currentFeed?.isSaved - ? { - ios: { - name: 'trash', - }, - android: 'ic_delete', - web: 'trash', - } - : { - ios: { - name: 'plus', - }, - android: '', - web: 'plus', - }, - }, - { - testID: 'feedHeaderDropdownReportBtn', - label: 'Report feed', - onPress: onPressReport, - icon: { - ios: { - name: 'exclamationmark.triangle', - }, - android: 'ic_menu_report_image', - web: 'circle-exclamation', - }, - }, - { - testID: 'feedHeaderDropdownShareBtn', - label: 'Share link', - onPress: onPressShare, - icon: { - ios: { - name: 'square.and.arrow.up', - }, - android: 'ic_menu_share', - web: 'share', - }, - }, - ].filter(Boolean) as DropdownItem[] - }, [ - currentFeed, - onPressAbout, - onToggleSaved, - onPressReport, - onPressShare, - onPressViewAuthor, - ]) - - const renderEmptyState = React.useCallback(() => { - return ( - <View style={[pal.border, {borderTopWidth: 1, paddingTop: 20}]}> - <EmptyState icon="feed" message="This feed is empty!" /> - </View> - ) - }, [pal.border]) - - return ( - <View style={s.hContentRegion}> - <SimpleViewHeader - showBackButton={isMobile} - style={ - !isMobile && [pal.border, {borderLeftWidth: 1, borderRightWidth: 1}] - }> - <Text type="title-lg" style={styles.headerText} numberOfLines={1}> - {currentFeed ? ( - <TextLink - type="title-lg" - href="/" - style={[pal.text, {fontWeight: 'bold'}]} - text={currentFeed?.displayName || ''} - onPress={() => store.emitScreenSoftReset()} - /> - ) : ( - 'Loading...' - )} - </Text> - {currentFeed ? ( - <> - <Button - type="default-light" - testID="toggleLikeBtn" - accessibilityLabel="Like this feed" - accessibilityHint="" - onPress={onToggleLiked} - style={styles.headerBtn}> - {currentFeed?.isLiked ? ( - <HeartIconSolid size={19} style={styles.liked} /> - ) : ( - <HeartIcon strokeWidth={3} size={19} style={pal.textLight} /> - )} - </Button> - {currentFeed?.isSaved ? ( - <Button - type="default-light" - accessibilityLabel={ - isPinned ? 'Unpin this feed' : 'Pin this feed' - } - accessibilityHint="" - onPress={onTogglePinned} - style={styles.headerBtn}> - <FontAwesomeIcon - icon="thumb-tack" - size={17} - color={isPinned ? colors.blue3 : pal.colors.textLight} - style={styles.top1} - /> - </Button> - ) : ( - <Button - type="inverted" - onPress={onToggleSaved} - accessibilityLabel="Add to my feeds" - accessibilityHint="" - style={styles.headerAddBtn}> - <FontAwesomeIcon - icon="plus" - color={palInverted.colors.text} - size={19} - /> - <Text type="button" style={palInverted.text}> - Add{!isMobile && ' to My Feeds'} - </Text> - </Button> - )} - </> - ) : null} - <NativeDropdown - testID="feedHeaderDropdownBtn" - items={dropdownItems} - accessibilityLabel="More options" - accessibilityHint=""> - <View - style={{ - paddingLeft: 12, - paddingRight: isMobile ? 12 : 0, - }}> - <FontAwesomeIcon - icon="ellipsis" - size={20} - color={pal.colors.textLight} - /> - </View> - </NativeDropdown> - </SimpleViewHeader> - <Feed - scrollElRef={scrollElRef} - feed={algoFeed} - onScroll={onMainScroll} - scrollEventThrottle={100} - renderEmptyState={renderEmptyState} - extraData={[uri, isPinned]} - style={!isTabletOrDesktop ? {flex: 1} : undefined} - /> - {isScrolledDown ? ( - <LoadLatestBtn - onPress={onSoftReset} - label="Scroll to top" - showIndicator={false} - /> - ) : null} - <FAB - testID="composeFAB" - onPress={onPressCompose} - icon={<ComposeIcon2 strokeWidth={1.5} size={29} style={s.white} />} - accessibilityRole="button" - accessibilityLabel="New post" - accessibilityHint="" - /> - </View> - ) - }, -) - -const styles = StyleSheet.create({ - header: { - flexDirection: 'row', - gap: 12, - paddingHorizontal: 16, - paddingTop: 12, - paddingBottom: 16, - borderTopWidth: 1, - }, - headerText: { - flex: 1, - fontWeight: 'bold', - }, - headerBtn: { - paddingVertical: 0, - }, - headerAddBtn: { - flexDirection: 'row', - alignItems: 'center', - gap: 4, - paddingVertical: 4, - paddingLeft: 10, - }, - liked: { - color: colors.red3, - }, - top1: { - position: 'relative', - top: 1, - }, - top2: { - position: 'relative', - top: 2, - }, - notFoundContainer: { - margin: 10, - paddingHorizontal: 18, - paddingVertical: 14, - borderRadius: 6, - }, -}) diff --git a/src/view/screens/Feeds.tsx b/src/view/screens/Feeds.tsx index fc4d99cf5..383bbcaa5 100644 --- a/src/view/screens/Feeds.tsx +++ b/src/view/screens/Feeds.tsx @@ -2,7 +2,6 @@ import React from 'react' import {ActivityIndicator, StyleSheet, RefreshControl, View} from 'react-native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIconStyle} from '@fortawesome/react-native-fontawesome' -import {AtUri} from '@atproto/api' import {withAuthRequired} from 'view/com/auth/withAuthRequired' import {ViewHeader} from 'view/com/util/ViewHeader' import {FAB} from 'view/com/util/fab/FAB' @@ -24,9 +23,10 @@ import {ErrorMessage} from 'view/com/util/error/ErrorMessage' import debounce from 'lodash.debounce' import {Text} from 'view/com/util/text/Text' import {MyFeedsUIModel, MyFeedsItem} from 'state/models/ui/my-feeds' +import {FeedSourceModel} from 'state/models/content/feed-source' import {FlatList} from 'view/com/util/Views' import {useFocusEffect} from '@react-navigation/native' -import {CustomFeed} from 'view/com/feeds/CustomFeed' +import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard' type Props = NativeStackScreenProps<FeedsTabNavigatorParams, 'Feeds'> export const FeedsScreen = withAuthRequired( @@ -52,6 +52,10 @@ export const FeedsScreen = withAuthRequired( } }, [store, myFeeds]), ) + React.useEffect(() => { + // watch for changes to saved/pinned feeds + return myFeeds.registerListeners() + }, [myFeeds]) const onPressCompose = React.useCallback(() => { store.shell.openComposer({}) @@ -139,13 +143,7 @@ export const FeedsScreen = withAuthRequired( </> ) } else if (item.type === 'saved-feed') { - return ( - <SavedFeed - uri={item.feed.uri} - avatar={item.feed.data.avatar} - displayName={item.feed.displayName} - /> - ) + return <SavedFeed feed={item.feed} /> } else if (item.type === 'discover-feeds-header') { return ( <> @@ -187,7 +185,7 @@ export const FeedsScreen = withAuthRequired( ) } else if (item.type === 'discover-feed') { return ( - <CustomFeed + <FeedSourceCard item={item.feed} showSaveBtn showDescription @@ -257,33 +255,43 @@ export const FeedsScreen = withAuthRequired( }), ) -function SavedFeed({ - uri, - avatar, - displayName, -}: { - uri: string - avatar: string | undefined - displayName: string -}) { +function SavedFeed({feed}: {feed: FeedSourceModel}) { const pal = usePalette('default') - const urip = new AtUri(uri) - const href = `/profile/${urip.hostname}/feed/${urip.rkey}` const {isMobile} = useWebMediaQueries() return ( <Link - testID={`saved-feed-${displayName}`} - href={href} + testID={`saved-feed-${feed.displayName}`} + href={feed.href} style={[pal.border, styles.savedFeed, isMobile && styles.savedFeedMobile]} hoverStyle={pal.viewLight} - accessibilityLabel={displayName} + accessibilityLabel={feed.displayName} accessibilityHint="" asAnchor anchorNoUnderline> - <UserAvatar type="algo" size={28} avatar={avatar} /> - <Text type="lg-medium" style={[pal.text, s.flex1]} numberOfLines={1}> - {displayName} - </Text> + {feed.error ? ( + <View + style={{width: 28, flexDirection: 'row', justifyContent: 'center'}}> + <FontAwesomeIcon + icon="exclamation-circle" + color={pal.colors.textLight} + /> + </View> + ) : ( + <UserAvatar type="algo" size={28} avatar={feed.avatar} /> + )} + <View + style={{flex: 1, flexDirection: 'row', gap: 8, alignItems: 'center'}}> + <Text type="lg-medium" style={pal.text} numberOfLines={1}> + {feed.displayName} + </Text> + {feed.error && ( + <View style={[styles.offlineSlug, pal.borderDark]}> + <Text type="xs" style={pal.textLight}> + Feed offline + </Text> + </View> + )} + </View> {isMobile && ( <FontAwesomeIcon icon="chevron-right" @@ -342,4 +350,10 @@ const styles = StyleSheet.create({ savedFeedMobile: { paddingVertical: 10, }, + offlineSlug: { + borderWidth: 1, + borderRadius: 4, + paddingHorizontal: 4, + paddingVertical: 2, + }, }) diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index ad47e9f9b..d8bf4f637 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -1,7 +1,6 @@ import React from 'react' import {useWindowDimensions} from 'react-native' import {useFocusEffect} from '@react-navigation/native' -import {AppBskyFeedGetFeed as GetCustomFeed} from '@atproto/api' import {observer} from 'mobx-react-lite' import isEqual from 'lodash.isequal' import {NativeStackScreenProps, HomeTabNavigatorParams} from 'lib/routes/types' @@ -30,29 +29,29 @@ export const HomeScreen = withAuthRequired( >([]) React.useEffect(() => { - const {pinned} = store.me.savedFeeds + const pinned = store.preferences.pinnedFeeds - if ( - isEqual( - pinned.map(p => p.uri), - requestedCustomFeeds, - ) - ) { + if (isEqual(pinned, requestedCustomFeeds)) { // no changes return } const feeds = [] - for (const feed of pinned) { - const model = new PostsFeedModel(store, 'custom', {feed: feed.uri}) - feeds.push(model) + for (const uri of pinned) { + if (uri.includes('app.bsky.feed.generator')) { + const model = new PostsFeedModel(store, 'custom', {feed: uri}) + feeds.push(model) + } else if (uri.includes('app.bsky.graph.list')) { + const model = new PostsFeedModel(store, 'list', {list: uri}) + feeds.push(model) + } } pagerRef.current?.setPage(0) setCustomFeeds(feeds) - setRequestedCustomFeeds(pinned.map(p => p.uri)) + setRequestedCustomFeeds(pinned) }, [ store, - store.me.savedFeeds.pinned, + store.preferences.pinnedFeeds, customFeeds, setCustomFeeds, pagerRef, @@ -124,7 +123,7 @@ export const HomeScreen = withAuthRequired( {customFeeds.map((f, index) => { return ( <FeedPage - key={(f.params as GetCustomFeed.QueryParams).feed} + key={f.reactKey} testID="customFeedPage" isPageFocused={selectedPage === 1 + index} feed={f} diff --git a/src/view/screens/Lists.tsx b/src/view/screens/Lists.tsx new file mode 100644 index 000000000..6e2b11c97 --- /dev/null +++ b/src/view/screens/Lists.tsx @@ -0,0 +1,92 @@ +import React from 'react' +import {View} from 'react-native' +import {useFocusEffect, useNavigation} from '@react-navigation/native' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {AtUri} from '@atproto/api' +import {observer} from 'mobx-react-lite' +import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' +import {withAuthRequired} from 'view/com/auth/withAuthRequired' +import {useStores} from 'state/index' +import {ListsListModel} from 'state/models/lists/lists-list' +import {ListsList} from 'view/com/lists/ListsList' +import {Text} from 'view/com/util/text/Text' +import {Button} from 'view/com/util/forms/Button' +import {NavigationProp} from 'lib/routes/types' +import {usePalette} from 'lib/hooks/usePalette' +import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {SimpleViewHeader} from 'view/com/util/SimpleViewHeader' +import {s} from 'lib/styles' + +type Props = NativeStackScreenProps<CommonNavigatorParams, 'Lists'> +export const ListsScreen = withAuthRequired( + observer(function ListsScreenImpl({}: Props) { + const pal = usePalette('default') + const store = useStores() + const {isMobile} = useWebMediaQueries() + const navigation = useNavigation<NavigationProp>() + + const listsLists: ListsListModel = React.useMemo( + () => new ListsListModel(store, 'my-curatelists'), + [store], + ) + + useFocusEffect( + React.useCallback(() => { + store.shell.setMinimalShellMode(false) + listsLists.refresh() + }, [store, listsLists]), + ) + + const onPressNewList = React.useCallback(() => { + store.shell.openModal({ + name: 'create-or-edit-list', + purpose: 'app.bsky.graph.defs#curatelist', + onSave: (uri: string) => { + try { + const urip = new AtUri(uri) + navigation.navigate('ProfileList', { + name: urip.hostname, + rkey: urip.rkey, + }) + } catch {} + }, + }) + }, [store, navigation]) + + return ( + <View style={s.hContentRegion} testID="listsScreen"> + <SimpleViewHeader + showBackButton={isMobile} + style={ + !isMobile && [pal.border, {borderLeftWidth: 1, borderRightWidth: 1}] + }> + <View style={{flex: 1}}> + <Text type="title-lg" style={[pal.text, {fontWeight: 'bold'}]}> + User Lists + </Text> + <Text style={pal.textLight}> + Public, shareable lists which can drive feeds. + </Text> + </View> + <View> + <Button + testID="newUserListBtn" + type="default" + onPress={onPressNewList} + style={{ + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }}> + <FontAwesomeIcon icon="plus" color={pal.colors.text} /> + <Text type="button" style={pal.text}> + New + </Text> + </Button> + </View> + </SimpleViewHeader> + <ListsList listsList={listsLists} /> + </View> + ) + }), +) diff --git a/src/view/screens/Moderation.tsx b/src/view/screens/Moderation.tsx index 23a808feb..d24bc145a 100644 --- a/src/view/screens/Moderation.tsx +++ b/src/view/screens/Moderation.tsx @@ -66,9 +66,9 @@ export const ModerationScreen = withAuthRequired( </Text> </TouchableOpacity> <Link - testID="mutelistsBtn" + testID="moderationlistsBtn" style={[styles.linkCard, pal.view]} - href="/moderation/mute-lists"> + href="/moderation/modlists"> <View style={[styles.iconContainer, pal.btn]}> <FontAwesomeIcon icon="users-slash" @@ -76,7 +76,7 @@ export const ModerationScreen = withAuthRequired( /> </View> <Text type="lg" style={pal.text}> - Mute lists + Moderation lists </Text> </Link> <Link diff --git a/src/view/screens/ModerationModlists.tsx b/src/view/screens/ModerationModlists.tsx new file mode 100644 index 000000000..fc148ed8e --- /dev/null +++ b/src/view/screens/ModerationModlists.tsx @@ -0,0 +1,92 @@ +import React from 'react' +import {View} from 'react-native' +import {useFocusEffect, useNavigation} from '@react-navigation/native' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {AtUri} from '@atproto/api' +import {observer} from 'mobx-react-lite' +import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' +import {withAuthRequired} from 'view/com/auth/withAuthRequired' +import {useStores} from 'state/index' +import {ListsListModel} from 'state/models/lists/lists-list' +import {ListsList} from 'view/com/lists/ListsList' +import {Text} from 'view/com/util/text/Text' +import {Button} from 'view/com/util/forms/Button' +import {NavigationProp} from 'lib/routes/types' +import {usePalette} from 'lib/hooks/usePalette' +import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {SimpleViewHeader} from 'view/com/util/SimpleViewHeader' +import {s} from 'lib/styles' + +type Props = NativeStackScreenProps<CommonNavigatorParams, 'ModerationModlists'> +export const ModerationModlistsScreen = withAuthRequired( + observer(function ModerationModlistsScreenImpl({}: Props) { + const pal = usePalette('default') + const store = useStores() + const {isMobile} = useWebMediaQueries() + const navigation = useNavigation<NavigationProp>() + + const mutelists: ListsListModel = React.useMemo( + () => new ListsListModel(store, 'my-modlists'), + [store], + ) + + useFocusEffect( + React.useCallback(() => { + store.shell.setMinimalShellMode(false) + mutelists.refresh() + }, [store, mutelists]), + ) + + const onPressNewList = React.useCallback(() => { + store.shell.openModal({ + name: 'create-or-edit-list', + purpose: 'app.bsky.graph.defs#modlist', + onSave: (uri: string) => { + try { + const urip = new AtUri(uri) + navigation.navigate('ProfileList', { + name: urip.hostname, + rkey: urip.rkey, + }) + } catch {} + }, + }) + }, [store, navigation]) + + return ( + <View style={s.hContentRegion} testID="moderationModlistsScreen"> + <SimpleViewHeader + showBackButton={isMobile} + style={ + !isMobile && [pal.border, {borderLeftWidth: 1, borderRightWidth: 1}] + }> + <View style={{flex: 1}}> + <Text type="title-lg" style={[pal.text, {fontWeight: 'bold'}]}> + Moderation Lists + </Text> + <Text style={pal.textLight}> + Public, shareable lists of users to mute or block in bulk. + </Text> + </View> + <View> + <Button + testID="newModListBtn" + type="default" + onPress={onPressNewList} + style={{ + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }}> + <FontAwesomeIcon icon="plus" color={pal.colors.text} /> + <Text type="button" style={pal.text}> + New + </Text> + </Button> + </View> + </SimpleViewHeader> + <ListsList listsList={mutelists} /> + </View> + ) + }), +) diff --git a/src/view/screens/ModerationMuteLists.tsx b/src/view/screens/ModerationMuteLists.tsx deleted file mode 100644 index bc933c24e..000000000 --- a/src/view/screens/ModerationMuteLists.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import React from 'react' -import {StyleSheet} from 'react-native' -import {useFocusEffect, useNavigation} from '@react-navigation/native' -import { - FontAwesomeIcon, - FontAwesomeIconStyle, -} from '@fortawesome/react-native-fontawesome' -import {AtUri} from '@atproto/api' -import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' -import {withAuthRequired} from 'view/com/auth/withAuthRequired' -import {EmptyStateWithButton} from 'view/com/util/EmptyStateWithButton' -import {useStores} from 'state/index' -import {ListsListModel} from 'state/models/lists/lists-list' -import {ListsList} from 'view/com/lists/ListsList' -import {Button} from 'view/com/util/forms/Button' -import {NavigationProp} from 'lib/routes/types' -import {usePalette} from 'lib/hooks/usePalette' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {CenteredView} from 'view/com/util/Views' -import {ViewHeader} from 'view/com/util/ViewHeader' - -type Props = NativeStackScreenProps< - CommonNavigatorParams, - 'ModerationMuteLists' -> -export const ModerationMuteListsScreen = withAuthRequired(({}: Props) => { - const pal = usePalette('default') - const store = useStores() - const {isTabletOrDesktop} = useWebMediaQueries() - const navigation = useNavigation<NavigationProp>() - - const mutelists: ListsListModel = React.useMemo( - () => new ListsListModel(store, 'my-modlists'), - [store], - ) - - useFocusEffect( - React.useCallback(() => { - store.shell.setMinimalShellMode(false) - mutelists.refresh() - }, [store, mutelists]), - ) - - const onPressNewMuteList = React.useCallback(() => { - store.shell.openModal({ - name: 'create-or-edit-mute-list', - onSave: (uri: string) => { - try { - const urip = new AtUri(uri) - navigation.navigate('ProfileList', { - name: urip.hostname, - rkey: urip.rkey, - }) - } catch {} - }, - }) - }, [store, navigation]) - - const renderEmptyState = React.useCallback(() => { - return ( - <EmptyStateWithButton - testID="emptyMuteLists" - icon="users-slash" - message="You can subscribe to mute lists to automatically mute all of the users they include. Mute lists are public but your subscription to a mute list is private." - buttonLabel="New Mute List" - onPress={onPressNewMuteList} - /> - ) - }, [onPressNewMuteList]) - - const renderHeaderButton = React.useCallback( - () => ( - <Button - type="primary-light" - onPress={onPressNewMuteList} - style={styles.createBtn}> - <FontAwesomeIcon - icon="plus" - style={pal.link as FontAwesomeIconStyle} - size={18} - /> - </Button> - ), - [onPressNewMuteList, pal], - ) - - return ( - <CenteredView - style={[ - styles.container, - pal.view, - pal.border, - isTabletOrDesktop && styles.containerDesktop, - ]} - testID="moderationMutelistsScreen"> - <ViewHeader - title="Mute Lists" - showOnDesktop - renderButton={renderHeaderButton} - /> - <ListsList - listsList={mutelists} - showAddBtns={isTabletOrDesktop} - renderEmptyState={renderEmptyState} - onPressCreateNew={onPressNewMuteList} - /> - </CenteredView> - ) -}) - -const styles = StyleSheet.create({ - container: { - flex: 1, - paddingBottom: 100, - }, - containerDesktop: { - borderLeftWidth: 1, - borderRightWidth: 1, - paddingBottom: 0, - }, - createBtn: { - width: 40, - }, -}) diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index 596bda57e..c1ab69313 100644 --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -25,8 +25,8 @@ import {FAB} from '../com/util/fab/FAB' import {s, colors} from 'lib/styles' import {useAnalytics} from 'lib/analytics/analytics' import {ComposeIcon2} from 'lib/icons' -import {CustomFeed} from 'view/com/feeds/CustomFeed' -import {CustomFeedModel} from 'state/models/feeds/custom-feed' +import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard' +import {FeedSourceModel} from 'state/models/content/feed-source' import {useSetTitle} from 'lib/hooks/useSetTitle' import {combinedDisplayName} from 'lib/strings/display-names' @@ -189,9 +189,14 @@ export const ProfileScreen = withAuthRequired( style={styles.emptyState} /> ) - } else if (item instanceof CustomFeedModel) { + } else if (item instanceof FeedSourceModel) { return ( - <CustomFeed item={item} showSaveBtn showLikes showDescription /> + <FeedSourceCard + item={item} + showSaveBtn + showLikes + showDescription + /> ) } // if section is posts or posts & replies diff --git a/src/view/screens/ProfileFeed.tsx b/src/view/screens/ProfileFeed.tsx new file mode 100644 index 000000000..70e52bf7a --- /dev/null +++ b/src/view/screens/ProfileFeed.tsx @@ -0,0 +1,535 @@ +import React, {useMemo, useCallback} from 'react' +import {FlatList, StyleSheet, View, ActivityIndicator} from 'react-native' +import {NativeStackScreenProps} from '@react-navigation/native-stack' +import {useNavigation} from '@react-navigation/native' +import {usePalette} from 'lib/hooks/usePalette' +import {HeartIcon, HeartIconSolid} from 'lib/icons' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {CommonNavigatorParams} from 'lib/routes/types' +import {makeRecordUri} from 'lib/strings/url-helpers' +import {colors, s} from 'lib/styles' +import {observer} from 'mobx-react-lite' +import {useStores} from 'state/index' +import {FeedSourceModel} from 'state/models/content/feed-source' +import {PostsFeedModel} from 'state/models/feeds/posts' +import {withAuthRequired} from 'view/com/auth/withAuthRequired' +import {PagerWithHeader} from 'view/com/pager/PagerWithHeader' +import {ProfileSubpageHeader} from 'view/com/profile/ProfileSubpageHeader' +import {Feed} from 'view/com/posts/Feed' +import {TextLink} from 'view/com/util/Link' +import {Button} from 'view/com/util/forms/Button' +import {Text} from 'view/com/util/text/Text' +import {RichText} from 'view/com/util/text/RichText' +import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn' +import {FAB} from 'view/com/util/fab/FAB' +import {EmptyState} from 'view/com/util/EmptyState' +import * as Toast from 'view/com/util/Toast' +import {useSetTitle} from 'lib/hooks/useSetTitle' +import {useCustomFeed} from 'lib/hooks/useCustomFeed' +import {OnScrollCb} from 'lib/hooks/useOnMainScroll' +import {shareUrl} from 'lib/sharing' +import {toShareUrl} from 'lib/strings/url-helpers' +import {Haptics} from 'lib/haptics' +import {useAnalytics} from 'lib/analytics/analytics' +import {NativeDropdown, DropdownItem} from 'view/com/util/forms/NativeDropdown' +import {resolveName} from 'lib/api' +import {makeCustomFeedLink} from 'lib/routes/links' +import {pluralize} from 'lib/strings/helpers' +import {CenteredView, ScrollView} from 'view/com/util/Views' +import {NavigationProp} from 'lib/routes/types' +import {sanitizeHandle} from 'lib/strings/handles' +import {makeProfileLink} from 'lib/routes/links' +import {ComposeIcon2} from 'lib/icons' + +const SECTION_TITLES = ['Posts', 'About'] + +interface SectionRef { + scrollToTop: () => void +} + +type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileFeed'> +export const ProfileFeedScreen = withAuthRequired( + observer(function ProfileFeedScreenImpl(props: Props) { + const pal = usePalette('default') + const store = useStores() + const navigation = useNavigation<NavigationProp>() + + const {name: handleOrDid} = props.route.params + + const [feedOwnerDid, setFeedOwnerDid] = React.useState<string | undefined>() + const [error, setError] = React.useState<string | undefined>() + + const onPressBack = React.useCallback(() => { + if (navigation.canGoBack()) { + navigation.goBack() + } else { + navigation.navigate('Home') + } + }, [navigation]) + + React.useEffect(() => { + /* + * We must resolve the DID of the feed owner before we can fetch the feed. + */ + async function fetchDid() { + try { + const did = await resolveName(store, handleOrDid) + setFeedOwnerDid(did) + } catch (e) { + setError( + `We're sorry, but we were unable to resolve this feed. If this persists, please contact the feed creator, @${handleOrDid}.`, + ) + } + } + + fetchDid() + }, [store, handleOrDid, setFeedOwnerDid]) + + if (error) { + return ( + <CenteredView> + <View style={[pal.view, pal.border, styles.notFoundContainer]}> + <Text type="title-lg" style={[pal.text, s.mb10]}> + Could not load feed + </Text> + <Text type="md" style={[pal.text, s.mb20]}> + {error} + </Text> + + <View style={{flexDirection: 'row'}}> + <Button + type="default" + accessibilityLabel="Go Back" + accessibilityHint="Return to previous page" + onPress={onPressBack} + style={{flexShrink: 1}}> + <Text type="button" style={pal.text}> + Go Back + </Text> + </Button> + </View> + </View> + </CenteredView> + ) + } + + return feedOwnerDid ? ( + <ProfileFeedScreenInner {...props} feedOwnerDid={feedOwnerDid} /> + ) : ( + <CenteredView> + <View style={s.p20}> + <ActivityIndicator size="large" /> + </View> + </CenteredView> + ) + }), +) + +export const ProfileFeedScreenInner = observer( + function ProfileFeedScreenInnerImpl({ + route, + feedOwnerDid, + }: Props & {feedOwnerDid: string}) { + const pal = usePalette('default') + const store = useStores() + const {track} = useAnalytics() + const feedSectionRef = React.useRef<SectionRef>(null) + const {rkey, name: handleOrDid} = route.params + const uri = useMemo( + () => makeRecordUri(feedOwnerDid, 'app.bsky.feed.generator', rkey), + [rkey, feedOwnerDid], + ) + const feedInfo = useCustomFeed(uri) + const feed: PostsFeedModel = useMemo(() => { + const model = new PostsFeedModel(store, 'custom', { + feed: uri, + }) + model.setup() + return model + }, [store, uri]) + const isPinned = store.preferences.isPinnedFeed(uri) + useSetTitle(feedInfo?.displayName) + + // events + // = + + const onToggleSaved = React.useCallback(async () => { + try { + Haptics.default() + if (feedInfo?.isSaved) { + await feedInfo?.unsave() + } else { + await feedInfo?.save() + } + } catch (err) { + Toast.show( + 'There was an an issue updating your feeds, please check your internet connection and try again.', + ) + store.log.error('Failed up update feeds', {err}) + } + }, [store, feedInfo]) + + const onToggleLiked = React.useCallback(async () => { + Haptics.default() + try { + if (feedInfo?.isLiked) { + await feedInfo?.unlike() + } else { + await feedInfo?.like() + } + } catch (err) { + Toast.show( + 'There was an an issue contacting the server, please check your internet connection and try again.', + ) + store.log.error('Failed up toggle like', {err}) + } + }, [store, feedInfo]) + + const onTogglePinned = React.useCallback(async () => { + Haptics.default() + if (feedInfo) { + feedInfo.togglePin().catch(e => { + Toast.show('There was an issue contacting the server') + store.log.error('Failed to toggle pinned feed', {e}) + }) + } + }, [store, feedInfo]) + + const onPressShare = React.useCallback(() => { + const url = toShareUrl(`/profile/${handleOrDid}/feed/${rkey}`) + shareUrl(url) + track('CustomFeed:Share') + }, [handleOrDid, rkey, track]) + + const onPressReport = React.useCallback(() => { + if (!feedInfo) return + store.shell.openModal({ + name: 'report', + uri: feedInfo.uri, + cid: feedInfo.cid, + }) + }, [store, feedInfo]) + + const onCurrentPageSelected = React.useCallback( + (index: number) => { + if (index === 0) { + feedSectionRef.current?.scrollToTop() + } + }, + [feedSectionRef], + ) + + // render + // = + + const dropdownItems: DropdownItem[] = React.useMemo(() => { + return [ + { + testID: 'feedHeaderDropdownToggleSavedBtn', + label: feedInfo?.isSaved ? 'Remove from my feeds' : 'Add to my feeds', + onPress: onToggleSaved, + icon: feedInfo?.isSaved + ? { + ios: { + name: 'trash', + }, + android: 'ic_delete', + web: ['far', 'trash-can'], + } + : { + ios: { + name: 'plus', + }, + android: '', + web: 'plus', + }, + }, + { + testID: 'feedHeaderDropdownReportBtn', + label: 'Report feed', + onPress: onPressReport, + icon: { + ios: { + name: 'exclamationmark.triangle', + }, + android: 'ic_menu_report_image', + web: 'circle-exclamation', + }, + }, + { + testID: 'feedHeaderDropdownShareBtn', + label: 'Share link', + onPress: onPressShare, + icon: { + ios: { + name: 'square.and.arrow.up', + }, + android: 'ic_menu_share', + web: 'share', + }, + }, + ] as DropdownItem[] + }, [feedInfo, onToggleSaved, onPressReport, onPressShare]) + + const renderHeader = useCallback(() => { + return ( + <ProfileSubpageHeader + isLoading={!feedInfo?.hasLoaded} + href={makeCustomFeedLink(feedOwnerDid, rkey)} + title={feedInfo?.displayName} + avatar={feedInfo?.avatar} + isOwner={feedInfo?.isOwner} + creator={ + feedInfo + ? {did: feedInfo.creatorDid, handle: feedInfo.creatorHandle} + : undefined + } + avatarType="algo"> + {feedInfo && ( + <> + <Button + type="default" + label={feedInfo?.isSaved ? 'Unsave' : 'Save'} + onPress={onToggleSaved} + style={styles.btn} + /> + <Button + type={isPinned ? 'default' : 'inverted'} + label={isPinned ? 'Unpin' : 'Pin to home'} + onPress={onTogglePinned} + style={styles.btn} + /> + </> + )} + <NativeDropdown + testID="headerDropdownBtn" + items={dropdownItems} + accessibilityLabel="More options" + accessibilityHint=""> + <View style={[pal.viewLight, styles.btn]}> + <FontAwesomeIcon + icon="ellipsis" + size={20} + color={pal.colors.text} + /> + </View> + </NativeDropdown> + </ProfileSubpageHeader> + ) + }, [ + pal, + feedOwnerDid, + rkey, + feedInfo, + isPinned, + onTogglePinned, + onToggleSaved, + dropdownItems, + ]) + + return ( + <View style={s.hContentRegion}> + <PagerWithHeader + items={SECTION_TITLES} + renderHeader={renderHeader} + onCurrentPageSelected={onCurrentPageSelected}> + {({onScroll, headerHeight, isScrolledDown}) => ( + <FeedSection + key="1" + ref={feedSectionRef} + feed={feed} + onScroll={onScroll} + headerHeight={headerHeight} + isScrolledDown={isScrolledDown} + /> + )} + {({onScroll, headerHeight}) => ( + <ScrollView + key="2" + onScroll={onScroll} + scrollEventThrottle={1} + contentContainerStyle={{paddingTop: headerHeight}}> + <AboutSection + feedOwnerDid={feedOwnerDid} + feedRkey={rkey} + feedInfo={feedInfo} + onToggleLiked={onToggleLiked} + /> + </ScrollView> + )} + </PagerWithHeader> + <FAB + testID="composeFAB" + onPress={() => store.shell.openComposer({})} + icon={ + <ComposeIcon2 + strokeWidth={1.5} + size={29} + style={{color: 'white'}} + /> + } + accessibilityRole="button" + accessibilityLabel="New post" + accessibilityHint="" + /> + </View> + ) + }, +) + +interface FeedSectionProps { + feed: PostsFeedModel + onScroll: OnScrollCb + headerHeight: number + isScrolledDown: boolean +} +const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>( + function FeedSectionImpl( + {feed, onScroll, headerHeight, isScrolledDown}, + ref, + ) { + const hasNew = feed.hasNewLatest && !feed.isRefreshing + const scrollElRef = React.useRef<FlatList>(null) + + const onScrollToTop = useCallback(() => { + scrollElRef.current?.scrollToOffset({offset: -headerHeight}) + }, [scrollElRef, headerHeight]) + + const onPressLoadLatest = React.useCallback(() => { + onScrollToTop() + feed.refresh() + }, [feed, onScrollToTop]) + + React.useImperativeHandle(ref, () => ({ + scrollToTop: onScrollToTop, + })) + + const renderPostsEmpty = useCallback(() => { + return <EmptyState icon="feed" message="This feed is empty!" /> + }, []) + + return ( + <View> + <Feed + feed={feed} + scrollElRef={scrollElRef} + onScroll={onScroll} + scrollEventThrottle={5} + renderEmptyState={renderPostsEmpty} + headerOffset={headerHeight} + /> + {(isScrolledDown || hasNew) && ( + <LoadLatestBtn + onPress={onPressLoadLatest} + label="Load new posts" + showIndicator={hasNew} + /> + )} + </View> + ) + }, +) + +const AboutSection = observer(function AboutPageImpl({ + feedOwnerDid, + feedRkey, + feedInfo, + onToggleLiked, +}: { + feedOwnerDid: string + feedRkey: string + feedInfo: FeedSourceModel | undefined + onToggleLiked: () => void +}) { + const pal = usePalette('default') + + if (!feedInfo) { + return <View /> + } + return ( + <View + style={[ + { + borderTopWidth: 1, + paddingVertical: 20, + paddingHorizontal: 20, + gap: 12, + }, + pal.border, + ]}> + {feedInfo.descriptionRT ? ( + <RichText + testID="listDescription" + type="lg" + style={pal.text} + richText={feedInfo.descriptionRT} + /> + ) : ( + <Text type="lg" style={[{fontStyle: 'italic'}, pal.textLight]}> + No description + </Text> + )} + <View style={{flexDirection: 'row', alignItems: 'center', gap: 10}}> + <Button + type="default" + testID="toggleLikeBtn" + accessibilityLabel="Like this feed" + accessibilityHint="" + onPress={onToggleLiked} + style={{paddingHorizontal: 10}}> + {feedInfo?.isLiked ? ( + <HeartIconSolid size={19} style={styles.liked} /> + ) : ( + <HeartIcon strokeWidth={3} size={19} style={pal.textLight} /> + )} + </Button> + {typeof feedInfo.likeCount === 'number' && ( + <TextLink + href={makeCustomFeedLink(feedOwnerDid, feedRkey, 'liked-by')} + text={`Liked by ${feedInfo.likeCount} ${pluralize( + feedInfo.likeCount, + 'user', + )}`} + style={[pal.textLight, s.semiBold]} + /> + )} + </View> + <Text type="md" style={[pal.textLight]} numberOfLines={1}> + Created by{' '} + {feedInfo.isOwner ? ( + 'you' + ) : ( + <TextLink + text={sanitizeHandle(feedInfo.creatorHandle, '@')} + href={makeProfileLink({ + did: feedInfo.creatorDid, + handle: feedInfo.creatorHandle, + })} + style={pal.textLight} + /> + )} + </Text> + </View> + ) +}) + +const styles = StyleSheet.create({ + btn: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + paddingVertical: 7, + paddingHorizontal: 14, + borderRadius: 50, + marginLeft: 6, + }, + liked: { + color: colors.red3, + }, + notFoundContainer: { + margin: 10, + paddingHorizontal: 18, + paddingVertical: 14, + borderRadius: 6, + }, +}) diff --git a/src/view/screens/CustomFeedLikedBy.tsx b/src/view/screens/ProfileFeedLikedBy.tsx index 49d0d0482..2e9d12aae 100644 --- a/src/view/screens/CustomFeedLikedBy.tsx +++ b/src/view/screens/ProfileFeedLikedBy.tsx @@ -8,8 +8,8 @@ import {PostLikedBy as PostLikedByComponent} from '../com/post-thread/PostLikedB import {useStores} from 'state/index' import {makeRecordUri} from 'lib/strings/url-helpers' -type Props = NativeStackScreenProps<CommonNavigatorParams, 'CustomFeedLikedBy'> -export const CustomFeedLikedByScreen = withAuthRequired(({route}: Props) => { +type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileFeedLikedBy'> +export const ProfileFeedLikedByScreen = withAuthRequired(({route}: Props) => { const store = useStores() const {name, rkey} = route.params const uri = makeRecordUri(name, 'app.bsky.feed.generator', rkey) diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx index 11a847db3..859f50bef 100644 --- a/src/view/screens/ProfileList.tsx +++ b/src/view/screens/ProfileList.tsx @@ -1,166 +1,802 @@ -import React from 'react' -import {StyleSheet} from 'react-native' +import React, {useCallback, useMemo} from 'react' +import { + ActivityIndicator, + FlatList, + Pressable, + StyleSheet, + View, +} from 'react-native' import {useFocusEffect} from '@react-navigation/native' import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' import {useNavigation} from '@react-navigation/native' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {observer} from 'mobx-react-lite' +import {RichText as RichTextAPI} from '@atproto/api' import {withAuthRequired} from 'view/com/auth/withAuthRequired' -import {ViewHeader} from 'view/com/util/ViewHeader' +import {PagerWithHeader} from 'view/com/pager/PagerWithHeader' +import {ProfileSubpageHeader} from 'view/com/profile/ProfileSubpageHeader' +import {Feed} from 'view/com/posts/Feed' +import {Text} from 'view/com/util/text/Text' +import {NativeDropdown, DropdownItem} from 'view/com/util/forms/NativeDropdown' import {CenteredView} from 'view/com/util/Views' -import {ListItems} from 'view/com/lists/ListItems' import {EmptyState} from 'view/com/util/EmptyState' +import {RichText} from 'view/com/util/text/RichText' +import {Button} from 'view/com/util/forms/Button' +import {TextLink} from 'view/com/util/Link' import * as Toast from 'view/com/util/Toast' +import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn' +import {FAB} from 'view/com/util/fab/FAB' +import {Haptics} from 'lib/haptics' import {ListModel} from 'state/models/content/list' +import {PostsFeedModel} from 'state/models/feeds/posts' import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' import {useSetTitle} from 'lib/hooks/useSetTitle' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {OnScrollCb} from 'lib/hooks/useOnMainScroll' import {NavigationProp} from 'lib/routes/types' import {toShareUrl} from 'lib/strings/url-helpers' import {shareUrl} from 'lib/sharing' -import {ListActions} from 'view/com/lists/ListActions' +import {resolveName} from 'lib/api' import {s} from 'lib/styles' +import {sanitizeHandle} from 'lib/strings/handles' +import {makeProfileLink, makeListLink} from 'lib/routes/links' +import {ComposeIcon2} from 'lib/icons' +import {ListItems} from 'view/com/lists/ListItems' + +const SECTION_TITLES_CURATE = ['Posts', 'About'] +const SECTION_TITLES_MOD = ['About'] + +interface SectionRef { + scrollToTop: () => void +} type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileList'> export const ProfileListScreen = withAuthRequired( - observer(function ProfileListScreenImpl({route}: Props) { + observer(function ProfileListScreenImpl(props: Props) { + const pal = usePalette('default') const store = useStores() const navigation = useNavigation<NavigationProp>() - const {isTabletOrDesktop} = useWebMediaQueries() - const pal = usePalette('default') - const {name, rkey} = route.params - const list: ListModel = React.useMemo(() => { + const {name: handleOrDid} = props.route.params + + const [listOwnerDid, setListOwnerDid] = React.useState<string | undefined>() + const [error, setError] = React.useState<string | undefined>() + + const onPressBack = useCallback(() => { + if (navigation.canGoBack()) { + navigation.goBack() + } else { + navigation.navigate('Home') + } + }, [navigation]) + + React.useEffect(() => { + /* + * We must resolve the DID of the list owner before we can fetch the list. + */ + async function fetchDid() { + try { + const did = await resolveName(store, handleOrDid) + setListOwnerDid(did) + } catch (e) { + setError( + `We're sorry, but we were unable to resolve this list. If this persists, please contact the list creator, @${handleOrDid}.`, + ) + } + } + + fetchDid() + }, [store, handleOrDid, setListOwnerDid]) + + if (error) { + return ( + <CenteredView> + <View + style={[ + pal.view, + pal.border, + { + margin: 10, + paddingHorizontal: 18, + paddingVertical: 14, + borderRadius: 6, + }, + ]}> + <Text type="title-lg" style={[pal.text, s.mb10]}> + Could not load list + </Text> + <Text type="md" style={[pal.text, s.mb20]}> + {error} + </Text> + + <View style={{flexDirection: 'row'}}> + <Button + type="default" + accessibilityLabel="Go Back" + accessibilityHint="Return to previous page" + onPress={onPressBack} + style={{flexShrink: 1}}> + <Text type="button" style={pal.text}> + Go Back + </Text> + </Button> + </View> + </View> + </CenteredView> + ) + } + + return listOwnerDid ? ( + <ProfileListScreenInner {...props} listOwnerDid={listOwnerDid} /> + ) : ( + <CenteredView> + <View style={s.p20}> + <ActivityIndicator size="large" /> + </View> + </CenteredView> + ) + }), +) + +export const ProfileListScreenInner = observer( + function ProfileListScreenInnerImpl({ + route, + listOwnerDid, + }: Props & {listOwnerDid: string}) { + const store = useStores() + const {rkey} = route.params + const feedSectionRef = React.useRef<SectionRef>(null) + const aboutSectionRef = React.useRef<SectionRef>(null) + + const list: ListModel = useMemo(() => { const model = new ListModel( store, - `at://${name}/app.bsky.graph.list/${rkey}`, + `at://${listOwnerDid}/app.bsky.graph.list/${rkey}`, ) return model - }, [store, name, rkey]) - useSetTitle(list.list?.name) + }, [store, listOwnerDid, rkey]) + const feed = useMemo( + () => new PostsFeedModel(store, 'list', {list: list.uri}), + [store, list], + ) + useSetTitle(list.data?.name) useFocusEffect( - React.useCallback(() => { + useCallback(() => { store.shell.setMinimalShellMode(false) - list.loadMore(true) - }, [store, list]), + list.loadMore(true).then(() => { + if (list.isCuratelist) { + feed.setup() + } + }) + }, [store, list, feed]), ) - const onToggleSubscribed = React.useCallback(async () => { - try { - if (list.list?.viewer?.muted) { - await list.unsubscribe() - } else { - await list.subscribe() - } - } catch (err) { - Toast.show( - 'There was an an issue updating your subscription, please check your internet connection and try again.', - ) - store.log.error('Failed up update subscription', {err}) - } - }, [store, list]) - - const onPressEditList = React.useCallback(() => { + const onPressAddUser = useCallback(() => { store.shell.openModal({ - name: 'create-or-edit-mute-list', + name: 'list-add-user', list, - onSave() { - list.refresh() + onAdd() { + if (list.isCuratelist) { + feed.refresh() + } }, }) - }, [store, list]) + }, [store, list, feed]) - const onPressDeleteList = React.useCallback(() => { - store.shell.openModal({ - name: 'confirm', - title: 'Delete List', - message: 'Are you sure?', - async onPressConfirm() { - await list.delete() - if (navigation.canGoBack()) { - navigation.goBack() - } else { - navigation.navigate('Home') - } + const onCurrentPageSelected = React.useCallback( + (index: number) => { + if (index === 0) { + feedSectionRef.current?.scrollToTop() + } + if (index === 1) { + aboutSectionRef.current?.scrollToTop() + } + }, + [feedSectionRef], + ) + + const renderHeader = useCallback(() => { + return <Header rkey={rkey} list={list} /> + }, [rkey, list]) + + if (list.isCuratelist) { + return ( + <View style={s.hContentRegion}> + <PagerWithHeader + items={SECTION_TITLES_CURATE} + renderHeader={renderHeader} + onCurrentPageSelected={onCurrentPageSelected}> + {({onScroll, headerHeight, isScrolledDown}) => ( + <FeedSection + key="1" + ref={feedSectionRef} + feed={feed} + onScroll={onScroll} + headerHeight={headerHeight} + isScrolledDown={isScrolledDown} + /> + )} + {({onScroll, headerHeight, isScrolledDown}) => ( + <AboutSection + key="2" + ref={aboutSectionRef} + list={list} + descriptionRT={list.descriptionRT} + creator={list.data ? list.data.creator : undefined} + isCurateList={list.isCuratelist} + isOwner={list.isOwner} + onPressAddUser={onPressAddUser} + onScroll={onScroll} + headerHeight={headerHeight} + isScrolledDown={isScrolledDown} + /> + )} + </PagerWithHeader> + <FAB + testID="composeFAB" + onPress={() => store.shell.openComposer({})} + icon={ + <ComposeIcon2 + strokeWidth={1.5} + size={29} + style={{color: 'white'}} + /> + } + accessibilityRole="button" + accessibilityLabel="New post" + accessibilityHint="" + /> + </View> + ) + } + if (list.isModlist) { + return ( + <View style={s.hContentRegion}> + <PagerWithHeader + items={SECTION_TITLES_MOD} + renderHeader={renderHeader}> + {({onScroll, headerHeight, isScrolledDown}) => ( + <AboutSection + key="2" + list={list} + descriptionRT={list.descriptionRT} + creator={list.data ? list.data.creator : undefined} + isCurateList={list.isCuratelist} + isOwner={list.isOwner} + onPressAddUser={onPressAddUser} + onScroll={onScroll} + headerHeight={headerHeight} + isScrolledDown={isScrolledDown} + /> + )} + </PagerWithHeader> + <FAB + testID="composeFAB" + onPress={() => store.shell.openComposer({})} + icon={ + <ComposeIcon2 + strokeWidth={1.5} + size={29} + style={{color: 'white'}} + /> + } + accessibilityRole="button" + accessibilityLabel="New post" + accessibilityHint="" + /> + </View> + ) + } + return <Header rkey={rkey} list={list} /> + }, +) + +const Header = observer(function HeaderImpl({ + rkey, + list, +}: { + rkey: string + list: ListModel +}) { + const pal = usePalette('default') + const palInverted = usePalette('inverted') + const store = useStores() + const navigation = useNavigation<NavigationProp>() + + const onTogglePinned = useCallback(async () => { + Haptics.default() + list.togglePin().catch(e => { + Toast.show('There was an issue contacting the server') + store.log.error('Failed to toggle pinned list', {e}) + }) + }, [store, list]) + + const onSubscribeMute = useCallback(() => { + store.shell.openModal({ + name: 'confirm', + title: 'Mute these accounts?', + message: + 'Muting is private. Muted accounts can interact with you, but you will not see their posts or receive notifications from them.', + confirmBtnText: 'Mute this List', + async onPressConfirm() { + try { + await list.mute() + Toast.show('List muted') + } catch { + Toast.show( + 'There was an issue. Please check your internet connection and try again.', + ) + } + }, + onPressCancel() { + store.shell.closeModal() + }, + }) + }, [store, list]) + + const onUnsubscribeMute = useCallback(async () => { + try { + await list.unmute() + Toast.show('List unmuted') + } catch { + Toast.show( + 'There was an issue. Please check your internet connection and try again.', + ) + } + }, [list]) + + const onSubscribeBlock = useCallback(() => { + store.shell.openModal({ + name: 'confirm', + title: 'Block these accounts?', + message: + 'Blocking is public. Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.', + confirmBtnText: 'Block this List', + async onPressConfirm() { + try { + await list.block() + Toast.show('List blocked') + } catch { + Toast.show( + 'There was an issue. Please check your internet connection and try again.', + ) + } + }, + onPressCancel() { + store.shell.closeModal() + }, + }) + }, [store, list]) + + const onUnsubscribeBlock = useCallback(async () => { + try { + await list.unblock() + Toast.show('List unblocked') + } catch { + Toast.show( + 'There was an issue. Please check your internet connection and try again.', + ) + } + }, [list]) + + const onPressEdit = useCallback(() => { + store.shell.openModal({ + name: 'create-or-edit-list', + list, + onSave() { + list.refresh() + }, + }) + }, [store, list]) + + const onPressDelete = useCallback(() => { + store.shell.openModal({ + name: 'confirm', + title: 'Delete List', + message: 'Are you sure?', + async onPressConfirm() { + await list.delete() + Toast.show('List deleted') + if (navigation.canGoBack()) { + navigation.goBack() + } else { + navigation.navigate('Home') + } + }, + }) + }, [store, list, navigation]) + + const onPressReport = useCallback(() => { + if (!list.data) return + store.shell.openModal({ + name: 'report', + uri: list.uri, + cid: list.data.cid, + }) + }, [store, list]) + + const onPressShare = useCallback(() => { + const url = toShareUrl(`/profile/${list.creatorDid}/lists/${rkey}`) + shareUrl(url) + }, [list.creatorDid, rkey]) + + const dropdownItems: DropdownItem[] = useMemo(() => { + if (!list.hasLoaded) { + return [] + } + let items: DropdownItem[] = [ + { + testID: 'listHeaderDropdownShareBtn', + label: 'Share', + onPress: onPressShare, + icon: { + ios: { + name: 'square.and.arrow.up', + }, + android: '', + web: 'share', + }, + }, + ] + if (list.isOwner) { + items.push({label: 'separator'}) + items.push({ + testID: 'listHeaderDropdownEditBtn', + label: 'Edit List Details', + onPress: onPressEdit, + icon: { + ios: { + name: 'pencil', + }, + android: '', + web: 'pen', }, }) - }, [store, list, navigation]) - - const onPressReportList = React.useCallback(() => { - if (!list.list) return - store.shell.openModal({ - name: 'report', - uri: list.uri, - cid: list.list.cid, + items.push({ + testID: 'listHeaderDropdownDeleteBtn', + label: 'Delete List', + onPress: onPressDelete, + icon: { + ios: { + name: 'trash', + }, + android: '', + web: ['far', 'trash-can'], + }, }) - }, [store, list]) + } else { + items.push({label: 'separator'}) + items.push({ + testID: 'listHeaderDropdownReportBtn', + label: 'Report List', + onPress: onPressReport, + icon: { + ios: { + name: 'exclamationmark.triangle', + }, + android: '', + web: 'circle-exclamation', + }, + }) + } + return items + }, [ + list.hasLoaded, + list.isOwner, + onPressShare, + onPressEdit, + onPressDelete, + onPressReport, + ]) + + const subscribeDropdownItems: DropdownItem[] = useMemo(() => { + return [ + { + testID: 'subscribeDropdownMuteBtn', + label: 'Mute accounts', + onPress: onSubscribeMute, + icon: { + ios: { + name: 'speaker.slash', + }, + android: '', + web: 'user-slash', + }, + }, + { + testID: 'subscribeDropdownBlockBtn', + label: 'Block accounts', + onPress: onSubscribeBlock, + icon: { + ios: { + name: 'person.fill.xmark', + }, + android: '', + web: 'ban', + }, + }, + ] + }, [onSubscribeMute, onSubscribeBlock]) + + return ( + <ProfileSubpageHeader + isLoading={!list.hasLoaded} + href={makeListLink( + list.data?.creator.handle || list.data?.creator.did || '', + rkey, + )} + title={list.data?.name || 'User list'} + avatar={list.data?.avatar} + isOwner={list.isOwner} + creator={list.data?.creator} + avatarType="list"> + {list.isCuratelist ? ( + <Button + testID={list.isPinned ? 'unpinBtn' : 'pinBtn'} + type={list.isPinned ? 'default' : 'inverted'} + label={list.isPinned ? 'Unpin' : 'Pin to home'} + onPress={onTogglePinned} + /> + ) : list.isModlist ? ( + list.isBlocking ? ( + <Button + testID="unblockBtn" + type="default" + label="Unblock" + onPress={onUnsubscribeBlock} + /> + ) : list.isMuting ? ( + <Button + testID="unmuteBtn" + type="default" + label="Unmute" + onPress={onUnsubscribeMute} + /> + ) : ( + <NativeDropdown + testID="subscribeBtn" + items={subscribeDropdownItems} + accessibilityLabel="Subscribe to this list" + accessibilityHint=""> + <View style={[palInverted.view, styles.btn]}> + <Text style={palInverted.text}>Subscribe</Text> + </View> + </NativeDropdown> + ) + ) : null} + <NativeDropdown + testID="headerDropdownBtn" + items={dropdownItems} + accessibilityLabel="More options" + accessibilityHint=""> + <View style={[pal.viewLight, styles.btn]}> + <FontAwesomeIcon icon="ellipsis" size={20} color={pal.colors.text} /> + </View> + </NativeDropdown> + </ProfileSubpageHeader> + ) +}) + +interface FeedSectionProps { + feed: PostsFeedModel + onScroll: OnScrollCb + headerHeight: number + isScrolledDown: boolean +} +const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>( + function FeedSectionImpl( + {feed, onScroll, headerHeight, isScrolledDown}, + ref, + ) { + const hasNew = feed.hasNewLatest && !feed.isRefreshing + const scrollElRef = React.useRef<FlatList>(null) - const onPressShareList = React.useCallback(() => { - const url = toShareUrl(`/profile/${list.creatorDid}/lists/${rkey}`) - shareUrl(url) - }, [list.creatorDid, rkey]) + const onScrollToTop = useCallback(() => { + scrollElRef.current?.scrollToOffset({offset: -headerHeight}) + }, [scrollElRef, headerHeight]) - const renderEmptyState = React.useCallback(() => { - return <EmptyState icon="users-slash" message="This list is empty!" /> + const onPressLoadLatest = React.useCallback(() => { + onScrollToTop() + feed.refresh() + }, [feed, onScrollToTop]) + + React.useImperativeHandle(ref, () => ({ + scrollToTop: onScrollToTop, + })) + + const renderPostsEmpty = useCallback(() => { + return <EmptyState icon="feed" message="This feed is empty!" /> }, []) - const renderHeaderBtns = React.useCallback(() => { - return ( - <ListActions - muted={list.list?.viewer?.muted} - isOwner={list.isOwner} - onPressDeleteList={onPressDeleteList} - onPressEditList={onPressEditList} - onToggleSubscribed={onToggleSubscribed} - onPressShareList={onPressShareList} - onPressReportList={onPressReportList} - reversed={true} + return ( + <View> + <Feed + testID="listFeed" + feed={feed} + scrollElRef={scrollElRef} + onScroll={onScroll} + scrollEventThrottle={1} + renderEmptyState={renderPostsEmpty} + headerOffset={headerHeight} /> + {(isScrolledDown || hasNew) && ( + <LoadLatestBtn + onPress={onPressLoadLatest} + label="Load new posts" + showIndicator={hasNew} + /> + )} + </View> + ) + }, +) + +interface AboutSectionProps { + list: ListModel + descriptionRT: RichTextAPI | null + creator: {did: string; handle: string} | undefined + isCurateList: boolean | undefined + isOwner: boolean | undefined + onPressAddUser: () => void + onScroll: OnScrollCb + headerHeight: number + isScrolledDown: boolean +} +const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>( + function AboutSectionImpl( + { + list, + descriptionRT, + creator, + isCurateList, + isOwner, + onPressAddUser, + onScroll, + headerHeight, + isScrolledDown, + }, + ref, + ) { + const pal = usePalette('default') + const {isMobile} = useWebMediaQueries() + const scrollElRef = React.useRef<FlatList>(null) + + const onScrollToTop = useCallback(() => { + scrollElRef.current?.scrollToOffset({offset: -headerHeight}) + }, [scrollElRef, headerHeight]) + + React.useImperativeHandle(ref, () => ({ + scrollToTop: onScrollToTop, + })) + + const renderHeader = React.useCallback(() => { + if (!list.data) { + return <View /> + } + return ( + <View> + <View + style={[ + { + borderTopWidth: 1, + padding: isMobile ? 14 : 20, + gap: 12, + }, + pal.border, + ]}> + {descriptionRT ? ( + <RichText + testID="listDescription" + type="lg" + style={pal.text} + richText={descriptionRT} + /> + ) : ( + <Text + testID="listDescriptionEmpty" + type="lg" + style={[{fontStyle: 'italic'}, pal.textLight]}> + No description + </Text> + )} + <Text type="md" style={[pal.textLight]} numberOfLines={1}> + {isCurateList ? 'User list' : 'Moderation list'} by{' '} + {isOwner ? ( + 'you' + ) : ( + <TextLink + text={sanitizeHandle(creator?.handle || '', '@')} + href={creator ? makeProfileLink(creator) : ''} + style={pal.textLight} + /> + )} + </Text> + </View> + <View + style={[ + { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: isMobile ? 14 : 20, + paddingBottom: isMobile ? 14 : 18, + }, + ]}> + <Text type="lg-bold">Users</Text> + {isOwner && ( + <Pressable + testID="addUserBtn" + accessibilityRole="button" + accessibilityLabel="Add a user to this list" + accessibilityHint="" + onPress={onPressAddUser} + style={{flexDirection: 'row', alignItems: 'center', gap: 6}}> + <FontAwesomeIcon + icon="user-plus" + color={pal.colors.link} + size={16} + /> + <Text style={pal.link}>Add</Text> + </Pressable> + )} + </View> + </View> ) }, [ - list.isOwner, - list.list?.viewer?.muted, - onPressDeleteList, - onPressEditList, - onPressShareList, - onToggleSubscribed, - onPressReportList, + pal, + list.data, + isMobile, + descriptionRT, + creator, + isCurateList, + isOwner, + onPressAddUser, ]) + const renderEmptyState = useCallback(() => { + return ( + <EmptyState + icon="users-slash" + message="This list is empty!" + style={{paddingTop: 40}} + /> + ) + }, []) + return ( - <CenteredView - style={[ - styles.container, - isTabletOrDesktop && styles.containerDesktop, - pal.view, - pal.border, - ]} - testID="moderationMutelistsScreen"> - <ViewHeader title="" renderButton={renderHeaderBtns} /> + <View> <ListItems - list={list} + testID="listItems" + scrollElRef={scrollElRef} + renderHeader={renderHeader} renderEmptyState={renderEmptyState} - onToggleSubscribed={onToggleSubscribed} - onPressEditList={onPressEditList} - onPressDeleteList={onPressDeleteList} - onPressReportList={onPressReportList} - onPressShareList={onPressShareList} - style={[s.flex1]} + list={list} + headerOffset={headerHeight} + onScroll={onScroll} + scrollEventThrottle={1} /> - </CenteredView> + {isScrolledDown && ( + <LoadLatestBtn + onPress={onScrollToTop} + label="Scroll to top" + showIndicator={false} + /> + )} + </View> ) - }), + }, ) const styles = StyleSheet.create({ - container: { - flex: 1, - paddingBottom: 100, - }, - containerDesktop: { - borderLeftWidth: 1, - borderRightWidth: 1, - paddingBottom: 0, + btn: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + paddingVertical: 7, + paddingHorizontal: 14, + borderRadius: 50, + marginLeft: 6, }, }) diff --git a/src/view/screens/SavedFeeds.tsx b/src/view/screens/SavedFeeds.tsx index 5253c5bd6..8f8cdc6c9 100644 --- a/src/view/screens/SavedFeeds.tsx +++ b/src/view/screens/SavedFeeds.tsx @@ -14,6 +14,7 @@ import {usePalette} from 'lib/hooks/usePalette' import {CommonNavigatorParams} from 'lib/routes/types' import {observer} from 'mobx-react-lite' import {useStores} from 'state/index' +import {SavedFeedsModel} from 'state/models/ui/saved-feeds' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {withAuthRequired} from 'view/com/auth/withAuthRequired' import {ViewHeader} from 'view/com/util/ViewHeader' @@ -25,9 +26,9 @@ import DraggableFlatList, { ShadowDecorator, ScaleDecorator, } from 'react-native-draggable-flatlist' -import {CustomFeed} from 'view/com/feeds/CustomFeed' +import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard' +import {FeedSourceModel} from 'state/models/content/feed-source' 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' import {Link, TextLink} from 'view/com/util/Link' @@ -41,7 +42,11 @@ export const SavedFeeds = withAuthRequired( const {isMobile, isTabletOrDesktop} = useWebMediaQueries() const {screen} = useAnalytics() - const savedFeeds = useMemo(() => store.me.savedFeeds, [store]) + const savedFeeds = useMemo(() => { + const model = new SavedFeedsModel(store) + model.refresh() + return model + }, [store]) useFocusEffect( useCallback(() => { screen('SavedFeeds') @@ -102,7 +107,7 @@ export const SavedFeeds = withAuthRequired( const onRefresh = useCallback(() => savedFeeds.refresh(), [savedFeeds]) const onDragEnd = useCallback( - async ({data}: {data: CustomFeedModel[]}) => { + async ({data}: {data: FeedSourceModel[]}) => { try { await savedFeeds.reorderPinnedFeeds(data) } catch (e) { @@ -123,8 +128,8 @@ export const SavedFeeds = withAuthRequired( <ViewHeader title="Edit My Feeds" showOnDesktop showBorder /> <DraggableFlatList containerStyle={[isTabletOrDesktop ? s.hContentRegion : s.flex1]} - data={savedFeeds.all} - keyExtractor={item => item.data.uri} + data={savedFeeds.pinned.concat(savedFeeds.unpinned)} + keyExtractor={item => item.uri} refreshing={savedFeeds.isRefreshing} refreshControl={ <RefreshControl @@ -134,7 +139,9 @@ export const SavedFeeds = withAuthRequired( titleColor={pal.colors.text} /> } - renderItem={({item, drag}) => <ListItem item={item} drag={drag} />} + renderItem={({item, drag}) => ( + <ListItem savedFeeds={savedFeeds} item={item} drag={drag} /> + )} getItemLayout={(data, index) => ({ length: 77, offset: 77 * index, @@ -152,24 +159,25 @@ export const SavedFeeds = withAuthRequired( ) const ListItem = observer(function ListItemImpl({ + savedFeeds, item, drag, }: { - item: CustomFeedModel + savedFeeds: SavedFeedsModel + item: FeedSourceModel drag: () => void }) { const pal = usePalette('default') const store = useStores() - const savedFeeds = useMemo(() => store.me.savedFeeds, [store]) - const isPinned = savedFeeds.isPinned(item) + const isPinned = item.isPinned const onTogglePinned = useCallback(() => { Haptics.default() - savedFeeds.togglePinnedFeed(item).catch(e => { + item.togglePin().catch(e => { Toast.show('There was an issue contacting the server') store.log.error('Failed to toggle pinned feed', {e}) }) - }, [savedFeeds, item, store]) + }, [item, store]) const onPressUp = useCallback( () => savedFeeds.movePinnedFeed(item, 'up').catch(e => { @@ -222,8 +230,8 @@ const ListItem = observer(function ListItemImpl({ style={s.ml20} /> ) : null} - <CustomFeed - key={item.data.uri} + <FeedSourceCard + key={item.uri} item={item} showSaveBtn style={styles.noBorder} diff --git a/src/view/shell/Drawer.tsx b/src/view/shell/Drawer.tsx index 51a846c4a..95b79faa1 100644 --- a/src/view/shell/Drawer.tsx +++ b/src/view/shell/Drawer.tsx @@ -29,6 +29,7 @@ import { MagnifyingGlassIcon2Solid, UserIconSolid, HashtagIcon, + ListIcon, HandIcon, } from 'lib/icons' import {UserAvatar} from 'view/com/util/UserAvatar' @@ -106,6 +107,12 @@ export const DrawerContent = observer(function DrawerContentImpl() { [onPressTab], ) + const onPressLists = React.useCallback(() => { + track('Menu:ItemClicked', {url: 'Lists'}) + navigation.navigate('Lists') + store.shell.closeDrawer() + }, [navigation, track, store.shell]) + const onPressModeration = React.useCallback(() => { track('Menu:ItemClicked', {url: 'Moderation'}) navigation.navigate('Moderation') @@ -277,6 +284,13 @@ export const DrawerContent = observer(function DrawerContentImpl() { onPress={onPressMyFeeds} /> <MenuItem + icon={<ListIcon strokeWidth={2} style={pal.text} size={26} />} + label="Lists" + accessibilityLabel="Lists" + accessibilityHint="" + onPress={onPressLists} + /> + <MenuItem icon={<HandIcon strokeWidth={5} style={pal.text} size={24} />} label="Moderation" accessibilityLabel="Moderation" diff --git a/src/view/shell/desktop/Feeds.tsx b/src/view/shell/desktop/Feeds.tsx index 3f2063887..3237d2cdd 100644 --- a/src/view/shell/desktop/Feeds.tsx +++ b/src/view/shell/desktop/Feeds.tsx @@ -1,16 +1,17 @@ import React from 'react' import {View, StyleSheet} from 'react-native' import {useNavigationState} from '@react-navigation/native' -import {AtUri} from '@atproto/api' import {observer} from 'mobx-react-lite' import {useStores} from 'state/index' import {usePalette} from 'lib/hooks/usePalette' +import {useDesktopRightNavItems} from 'lib/hooks/useDesktopRightNavItems' import {TextLink} from 'view/com/util/Link' import {getCurrentRoute} from 'lib/routes/helpers' export const DesktopFeeds = observer(function DesktopFeeds() { const store = useStores() const pal = usePalette('default') + const items = useDesktopRightNavItems(store.preferences.pinnedFeeds) const route = useNavigationState(state => { if (!state) { @@ -22,20 +23,22 @@ export const DesktopFeeds = observer(function DesktopFeeds() { return ( <View style={[styles.container, pal.view, pal.border]}> <FeedItem href="/" title="Following" current={route.name === 'Home'} /> - {store.me.savedFeeds.pinned.map(feed => { + {items.map(item => { try { - const {hostname, rkey} = new AtUri(feed.uri) - const href = `/profile/${hostname}/feed/${rkey}` const params = route.params as Record<string, string> + const routeName = + item.collection === 'app.bsky.feed.generator' + ? 'ProfileFeed' + : 'ProfileList' return ( <FeedItem - key={feed.uri} - href={href} - title={feed.displayName} + key={item.uri} + href={item.href} + title={item.displayName} current={ - route.name === 'CustomFeed' && - params.name === hostname && - params.rkey === rkey + route.name === routeName && + params.name === item.hostname && + params.rkey === item.rkey } /> ) diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx index 2679a6648..39271605c 100644 --- a/src/view/shell/desktop/LeftNav.tsx +++ b/src/view/shell/desktop/LeftNav.tsx @@ -31,8 +31,9 @@ import { CogIcon, CogIconSolid, ComposeIcon2, - HandIcon, + ListIcon, HashtagIcon, + HandIcon, } from 'lib/icons' import {getCurrentRoute, isTab, isStateAtTabRoot} from 'lib/routes/helpers' import {NavigationProp, CommonNavigatorParams} from 'lib/routes/types' @@ -320,12 +321,30 @@ export const DesktopLeftNav = observer(function DesktopLeftNav() { label="Notifications" /> <NavItem + href="/lists" + icon={ + <ListIcon + style={pal.text} + size={isDesktop ? 26 : 30} + strokeWidth={2} + /> + } + iconFilled={ + <ListIcon + style={pal.text} + size={isDesktop ? 26 : 30} + strokeWidth={3} + /> + } + label="Lists" + /> + <NavItem href="/moderation" icon={ <HandIcon - strokeWidth={5.5} style={pal.text} size={isDesktop ? 24 : 27} + strokeWidth={5.5} /> } iconFilled={ |