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/view/com | |
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/view/com')
39 files changed, 1179 insertions, 626 deletions
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} /> } |