diff options
-rw-r--r-- | src/components/Error.tsx | 32 | ||||
-rw-r--r-- | src/components/ListCard.tsx | 48 | ||||
-rw-r--r-- | src/components/moderation/Hider.tsx | 89 | ||||
-rw-r--r-- | src/components/moderation/ModerationDetailsDialog.tsx | 4 | ||||
-rw-r--r-- | src/lib/hooks/useGoBack.ts | 23 | ||||
-rw-r--r-- | src/screens/List/ListHiddenScreen.tsx | 216 | ||||
-rw-r--r-- | src/state/queries/list.ts | 2 | ||||
-rw-r--r-- | src/view/com/lists/ListCard.tsx | 183 | ||||
-rw-r--r-- | src/view/com/lists/MyLists.tsx | 40 | ||||
-rw-r--r-- | src/view/com/util/post-embeds/ListEmbed.tsx | 32 | ||||
-rw-r--r-- | src/view/com/util/post-embeds/index.tsx | 16 | ||||
-rw-r--r-- | src/view/screens/ProfileList.tsx | 148 |
12 files changed, 494 insertions, 339 deletions
diff --git a/src/components/Error.tsx b/src/components/Error.tsx index 481532434..59d219831 100644 --- a/src/components/Error.tsx +++ b/src/components/Error.tsx @@ -2,21 +2,18 @@ import React from 'react' import {View} from 'react-native' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {useNavigation} from '@react-navigation/core' -import {StackActions} from '@react-navigation/native' -import {NavigationProp} from 'lib/routes/types' +import {useGoBack} from 'lib/hooks/useGoBack' import {CenteredView} from 'view/com/util/Views' import {atoms as a, useBreakpoints, useTheme} from '#/alf' import {Button, ButtonText} from '#/components/Button' import {Text} from '#/components/Typography' -import {router} from '#/routes' export function Error({ title, message, onRetry, - onGoBack: onGoBackProp, + onGoBack, hideBackButton, sideBorders = true, }: { @@ -27,31 +24,10 @@ export function Error({ hideBackButton?: boolean sideBorders?: boolean }) { - const navigation = useNavigation<NavigationProp>() const {_} = useLingui() const t = useTheme() const {gtMobile} = useBreakpoints() - - const canGoBack = navigation.canGoBack() - const onGoBack = React.useCallback(() => { - if (onGoBackProp) { - onGoBackProp() - return - } - if (canGoBack) { - navigation.goBack() - } else { - navigation.navigate('HomeTab') - - // Checking the state for routes ensures that web doesn't encounter errors while going back - if (navigation.getState()?.routes) { - navigation.dispatch(StackActions.push(...router.matchPath('/'))) - } else { - navigation.navigate('HomeTab') - navigation.dispatch(StackActions.popToTop()) - } - } - }, [navigation, canGoBack, onGoBackProp]) + const goBack = useGoBack(onGoBack) return ( <CenteredView @@ -96,7 +72,7 @@ export function Error({ variant="solid" color={onRetry ? 'secondary' : 'primary'} label={_(msg`Return to previous page`)} - onPress={onGoBack} + onPress={goBack} size="large" style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]}> <ButtonText> diff --git a/src/components/ListCard.tsx b/src/components/ListCard.tsx index 0ed27cf50..829f36d47 100644 --- a/src/components/ListCard.tsx +++ b/src/components/ListCard.tsx @@ -1,13 +1,20 @@ import React from 'react' import {View} from 'react-native' -import {AppBskyActorDefs, AppBskyGraphDefs, AtUri} from '@atproto/api' +import { + AppBskyActorDefs, + AppBskyGraphDefs, + AtUri, + moderateUserList, + ModerationUI, +} from '@atproto/api' import {Trans} from '@lingui/macro' import {useQueryClient} from '@tanstack/react-query' import {sanitizeHandle} from 'lib/strings/handles' +import {useModerationOpts} from 'state/preferences/moderation-opts' import {precacheList} from 'state/queries/feed' -import {useTheme} from '#/alf' -import {atoms as a} from '#/alf' +import {useSession} from 'state/session' +import {atoms as a, useTheme} from '#/alf' import { Avatar, Description, @@ -16,6 +23,7 @@ import { SaveButton, } from '#/components/FeedCard' import {Link as InternalLink, LinkProps} from '#/components/Link' +import * as Hider from '#/components/moderation/Hider' import {Text} from '#/components/Typography' /* @@ -43,6 +51,11 @@ type Props = { export function Default(props: Props) { const {view, showPinButton} = props + const moderationOpts = useModerationOpts() + const moderation = moderationOpts + ? moderateUserList(view, moderationOpts) + : undefined + return ( <Link {...props}> <Outer> @@ -52,6 +65,7 @@ export function Default(props: Props) { title={view.name} creator={view.creator} purpose={view.purpose} + modUi={moderation?.ui('contentView')} /> {showPinButton && view.purpose === CURATELIST && ( <SaveButton view={view} pin /> @@ -89,18 +103,40 @@ export function TitleAndByline({ title, creator, purpose = CURATELIST, + modUi, }: { title: string creator?: AppBskyActorDefs.ProfileViewBasic purpose?: AppBskyGraphDefs.ListView['purpose'] + modUi?: ModerationUI }) { const t = useTheme() + const {currentAccount} = useSession() return ( <View style={[a.flex_1]}> - <Text style={[a.text_md, a.font_bold, a.leading_snug]} numberOfLines={1}> - {title} - </Text> + <Hider.Outer + modui={modUi} + isContentVisibleInitialState={ + creator && currentAccount?.did === creator.did + } + allowOverride={creator && currentAccount?.did === creator.did}> + <Hider.Mask> + <Text + style={[a.text_md, a.font_bold, a.leading_snug, a.italic]} + numberOfLines={1}> + <Trans>Hidden list</Trans> + </Text> + </Hider.Mask> + <Hider.Content> + <Text + style={[a.text_md, a.font_bold, a.leading_snug]} + numberOfLines={1}> + {title} + </Text> + </Hider.Content> + </Hider.Outer> + {creator && ( <Text style={[a.leading_snug, t.atoms.text_contrast_medium]} diff --git a/src/components/moderation/Hider.tsx b/src/components/moderation/Hider.tsx new file mode 100644 index 000000000..fcb88ddd9 --- /dev/null +++ b/src/components/moderation/Hider.tsx @@ -0,0 +1,89 @@ +import React from 'react' +import {ModerationUI} from '@atproto/api' + +import { + ModerationCauseDescription, + useModerationCauseDescription, +} from '#/lib/moderation/useModerationCauseDescription' +import { + ModerationDetailsDialog, + useModerationDetailsDialogControl, +} from '#/components/moderation/ModerationDetailsDialog' + +type Context = { + isContentVisible: boolean + setIsContentVisible: (show: boolean) => void + info: ModerationCauseDescription + showInfoDialog: () => void + meta: { + isNoPwi: boolean + allowOverride: boolean + } +} + +const Context = React.createContext<Context>({} as Context) + +export const useHider = () => React.useContext(Context) + +export function Outer({ + modui, + isContentVisibleInitialState, + allowOverride, + children, +}: React.PropsWithChildren<{ + isContentVisibleInitialState?: boolean + allowOverride?: boolean + modui: ModerationUI | undefined +}>) { + const control = useModerationDetailsDialogControl() + const blur = modui?.blurs[0] + const [isContentVisible, setIsContentVisible] = React.useState( + isContentVisibleInitialState || !blur, + ) + const info = useModerationCauseDescription(blur) + + const meta = { + isNoPwi: Boolean( + modui?.blurs.find( + cause => + cause.type === 'label' && + cause.labelDef.identifier === '!no-unauthenticated', + ), + ), + allowOverride: allowOverride ?? !modui?.noOverride, + } + + const showInfoDialog = () => { + control.open() + } + + const onSetContentVisible = (show: boolean) => { + if (meta.allowOverride) return + setIsContentVisible(show) + } + + const ctx = { + isContentVisible, + setIsContentVisible: onSetContentVisible, + showInfoDialog, + info, + meta, + } + + return ( + <Context.Provider value={ctx}> + {children} + <ModerationDetailsDialog control={control} modcause={blur} /> + </Context.Provider> + ) +} + +export function Content({children}: {children: React.ReactNode}) { + const ctx = useHider() + return ctx.isContentVisible ? children : null +} + +export function Mask({children}: {children: React.ReactNode}) { + const ctx = useHider() + return ctx.isContentVisible ? null : children +} diff --git a/src/components/moderation/ModerationDetailsDialog.tsx b/src/components/moderation/ModerationDetailsDialog.tsx index ebfe45232..b8f02582c 100644 --- a/src/components/moderation/ModerationDetailsDialog.tsx +++ b/src/components/moderation/ModerationDetailsDialog.tsx @@ -18,7 +18,7 @@ export {useDialogControl as useModerationDetailsDialogControl} from '#/component export interface ModerationDetailsDialogProps { control: Dialog.DialogOuterProps['control'] - modcause: ModerationCause + modcause?: ModerationCause } export function ModerationDetailsDialog(props: ModerationDetailsDialogProps) { @@ -123,7 +123,7 @@ function ModerationDetailsDialogInner({ {description} </Text> - {modcause.type === 'label' && ( + {modcause?.type === 'label' && ( <> <Divider /> <Text style={[t.atoms.text, a.text_md, a.leading_snug, a.mt_lg]}> diff --git a/src/lib/hooks/useGoBack.ts b/src/lib/hooks/useGoBack.ts new file mode 100644 index 000000000..59555bdac --- /dev/null +++ b/src/lib/hooks/useGoBack.ts @@ -0,0 +1,23 @@ +import {StackActions, useNavigation} from '@react-navigation/native' + +import {NavigationProp} from 'lib/routes/types' +import {router} from '#/routes' + +export function useGoBack(onGoBack?: () => unknown) { + const navigation = useNavigation<NavigationProp>() + return () => { + onGoBack?.() + if (navigation.canGoBack()) { + navigation.goBack() + } else { + navigation.navigate('HomeTab') + // Checking the state for routes ensures that web doesn't encounter errors while going back + if (navigation.getState()?.routes) { + navigation.dispatch(StackActions.push(...router.matchPath('/'))) + } else { + navigation.navigate('HomeTab') + navigation.dispatch(StackActions.popToTop()) + } + } + } +} diff --git a/src/screens/List/ListHiddenScreen.tsx b/src/screens/List/ListHiddenScreen.tsx new file mode 100644 index 000000000..473bb08ea --- /dev/null +++ b/src/screens/List/ListHiddenScreen.tsx @@ -0,0 +1,216 @@ +import React from 'react' +import {View} from 'react-native' +import {AppBskyGraphDefs} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useQueryClient} from '@tanstack/react-query' + +import {logger} from '#/logger' +import {RQKEY_ROOT as listQueryRoot} from '#/state/queries/list' +import {useGoBack} from 'lib/hooks/useGoBack' +import {sanitizeHandle} from 'lib/strings/handles' +import {useListBlockMutation, useListMuteMutation} from 'state/queries/list' +import { + UsePreferencesQueryResponse, + useRemoveFeedMutation, +} from 'state/queries/preferences' +import {useSession} from 'state/session' +import * as Toast from 'view/com/util/Toast' +import {CenteredView} from 'view/com/util/Views' +import {atoms as a, useBreakpoints, useTheme} from '#/alf' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash' +import {Loader} from '#/components/Loader' +import {useHider} from '#/components/moderation/Hider' +import {Text} from '#/components/Typography' + +export function ListHiddenScreen({ + list, + preferences, +}: { + list: AppBskyGraphDefs.ListView + preferences: UsePreferencesQueryResponse +}) { + const {_} = useLingui() + const t = useTheme() + const {currentAccount} = useSession() + const {gtMobile} = useBreakpoints() + const isOwner = currentAccount?.did === list.creator.did + const goBack = useGoBack() + const queryClient = useQueryClient() + + const isModList = list.purpose === AppBskyGraphDefs.MODLIST + + const [isProcessing, setIsProcessing] = React.useState(false) + const listBlockMutation = useListBlockMutation() + const listMuteMutation = useListMuteMutation() + const {mutateAsync: removeSavedFeed} = useRemoveFeedMutation() + + const {setIsContentVisible} = useHider() + + const savedFeedConfig = preferences.savedFeeds.find(f => f.value === list.uri) + + const onUnsubscribe = async () => { + setIsProcessing(true) + if (list.viewer?.muted) { + try { + await listMuteMutation.mutateAsync({uri: list.uri, mute: false}) + } catch (e) { + setIsProcessing(false) + logger.error('Failed to unmute list', {message: e}) + Toast.show( + _( + msg`There was an issue. Please check your internet connection and try again.`, + ), + ) + return + } + } + if (list.viewer?.blocked) { + try { + await listBlockMutation.mutateAsync({uri: list.uri, block: false}) + } catch (e) { + setIsProcessing(false) + logger.error('Failed to unblock list', {message: e}) + Toast.show( + _( + msg`There was an issue. Please check your internet connection and try again.`, + ), + ) + return + } + } + queryClient.invalidateQueries({ + queryKey: [listQueryRoot], + }) + Toast.show(_(msg`Unsubscribed from list`)) + setIsProcessing(false) + } + + const onRemoveList = async () => { + if (!savedFeedConfig) return + try { + await removeSavedFeed(savedFeedConfig) + Toast.show(_(msg`Removed from saved feeds`)) + } catch (e) { + logger.error('Failed to remove list from saved feeds', {message: e}) + Toast.show( + _( + msg`There was an issue. Please check your internet connection and try again.`, + ), + ) + } finally { + setIsProcessing(false) + } + } + + return ( + <CenteredView + style={[ + a.flex_1, + a.align_center, + a.gap_5xl, + !gtMobile && a.justify_between, + t.atoms.border_contrast_low, + {paddingTop: 175, paddingBottom: 110}, + ]} + sideBorders={true}> + <View style={[a.w_full, a.align_center, a.gap_lg]}> + <EyeSlash + style={{color: t.atoms.text_contrast_medium.color}} + height={42} + width={42} + /> + <View style={[a.gap_sm, a.align_center]}> + <Text style={[a.font_bold, a.text_3xl]}> + <Trans>List has been hidden</Trans> + </Text> + <Text + style={[ + a.text_md, + a.text_center, + a.px_md, + t.atoms.text_contrast_high, + {lineHeight: 1.4}, + ]}> + <Trans> + This list - created by{' '} + <Text style={[a.text_md, !isOwner && a.font_bold]}> + {isOwner + ? _(msg`you`) + : sanitizeHandle(list.creator.handle, '@')} + </Text>{' '} + - contains possible violations of Bluesky's community guidelines + in its name or description. + </Trans> + </Text> + </View> + </View> + <View style={[a.gap_md, gtMobile ? {width: 350} : [a.w_full, a.px_lg]]}> + <View style={[a.gap_md]}> + {savedFeedConfig ? ( + <Button + variant="solid" + color="secondary" + size="medium" + label={_(msg`Remove from saved feeds`)} + onPress={onRemoveList} + disabled={isProcessing}> + <ButtonText> + <Trans>Removed from saved feeds</Trans> + </ButtonText> + {isProcessing ? ( + <ButtonIcon icon={Loader} position="right" /> + ) : null} + </Button> + ) : null} + {isOwner ? ( + <Button + variant="solid" + color="secondary" + size="medium" + label={_(msg`Show list anyway`)} + onPress={() => setIsContentVisible(true)} + disabled={isProcessing}> + <ButtonText> + <Trans>Show anyway</Trans> + </ButtonText> + </Button> + ) : list.viewer?.muted || list.viewer?.blocked ? ( + <Button + variant="solid" + color="secondary" + size="medium" + label={_(msg`Unsubscribe from list`)} + onPress={() => { + if (isModList) { + onUnsubscribe() + } else { + onRemoveList() + } + }} + disabled={isProcessing}> + <ButtonText> + <Trans>Unsubscribe from list</Trans> + </ButtonText> + {isProcessing ? ( + <ButtonIcon icon={Loader} position="right" /> + ) : null} + </Button> + ) : null} + </View> + <Button + variant="solid" + color="primary" + label={_(msg`Return to previous page`)} + onPress={goBack} + size="medium" + disabled={isProcessing}> + <ButtonText> + <Trans>Go Back</Trans> + </ButtonText> + </Button> + </View> + </CenteredView> + ) +} diff --git a/src/state/queries/list.ts b/src/state/queries/list.ts index eeb9c3b38..405cb4ae3 100644 --- a/src/state/queries/list.ts +++ b/src/state/queries/list.ts @@ -17,7 +17,7 @@ import {useAgent, useSession} from '../session' import {invalidate as invalidateMyLists} from './my-lists' import {RQKEY as PROFILE_LISTS_RQKEY} from './profile-lists' -const RQKEY_ROOT = 'list' +export const RQKEY_ROOT = 'list' export const RQKEY = (uri: string) => [RQKEY_ROOT, uri] export function useListQuery(uri?: string) { diff --git a/src/view/com/lists/ListCard.tsx b/src/view/com/lists/ListCard.tsx deleted file mode 100644 index 587885502..000000000 --- a/src/view/com/lists/ListCard.tsx +++ /dev/null @@ -1,183 +0,0 @@ -import React from 'react' -import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' -import {AppBskyGraphDefs, AtUri, RichText} from '@atproto/api' -import {Trans} from '@lingui/macro' - -import {useSession} from '#/state/session' -import {usePalette} from 'lib/hooks/usePalette' -import {makeProfileLink} from 'lib/routes/links' -import {sanitizeDisplayName} from 'lib/strings/display-names' -import {sanitizeHandle} from 'lib/strings/handles' -import {s} from 'lib/styles' -import {atoms as a} from '#/alf' -import {RichText as RichTextCom} from '#/components/RichText' -import {Link} from '../util/Link' -import {Text} from '../util/text/Text' -import {UserAvatar} from '../util/UserAvatar' - -export const ListCard = ({ - testID, - list, - noBg, - noBorder, - renderButton, - style, -}: { - testID?: string - list: AppBskyGraphDefs.ListView - noBg?: boolean - noBorder?: boolean - renderButton?: () => JSX.Element - style?: StyleProp<ViewStyle> -}) => { - const pal = usePalette('default') - const {currentAccount} = useSession() - - const rkey = React.useMemo(() => { - try { - const urip = new AtUri(list.uri) - return urip.rkey - } catch { - return '' - } - }, [list]) - - const descriptionRichText = React.useMemo(() => { - if (list.description) { - return new RichText({ - text: list.description, - facets: list.descriptionFacets, - }) - } - return undefined - }, [list]) - - return ( - <Link - testID={testID} - style={[ - styles.outer, - pal.border, - noBorder && styles.outerNoBorder, - !noBg && pal.view, - style, - ]} - href={makeProfileLink(list.creator, 'lists', rkey)} - title={list.name} - asAnchor - anchorNoUnderline> - <View style={styles.layout}> - <View style={styles.layoutAvi}> - <UserAvatar type="list" size={40} avatar={list.avatar} /> - </View> - <View style={styles.layoutContent}> - <Text - type="lg" - style={[s.bold, pal.text]} - numberOfLines={1} - lineHeight={1.2}> - {sanitizeDisplayName(list.name)} - </Text> - <Text type="md" style={[pal.textLight]} numberOfLines={1}> - {list.purpose === 'app.bsky.graph.defs#curatelist' && - (list.creator.did === currentAccount?.did ? ( - <Trans>User list by you</Trans> - ) : ( - <Trans> - User list by {sanitizeHandle(list.creator.handle, '@')} - </Trans> - ))} - {list.purpose === 'app.bsky.graph.defs#modlist' && - (list.creator.did === currentAccount?.did ? ( - <Trans>Moderation list by you</Trans> - ) : ( - <Trans> - Moderation list by {sanitizeHandle(list.creator.handle, '@')} - </Trans> - ))} - </Text> - <View style={s.flexRow}> - {list.viewer?.muted ? ( - <View style={[s.mt5, pal.btn, styles.pill]}> - <Text type="xs" style={pal.text}> - <Trans>Muted</Trans> - </Text> - </View> - ) : null} - - {list.viewer?.blocked ? ( - <View style={[s.mt5, pal.btn, styles.pill]}> - <Text type="xs" style={pal.text}> - <Trans>Blocked</Trans> - </Text> - </View> - ) : null} - </View> - </View> - {renderButton ? ( - <View style={styles.layoutButton}>{renderButton()}</View> - ) : undefined} - </View> - {descriptionRichText ? ( - <View style={styles.details}> - <RichTextCom - style={[a.flex_1]} - numberOfLines={20} - value={descriptionRichText} - /> - </View> - ) : undefined} - </Link> - ) -} - -const styles = StyleSheet.create({ - outer: { - borderTopWidth: StyleSheet.hairlineWidth, - paddingHorizontal: 6, - }, - outerNoBorder: { - borderTopWidth: 0, - }, - layout: { - flexDirection: 'row', - alignItems: 'center', - }, - layoutAvi: { - width: 54, - paddingLeft: 4, - paddingTop: 8, - paddingBottom: 10, - }, - avi: { - width: 40, - height: 40, - borderRadius: 20, - resizeMode: 'cover', - }, - layoutContent: { - flex: 1, - paddingRight: 10, - paddingTop: 10, - paddingBottom: 10, - }, - layoutButton: { - paddingRight: 10, - }, - details: { - paddingLeft: 54, - paddingRight: 10, - paddingBottom: 10, - }, - pill: { - borderRadius: 4, - paddingHorizontal: 6, - paddingVertical: 2, - }, - btn: { - paddingVertical: 7, - borderRadius: 50, - marginLeft: 6, - paddingHorizontal: 14, - }, -}) diff --git a/src/view/com/lists/MyLists.tsx b/src/view/com/lists/MyLists.tsx index 472d2688c..b56fa6c75 100644 --- a/src/view/com/lists/MyLists.tsx +++ b/src/view/com/lists/MyLists.tsx @@ -4,7 +4,6 @@ import { FlatList as RNFlatList, RefreshControl, StyleProp, - StyleSheet, View, ViewStyle, } from 'react-native' @@ -18,10 +17,13 @@ import {MyListsFilter, useMyListsQuery} from '#/state/queries/my-lists' import {useAnalytics} from 'lib/analytics/analytics' import {usePalette} from 'lib/hooks/usePalette' import {s} from 'lib/styles' +import {isWeb} from 'platform/detection' +import {useModerationOpts} from 'state/preferences/moderation-opts' import {EmptyState} from 'view/com/util/EmptyState' +import {atoms as a, useTheme} from '#/alf' +import * as ListCard from '#/components/ListCard' import {ErrorMessage} from '../util/error/ErrorMessage' import {List} from '../util/List' -import {ListCard} from './ListCard' const LOADING = {_reactKey: '__loading__'} const EMPTY = {_reactKey: '__empty__'} @@ -41,8 +43,10 @@ export function MyLists({ testID?: string }) { const pal = usePalette('default') + const t = useTheme() const {track} = useAnalytics() const {_} = useLingui() + const moderationOpts = useModerationOpts() const [isPTRing, setIsPTRing] = React.useState(false) const {data, isFetching, isFetched, isError, error, refetch} = useMyListsQuery(filter) @@ -53,7 +57,7 @@ export function MyLists({ if (isError && isEmpty) { items = items.concat([ERROR_ITEM]) } - if (!isFetched && isFetching) { + if ((!isFetched && isFetching) || !moderationOpts) { items = items.concat([LOADING]) } else if (isEmpty) { items = items.concat([EMPTY]) @@ -61,7 +65,7 @@ export function MyLists({ items = items.concat(data) } return items - }, [isError, isEmpty, isFetched, isFetching, data]) + }, [isError, isEmpty, isFetched, isFetching, moderationOpts, data]) // events // = @@ -85,7 +89,6 @@ export function MyLists({ if (item === EMPTY) { return ( <EmptyState - key={item._reactKey} icon="list-ul" message={_(msg`You have no lists.`)} testID="listsEmpty" @@ -94,14 +97,13 @@ export function MyLists({ } else if (item === ERROR_ITEM) { return ( <ErrorMessage - key={item._reactKey} message={cleanError(error)} onPressTryAgain={onRefresh} /> ) } else if (item === LOADING) { return ( - <View key={item._reactKey} style={{padding: 20}}> + <View style={{padding: 20}}> <ActivityIndicator /> </View> ) @@ -109,15 +111,18 @@ export function MyLists({ return renderItem ? ( renderItem(item, index) ) : ( - <ListCard - key={item.uri} - list={item} - testID={`list-${item.name}`} - style={styles.item} - /> + <View + style={[ + (index !== 0 || isWeb) && a.border_t, + t.atoms.border_contrast_low, + a.px_lg, + a.py_lg, + ]}> + <ListCard.Default view={item} /> + </View> ) }, - [error, onRefresh, renderItem, _], + [renderItem, t.atoms.border_contrast_low, _, error, onRefresh], ) if (inline) { @@ -166,10 +171,3 @@ export function MyLists({ ) } } - -const styles = StyleSheet.create({ - item: { - paddingHorizontal: 18, - paddingVertical: 4, - }, -}) diff --git a/src/view/com/util/post-embeds/ListEmbed.tsx b/src/view/com/util/post-embeds/ListEmbed.tsx deleted file mode 100644 index fc5ad270f..000000000 --- a/src/view/com/util/post-embeds/ListEmbed.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import React from 'react' -import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' -import {usePalette} from 'lib/hooks/usePalette' -import {ListCard} from 'view/com/lists/ListCard' -import {AppBskyGraphDefs} from '@atproto/api' -import {s} from 'lib/styles' - -export function ListEmbed({ - item, - style, -}: { - item: AppBskyGraphDefs.ListView - style?: StyleProp<ViewStyle> -}) { - const pal = usePalette('default') - - return ( - <View style={[pal.view, pal.border, s.border1, styles.container]}> - <ListCard list={item} style={[style, styles.card]} /> - </View> - ) -} - -const styles = StyleSheet.create({ - container: { - borderRadius: 8, - }, - card: { - borderTopWidth: 0, - borderRadius: 8, - }, -}) diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx index 0462212fb..9c1364483 100644 --- a/src/view/com/util/post-embeds/index.tsx +++ b/src/view/com/util/post-embeds/index.tsx @@ -25,13 +25,13 @@ import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {usePalette} from 'lib/hooks/usePalette' import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard' -import {atoms as a} from '#/alf' +import {atoms as a, useTheme} from '#/alf' +import * as ListCard from '#/components/ListCard' import {Embed as StarterPackCard} from '#/components/StarterPack/StarterPackCard' import {ContentHider} from '../../../../components/moderation/ContentHider' import {AutoSizedImage} from '../images/AutoSizedImage' import {ImageLayoutGrid} from '../images/ImageLayoutGrid' import {ExternalLinkEmbed} from './ExternalLinkEmbed' -import {ListEmbed} from './ListEmbed' import {MaybeQuoteEmbed} from './QuoteEmbed' type Embed = @@ -203,10 +203,20 @@ function MaybeListCard({view}: {view: AppBskyGraphDefs.ListView}) { const moderation = React.useMemo(() => { return moderationOpts ? moderateUserList(view, moderationOpts) : undefined }, [view, moderationOpts]) + const t = useTheme() return ( <ContentHider modui={moderation?.ui('contentList')}> - <ListEmbed item={view} /> + <View + style={[ + a.border, + t.atoms.border_contrast_medium, + a.p_md, + a.rounded_sm, + a.mt_sm, + ]}> + <ListCard.Default view={view} /> + </View> </ContentHider> ) } diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx index bf13791ae..0c2c6405f 100644 --- a/src/view/screens/ProfileList.tsx +++ b/src/view/screens/ProfileList.tsx @@ -32,6 +32,7 @@ import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' import { useAddSavedFeedsMutation, usePreferencesQuery, + UsePreferencesQueryResponse, useRemoveFeedMutation, useUpdateSavedFeedsMutation, } from '#/state/queries/preferences' @@ -67,9 +68,10 @@ import {LoadingScreen} from 'view/com/util/LoadingScreen' import {Text} from 'view/com/util/text/Text' import * as Toast from 'view/com/util/Toast' import {CenteredView} from 'view/com/util/Views' +import {ListHiddenScreen} from '#/screens/List/ListHiddenScreen' import {atoms as a, useTheme} from '#/alf' import {useDialogControl} from '#/components/Dialog' -import {ScreenHider} from '#/components/moderation/ScreenHider' +import * as Hider from '#/components/moderation/Hider' import * as Prompt from '#/components/Prompt' import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog' import {RichText} from '#/components/RichText' @@ -88,6 +90,7 @@ export function ProfileListScreen(props: Props) { const {data: resolvedUri, error: resolveError} = useResolveUriQuery( AtUri.make(handleOrDid, 'app.bsky.graph.list', rkey).toString(), ) + const {data: preferences} = usePreferencesQuery() const {data: list, error: listError} = useListQuery(resolvedUri?.uri) const moderationOpts = useModerationOpts() @@ -110,12 +113,13 @@ export function ProfileListScreen(props: Props) { ) } - return resolvedUri && list && moderationOpts ? ( + return resolvedUri && list && moderationOpts && preferences ? ( <ProfileListScreenLoaded {...props} uri={resolvedUri.uri} list={list} moderationOpts={moderationOpts} + preferences={preferences} /> ) : ( <LoadingScreen /> @@ -127,27 +131,32 @@ function ProfileListScreenLoaded({ uri, list, moderationOpts, + preferences, }: Props & { uri: string list: AppBskyGraphDefs.ListView moderationOpts: ModerationOpts + preferences: UsePreferencesQueryResponse }) { const {_} = useLingui() const queryClient = useQueryClient() const {openComposer} = useComposerControls() const setMinimalShellMode = useSetMinimalShellMode() + const {currentAccount} = useSession() const {rkey} = route.params const feedSectionRef = React.useRef<SectionRef>(null) const aboutSectionRef = React.useRef<SectionRef>(null) const {openModal} = useModalControls() - const isCurateList = list.purpose === 'app.bsky.graph.defs#curatelist' + const isCurateList = list.purpose === AppBskyGraphDefs.CURATELIST const isScreenFocused = useIsFocused() + const isHidden = list.labels?.findIndex(l => l.val === '!hide') !== -1 + const isOwner = currentAccount?.did === list.creator.did const moderation = React.useMemo(() => { return moderateUserList(list, moderationOpts) }, [list, moderationOpts]) - useSetTitle(list.name) + useSetTitle(isHidden ? _(msg`List Hidden`) : list.name) useFocusEffect( useCallback(() => { @@ -179,34 +188,75 @@ function ProfileListScreenLoaded({ ) const renderHeader = useCallback(() => { - return <Header rkey={rkey} list={list} /> - }, [rkey, list]) + return <Header rkey={rkey} list={list} preferences={preferences} /> + }, [rkey, list, preferences]) if (isCurateList) { return ( - <ScreenHider - screenDescription={'list'} - modui={moderation.ui('contentView')}> + <Hider.Outer modui={moderation.ui('contentView')} allowOverride={isOwner}> + <Hider.Mask> + <ListHiddenScreen list={list} preferences={preferences} /> + </Hider.Mask> + <Hider.Content> + <View style={s.hContentRegion}> + <PagerWithHeader + items={SECTION_TITLES_CURATE} + isHeaderReady={true} + renderHeader={renderHeader} + onCurrentPageSelected={onCurrentPageSelected}> + {({headerHeight, scrollElRef, isFocused}) => ( + <FeedSection + ref={feedSectionRef} + feed={`list|${uri}`} + scrollElRef={scrollElRef as ListRef} + headerHeight={headerHeight} + isFocused={isScreenFocused && isFocused} + /> + )} + {({headerHeight, scrollElRef}) => ( + <AboutSection + ref={aboutSectionRef} + scrollElRef={scrollElRef as ListRef} + list={list} + onPressAddUser={onPressAddUser} + headerHeight={headerHeight} + /> + )} + </PagerWithHeader> + <FAB + testID="composeFAB" + onPress={() => openComposer({})} + icon={ + <ComposeIcon2 + strokeWidth={1.5} + size={29} + style={{color: 'white'}} + /> + } + accessibilityRole="button" + accessibilityLabel={_(msg`New post`)} + accessibilityHint="" + /> + </View> + </Hider.Content> + </Hider.Outer> + ) + } + return ( + <Hider.Outer modui={moderation.ui('contentView')} allowOverride={isOwner}> + <Hider.Mask> + <ListHiddenScreen list={list} preferences={preferences} /> + </Hider.Mask> + <Hider.Content> <View style={s.hContentRegion}> <PagerWithHeader - items={SECTION_TITLES_CURATE} + items={SECTION_TITLES_MOD} isHeaderReady={true} - renderHeader={renderHeader} - onCurrentPageSelected={onCurrentPageSelected}> - {({headerHeight, scrollElRef, isFocused}) => ( - <FeedSection - ref={feedSectionRef} - feed={`list|${uri}`} - scrollElRef={scrollElRef as ListRef} - headerHeight={headerHeight} - isFocused={isScreenFocused && isFocused} - /> - )} + renderHeader={renderHeader}> {({headerHeight, scrollElRef}) => ( <AboutSection - ref={aboutSectionRef} - scrollElRef={scrollElRef as ListRef} list={list} + scrollElRef={scrollElRef as ListRef} onPressAddUser={onPressAddUser} headerHeight={headerHeight} /> @@ -227,47 +277,20 @@ function ProfileListScreenLoaded({ accessibilityHint="" /> </View> - </ScreenHider> - ) - } - return ( - <ScreenHider - screenDescription={_(msg`list`)} - modui={moderation.ui('contentView')}> - <View style={s.hContentRegion}> - <PagerWithHeader - items={SECTION_TITLES_MOD} - isHeaderReady={true} - renderHeader={renderHeader}> - {({headerHeight, scrollElRef}) => ( - <AboutSection - list={list} - scrollElRef={scrollElRef as ListRef} - onPressAddUser={onPressAddUser} - headerHeight={headerHeight} - /> - )} - </PagerWithHeader> - <FAB - testID="composeFAB" - onPress={() => openComposer({})} - icon={ - <ComposeIcon2 - strokeWidth={1.5} - size={29} - style={{color: 'white'}} - /> - } - accessibilityRole="button" - accessibilityLabel={_(msg`New post`)} - accessibilityHint="" - /> - </View> - </ScreenHider> + </Hider.Content> + </Hider.Outer> ) } -function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { +function Header({ + rkey, + list, + preferences, +}: { + rkey: string + list: AppBskyGraphDefs.ListView + preferences: UsePreferencesQueryResponse +}) { const pal = usePalette('default') const palInverted = usePalette('inverted') const {_} = useLingui() @@ -283,7 +306,6 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { const isBlocking = !!list.viewer?.blocked const isMuting = !!list.viewer?.muted const isOwner = list.creator.did === currentAccount?.did - const {data: preferences} = usePreferencesQuery() const {track} = useAnalytics() const playHaptic = useHaptics() @@ -644,7 +666,7 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { cid: list.cid, }} /> - {isCurateList || isPinned ? ( + {isCurateList ? ( <Button testID={isPinned ? 'unpinBtn' : 'pinBtn'} type={isPinned ? 'default' : 'inverted'} |