diff options
author | Samuel Newman <mozzius@protonmail.com> | 2025-09-09 20:20:33 +0300 |
---|---|---|
committer | GitHub <noreply@github.com> | 2025-09-09 10:20:33 -0700 |
commit | 6432667f608fae447b59e41b9f8bb64b564205a1 (patch) | |
tree | f90e4d113aecf0eb528f8146919a1764ec94e056 /src/screens/ProfileList/components | |
parent | 4a1b1f17f46de9f8dde2766d61edc02c2267b14b (diff) | |
download | voidsky-6432667f608fae447b59e41b9f8bb64b564205a1.tar.zst |
ALF lists screen (#8941)
* alf list screens * relocate to `#/screens`, balkanize * use useBreakpoints * showCancel on subscribe menu * fix typo
Diffstat (limited to 'src/screens/ProfileList/components')
-rw-r--r-- | src/screens/ProfileList/components/ErrorScreen.tsx | 46 | ||||
-rw-r--r-- | src/screens/ProfileList/components/Header.tsx | 208 | ||||
-rw-r--r-- | src/screens/ProfileList/components/MoreOptionsMenu.tsx | 298 | ||||
-rw-r--r-- | src/screens/ProfileList/components/SubscribeMenu.tsx | 130 |
4 files changed, 682 insertions, 0 deletions
diff --git a/src/screens/ProfileList/components/ErrorScreen.tsx b/src/screens/ProfileList/components/ErrorScreen.tsx new file mode 100644 index 000000000..7ce343def --- /dev/null +++ b/src/screens/ProfileList/components/ErrorScreen.tsx @@ -0,0 +1,46 @@ +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useNavigation} from '@react-navigation/native' + +import {type NavigationProp} from '#/lib/routes/types' +import {atoms as a, useTheme} from '#/alf' +import {Button, ButtonText} from '#/components/Button' +import {Text} from '#/components/Typography' + +export function ErrorScreen({error}: {error: React.ReactNode}) { + const t = useTheme() + const navigation = useNavigation<NavigationProp>() + const {_} = useLingui() + const onPressBack = () => { + if (navigation.canGoBack()) { + navigation.goBack() + } else { + navigation.navigate('Home') + } + } + + return ( + <View style={[a.px_xl, a.py_md, a.gap_md]}> + <Text style={[a.text_4xl, a.font_heavy]}> + <Trans>Could not load list</Trans> + </Text> + <Text style={[a.text_md, t.atoms.text_contrast_high, a.leading_snug]}> + {error} + </Text> + + <View style={[a.flex_row, a.mt_lg]}> + <Button + label={_(msg`Go back`)} + accessibilityHint={_(msg`Returns to previous page`)} + onPress={onPressBack} + size="small" + color="secondary"> + <ButtonText> + <Trans>Go back</Trans> + </ButtonText> + </Button> + </View> + </View> + ) +} diff --git a/src/screens/ProfileList/components/Header.tsx b/src/screens/ProfileList/components/Header.tsx new file mode 100644 index 000000000..fe4b33c75 --- /dev/null +++ b/src/screens/ProfileList/components/Header.tsx @@ -0,0 +1,208 @@ +import {useMemo} from 'react' +import {View} from 'react-native' +import {AppBskyGraphDefs, RichText as RichTextAPI} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {useHaptics} from '#/lib/haptics' +import {makeListLink} from '#/lib/routes/links' +import {logger} from '#/logger' +import {useListBlockMutation, useListMuteMutation} from '#/state/queries/list' +import { + useAddSavedFeedsMutation, + type UsePreferencesQueryResponse, + useUpdateSavedFeedsMutation, +} from '#/state/queries/preferences' +import {useSession} from '#/state/session' +import {ProfileSubpageHeader} from '#/view/com/profile/ProfileSubpageHeader' +import {atoms as a} from '#/alf' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {Pin_Stroke2_Corner0_Rounded as PinIcon} from '#/components/icons/Pin' +import {Loader} from '#/components/Loader' +import {RichText} from '#/components/RichText' +import * as Toast from '#/components/Toast' +import {MoreOptionsMenu} from './MoreOptionsMenu' +import {SubscribeMenu} from './SubscribeMenu' + +export function Header({ + rkey, + list, + preferences, +}: { + rkey: string + list: AppBskyGraphDefs.ListView + preferences: UsePreferencesQueryResponse +}) { + const {_} = useLingui() + const {currentAccount} = useSession() + const isCurateList = list.purpose === AppBskyGraphDefs.CURATELIST + const isModList = list.purpose === AppBskyGraphDefs.MODLIST + const isBlocking = !!list.viewer?.blocked + const isMuting = !!list.viewer?.muted + const playHaptic = useHaptics() + + const {mutateAsync: muteList, isPending: isMutePending} = + useListMuteMutation() + const {mutateAsync: blockList, isPending: isBlockPending} = + useListBlockMutation() + const {mutateAsync: addSavedFeeds, isPending: isAddSavedFeedPending} = + useAddSavedFeedsMutation() + const {mutateAsync: updateSavedFeeds, isPending: isUpdatingSavedFeeds} = + useUpdateSavedFeedsMutation() + + const isPending = isAddSavedFeedPending || isUpdatingSavedFeeds + + const savedFeedConfig = preferences?.savedFeeds?.find( + f => f.value === list.uri, + ) + const isPinned = Boolean(savedFeedConfig?.pinned) + + const onTogglePinned = async () => { + playHaptic() + + try { + if (savedFeedConfig) { + const pinned = !savedFeedConfig.pinned + await updateSavedFeeds([ + { + ...savedFeedConfig, + pinned, + }, + ]) + Toast.show( + pinned + ? _(msg`Pinned to your feeds`) + : _(msg`Unpinned from your feeds`), + ) + } else { + await addSavedFeeds([ + { + type: 'list', + value: list.uri, + pinned: true, + }, + ]) + Toast.show(_(msg`Saved to your feeds`)) + } + } catch (e) { + Toast.show(_(msg`There was an issue contacting the server`), { + type: 'error', + }) + logger.error('Failed to toggle pinned feed', {message: e}) + } + } + + const onUnsubscribeMute = async () => { + try { + await muteList({uri: list.uri, mute: false}) + Toast.show(_(msg({message: 'List unmuted', context: 'toast'}))) + logger.metric( + 'moderation:unsubscribedFromList', + {listType: 'mute'}, + {statsig: true}, + ) + } catch { + Toast.show( + _( + msg`There was an issue. Please check your internet connection and try again.`, + ), + ) + } + } + + const onUnsubscribeBlock = async () => { + try { + await blockList({uri: list.uri, block: false}) + Toast.show(_(msg({message: 'List unblocked', context: 'toast'}))) + logger.metric( + 'moderation:unsubscribedFromList', + {listType: 'block'}, + {statsig: true}, + ) + } catch { + Toast.show( + _( + msg`There was an issue. Please check your internet connection and try again.`, + ), + ) + } + } + + const descriptionRT = useMemo( + () => + list.description + ? new RichTextAPI({ + text: list.description, + facets: list.descriptionFacets, + }) + : undefined, + [list], + ) + + return ( + <> + <ProfileSubpageHeader + href={makeListLink(list.creator.handle || list.creator.did || '', rkey)} + title={list.name} + avatar={list.avatar} + isOwner={list.creator.did === currentAccount?.did} + creator={list.creator} + purpose={list.purpose} + avatarType="list"> + {isCurateList ? ( + <Button + testID={isPinned ? 'unpinBtn' : 'pinBtn'} + color={isPinned ? 'secondary' : 'primary_subtle'} + label={isPinned ? _(msg`Unpin`) : _(msg`Pin to home`)} + onPress={onTogglePinned} + disabled={isPending} + size="small" + style={[a.rounded_full]}> + {!isPinned && <ButtonIcon icon={isPending ? Loader : PinIcon} />} + <ButtonText> + {isPinned ? <Trans>Unpin</Trans> : <Trans>Pin to home</Trans>} + </ButtonText> + </Button> + ) : isModList ? ( + isBlocking ? ( + <Button + testID="unblockBtn" + color="secondary" + label={_(msg`Unblock`)} + onPress={onUnsubscribeBlock} + size="small" + style={[a.rounded_full]} + disabled={isBlockPending}> + {isBlockPending && <ButtonIcon icon={Loader} />} + <ButtonText> + <Trans>Unblock</Trans> + </ButtonText> + </Button> + ) : isMuting ? ( + <Button + testID="unmuteBtn" + color="secondary" + label={_(msg`Unmute`)} + onPress={onUnsubscribeMute} + size="small" + style={[a.rounded_full]} + disabled={isMutePending}> + {isMutePending && <ButtonIcon icon={Loader} />} + <ButtonText> + <Trans>Unmute</Trans> + </ButtonText> + </Button> + ) : ( + <SubscribeMenu list={list} /> + ) + ) : null} + <MoreOptionsMenu list={list} /> + </ProfileSubpageHeader> + {descriptionRT ? ( + <View style={[a.px_lg, a.pt_sm, a.pb_sm, a.gap_md]}> + <RichText value={descriptionRT} style={[a.text_md, a.leading_snug]} /> + </View> + ) : null} + </> + ) +} diff --git a/src/screens/ProfileList/components/MoreOptionsMenu.tsx b/src/screens/ProfileList/components/MoreOptionsMenu.tsx new file mode 100644 index 000000000..17ca43a82 --- /dev/null +++ b/src/screens/ProfileList/components/MoreOptionsMenu.tsx @@ -0,0 +1,298 @@ +import {type AppBskyActorDefs, AppBskyGraphDefs, AtUri} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useNavigation} from '@react-navigation/native' + +import {type NavigationProp} from '#/lib/routes/types' +import {shareUrl} from '#/lib/sharing' +import {toShareUrl} from '#/lib/strings/url-helpers' +import {logger} from '#/logger' +import {isWeb} from '#/platform/detection' +import {useModalControls} from '#/state/modals' +import { + useListBlockMutation, + useListDeleteMutation, + useListMuteMutation, +} from '#/state/queries/list' +import {useRemoveFeedMutation} from '#/state/queries/preferences' +import {useSession} from '#/state/session' +import {Button, ButtonIcon} from '#/components/Button' +import {useDialogControl} from '#/components/Dialog' +import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as ShareIcon} from '#/components/icons/ArrowOutOfBox' +import {ChainLink_Stroke2_Corner0_Rounded as ChainLink} from '#/components/icons/ChainLink' +import {DotGrid_Stroke2_Corner0_Rounded as DotGridIcon} from '#/components/icons/DotGrid' +import {PencilLine_Stroke2_Corner0_Rounded as PencilLineIcon} from '#/components/icons/Pencil' +import {PersonCheck_Stroke2_Corner0_Rounded as PersonCheckIcon} from '#/components/icons/Person' +import {Pin_Stroke2_Corner0_Rounded as PinIcon} from '#/components/icons/Pin' +import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker' +import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' +import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning' +import * as Menu from '#/components/Menu' +import { + ReportDialog, + useReportDialogControl, +} from '#/components/moderation/ReportDialog' +import * as Prompt from '#/components/Prompt' +import * as Toast from '#/components/Toast' + +export function MoreOptionsMenu({ + list, + savedFeedConfig, +}: { + list: AppBskyGraphDefs.ListView + savedFeedConfig?: AppBskyActorDefs.SavedFeed +}) { + const {_} = useLingui() + const {currentAccount} = useSession() + const {openModal} = useModalControls() + const deleteListPromptControl = useDialogControl() + const reportDialogControl = useReportDialogControl() + const navigation = useNavigation<NavigationProp>() + + const {mutateAsync: removeSavedFeed} = useRemoveFeedMutation() + const {mutateAsync: deleteList} = useListDeleteMutation() + const {mutateAsync: muteList} = useListMuteMutation() + const {mutateAsync: blockList} = useListBlockMutation() + + const isCurateList = list.purpose === AppBskyGraphDefs.CURATELIST + const isModList = list.purpose === AppBskyGraphDefs.MODLIST + const isBlocking = !!list.viewer?.blocked + const isMuting = !!list.viewer?.muted + const isPinned = Boolean(savedFeedConfig?.pinned) + const isOwner = currentAccount?.did === list.creator.did + + const onPressShare = () => { + const {rkey} = new AtUri(list.uri) + const url = toShareUrl(`/profile/${list.creator.did}/lists/${rkey}`) + shareUrl(url) + } + + const onRemoveFromSavedFeeds = async () => { + if (!savedFeedConfig) return + try { + await removeSavedFeed(savedFeedConfig) + Toast.show(_(msg`Removed from your feeds`)) + } catch (e) { + Toast.show(_(msg`There was an issue contacting the server`), { + type: 'error', + }) + logger.error('Failed to remove pinned list', {message: e}) + } + } + + const onPressEdit = () => { + openModal({ + name: 'create-or-edit-list', + list, + }) + } + + const onPressDelete = async () => { + await deleteList({uri: list.uri}) + + if (savedFeedConfig) { + await removeSavedFeed(savedFeedConfig) + } + + Toast.show(_(msg({message: 'List deleted', context: 'toast'}))) + if (navigation.canGoBack()) { + navigation.goBack() + } else { + navigation.navigate('Home') + } + } + + const onUnpinModList = async () => { + try { + if (!savedFeedConfig) return + await removeSavedFeed(savedFeedConfig) + Toast.show(_(msg`Unpinned list`)) + } catch { + Toast.show(_(msg`Failed to unpin list`), { + type: 'error', + }) + } + } + + const onUnsubscribeMute = async () => { + try { + await muteList({uri: list.uri, mute: false}) + Toast.show(_(msg({message: 'List unmuted', context: 'toast'}))) + logger.metric( + 'moderation:unsubscribedFromList', + {listType: 'mute'}, + {statsig: true}, + ) + } catch { + Toast.show( + _( + msg`There was an issue. Please check your internet connection and try again.`, + ), + ) + } + } + + const onUnsubscribeBlock = async () => { + try { + await blockList({uri: list.uri, block: false}) + Toast.show(_(msg({message: 'List unblocked', context: 'toast'}))) + logger.metric( + 'moderation:unsubscribedFromList', + {listType: 'block'}, + {statsig: true}, + ) + } catch { + Toast.show( + _( + msg`There was an issue. Please check your internet connection and try again.`, + ), + ) + } + } + + return ( + <> + <Menu.Root> + <Menu.Trigger label={_(msg`More options`)}> + {({props}) => ( + <Button + label={props.accessibilityLabel} + testID="moreOptionsBtn" + size="small" + color="secondary" + shape="round" + {...props}> + <ButtonIcon icon={DotGridIcon} /> + </Button> + )} + </Menu.Trigger> + <Menu.Outer> + <Menu.Group> + <Menu.Item + label={isWeb ? _(msg`Copy link to list`) : _(msg`Share via...`)} + onPress={onPressShare}> + <Menu.ItemText> + {isWeb ? ( + <Trans>Copy link to list</Trans> + ) : ( + <Trans>Share via...</Trans> + )} + </Menu.ItemText> + <Menu.ItemIcon + position="right" + icon={isWeb ? ChainLink : ShareIcon} + /> + </Menu.Item> + {savedFeedConfig && ( + <Menu.Item + label={_(msg`Remove from my feeds`)} + onPress={onRemoveFromSavedFeeds}> + <Menu.ItemText> + <Trans>Remove from my feeds</Trans> + </Menu.ItemText> + <Menu.ItemIcon position="right" icon={TrashIcon} /> + </Menu.Item> + )} + </Menu.Group> + + <Menu.Divider /> + + {isOwner ? ( + <Menu.Group> + <Menu.Item + label={_(msg`Edit list details`)} + onPress={onPressEdit}> + <Menu.ItemText> + <Trans>Edit list details</Trans> + </Menu.ItemText> + <Menu.ItemIcon position="right" icon={PencilLineIcon} /> + </Menu.Item> + <Menu.Item + label={_(msg`Delete list`)} + onPress={deleteListPromptControl.open}> + <Menu.ItemText> + <Trans>Delete list</Trans> + </Menu.ItemText> + <Menu.ItemIcon position="right" icon={TrashIcon} /> + </Menu.Item> + </Menu.Group> + ) : ( + <Menu.Group> + <Menu.Item + label={_(msg`Report list`)} + onPress={reportDialogControl.open}> + <Menu.ItemText> + <Trans>Report list</Trans> + </Menu.ItemText> + <Menu.ItemIcon position="right" icon={WarningIcon} /> + </Menu.Item> + </Menu.Group> + )} + + {isModList && isPinned && ( + <> + <Menu.Divider /> + <Menu.Group> + <Menu.Item + label={_(msg`Unpin moderation list`)} + onPress={onUnpinModList}> + <Menu.ItemText> + <Trans>Unpin moderation list</Trans> + </Menu.ItemText> + <Menu.ItemIcon icon={PinIcon} /> + </Menu.Item> + </Menu.Group> + </> + )} + + {isCurateList && (isBlocking || isMuting) && ( + <> + <Menu.Divider /> + <Menu.Group> + {isBlocking && ( + <Menu.Item + label={_(msg`Unblock list`)} + onPress={onUnsubscribeBlock}> + <Menu.ItemText> + <Trans>Unblock list</Trans> + </Menu.ItemText> + <Menu.ItemIcon icon={PersonCheckIcon} /> + </Menu.Item> + )} + {isMuting && ( + <Menu.Item + label={_(msg`Unmute list`)} + onPress={onUnsubscribeMute}> + <Menu.ItemText> + <Trans>Unmute list</Trans> + </Menu.ItemText> + <Menu.ItemIcon icon={UnmuteIcon} /> + </Menu.Item> + )} + </Menu.Group> + </> + )} + </Menu.Outer> + </Menu.Root> + + <Prompt.Basic + control={deleteListPromptControl} + title={_(msg`Delete this list?`)} + description={_( + msg`If you delete this list, you won't be able to recover it.`, + )} + onConfirm={onPressDelete} + confirmButtonCta={_(msg`Delete`)} + confirmButtonColor="negative" + /> + + <ReportDialog + control={reportDialogControl} + subject={{ + ...list, + $type: 'app.bsky.graph.defs#listView', + }} + /> + </> + ) +} diff --git a/src/screens/ProfileList/components/SubscribeMenu.tsx b/src/screens/ProfileList/components/SubscribeMenu.tsx new file mode 100644 index 000000000..5b6b9ba09 --- /dev/null +++ b/src/screens/ProfileList/components/SubscribeMenu.tsx @@ -0,0 +1,130 @@ +import {type AppBskyGraphDefs} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {logger} from '#/logger' +import {useListBlockMutation, useListMuteMutation} from '#/state/queries/list' +import {atoms as a} from '#/alf' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute' +import {PersonX_Stroke2_Corner0_Rounded as PersonXIcon} from '#/components/icons/Person' +import {Loader} from '#/components/Loader' +import * as Menu from '#/components/Menu' +import * as Prompt from '#/components/Prompt' +import * as Toast from '#/components/Toast' + +export function SubscribeMenu({list}: {list: AppBskyGraphDefs.ListView}) { + const {_} = useLingui() + const subscribeMutePromptControl = Prompt.usePromptControl() + const subscribeBlockPromptControl = Prompt.usePromptControl() + + const {mutateAsync: muteList, isPending: isMutePending} = + useListMuteMutation() + const {mutateAsync: blockList, isPending: isBlockPending} = + useListBlockMutation() + + const isPending = isMutePending || isBlockPending + + const onSubscribeMute = async () => { + try { + await muteList({uri: list.uri, mute: true}) + Toast.show(_(msg({message: 'List muted', context: 'toast'}))) + logger.metric( + 'moderation:subscribedToList', + {listType: 'mute'}, + {statsig: true}, + ) + } catch { + Toast.show( + _( + msg`There was an issue. Please check your internet connection and try again.`, + ), + {type: 'error'}, + ) + } + } + + const onSubscribeBlock = async () => { + try { + await blockList({uri: list.uri, block: true}) + Toast.show(_(msg({message: 'List blocked', context: 'toast'}))) + logger.metric( + 'moderation:subscribedToList', + {listType: 'block'}, + {statsig: true}, + ) + } catch { + Toast.show( + _( + msg`There was an issue. Please check your internet connection and try again.`, + ), + {type: 'error'}, + ) + } + } + + return ( + <> + <Menu.Root> + <Menu.Trigger label={_(msg`Subscribe to this list`)}> + {({props}) => ( + <Button + label={props.accessibilityLabel} + testID="subscribeBtn" + size="small" + color="primary_subtle" + style={[a.rounded_full]} + disabled={isPending} + {...props}> + {isPending && <ButtonIcon icon={Loader} />} + <ButtonText> + <Trans>Subscribe</Trans> + </ButtonText> + </Button> + )} + </Menu.Trigger> + <Menu.Outer showCancel> + <Menu.Group> + <Menu.Item + label={_(msg`Mute accounts`)} + onPress={subscribeMutePromptControl.open}> + <Menu.ItemText> + <Trans>Mute accounts</Trans> + </Menu.ItemText> + <Menu.ItemIcon position="right" icon={MuteIcon} /> + </Menu.Item> + <Menu.Item + label={_(msg`Block accounts`)} + onPress={subscribeBlockPromptControl.open}> + <Menu.ItemText> + <Trans>Block accounts</Trans> + </Menu.ItemText> + <Menu.ItemIcon position="right" icon={PersonXIcon} /> + </Menu.Item> + </Menu.Group> + </Menu.Outer> + </Menu.Root> + + <Prompt.Basic + control={subscribeMutePromptControl} + title={_(msg`Mute these accounts?`)} + description={_( + msg`Muting is private. Muted accounts can interact with you, but you will not see their posts or receive notifications from them.`, + )} + onConfirm={onSubscribeMute} + confirmButtonCta={_(msg`Mute list`)} + /> + + <Prompt.Basic + control={subscribeBlockPromptControl} + title={_(msg`Block these accounts?`)} + description={_( + msg`Blocking is public. Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`, + )} + onConfirm={onSubscribeBlock} + confirmButtonCta={_(msg`Block list`)} + confirmButtonColor="negative" + /> + </> + ) +} |