diff options
Diffstat (limited to 'src/view/screens')
27 files changed, 1991 insertions, 1157 deletions
diff --git a/src/view/screens/AppPasswords.tsx b/src/view/screens/AppPasswords.tsx index dc439c367..800216169 100644 --- a/src/view/screens/AppPasswords.tsx +++ b/src/view/screens/AppPasswords.tsx @@ -29,6 +29,8 @@ import { } from '#/state/queries/app-passwords' import {ErrorScreen} from '../com/util/error/ErrorScreen' import {cleanError} from '#/lib/strings/errors' +import * as Prompt from '#/components/Prompt' +import {useDialogControl} from '#/components/Dialog' type Props = NativeStackScreenProps<CommonNavigatorParams, 'AppPasswords'> export function AppPasswords({}: Props) { @@ -212,23 +214,18 @@ function AppPassword({ }) { const pal = usePalette('default') const {_} = useLingui() - const {openModal} = useModalControls() + const control = useDialogControl() const {contentLanguages} = useLanguagePrefs() const deleteMutation = useAppPasswordDeleteMutation() const onDelete = React.useCallback(async () => { - openModal({ - name: 'confirm', - title: _(msg`Delete app password`), - message: _( - msg`Are you sure you want to delete the app password "${name}"?`, - ), - async onPressConfirm() { - await deleteMutation.mutateAsync({name}) - Toast.show(_(msg`App password deleted`)) - }, - }) - }, [deleteMutation, openModal, name, _]) + await deleteMutation.mutateAsync({name}) + Toast.show(_(msg`App password deleted`)) + }, [deleteMutation, name, _]) + + const onPress = React.useCallback(() => { + control.open() + }, [control]) const primaryLocale = contentLanguages.length > 0 ? contentLanguages[0] : 'en-US' @@ -237,7 +234,7 @@ function AppPassword({ <TouchableOpacity testID={testID} style={[styles.item, pal.border]} - onPress={onDelete} + onPress={onPress} accessibilityRole="button" accessibilityLabel={_(msg`Delete app password`)} accessibilityHint=""> @@ -260,6 +257,17 @@ function AppPassword({ </Text> </View> <FontAwesomeIcon icon={['far', 'trash-can']} style={styles.trashIcon} /> + + <Prompt.Basic + control={control} + title={_(msg`Delete app password?`)} + description={_( + msg`Are you sure you want to delete the app password "${name}"?`, + )} + onConfirm={onDelete} + confirmButtonCta={_(msg`Delete`)} + confirmButtonColor="negative" + /> </TouchableOpacity> ) } diff --git a/src/view/screens/DebugMod.tsx b/src/view/screens/DebugMod.tsx new file mode 100644 index 000000000..64f2376a4 --- /dev/null +++ b/src/view/screens/DebugMod.tsx @@ -0,0 +1,923 @@ +import React from 'react' +import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' +import {View} from 'react-native' +import { + LABELS, + mock, + moderatePost, + moderateProfile, + ModerationOpts, + AppBskyActorDefs, + AppBskyFeedDefs, + AppBskyFeedPost, + LabelPreference, + ModerationDecision, + ModerationBehavior, + RichText, + ComAtprotoLabelDefs, + interpretLabelValueDefinition, +} from '@atproto/api' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {moderationOptsOverrideContext} from '#/state/queries/preferences' +import {useSession} from '#/state/session' +import {FeedNotification} from '#/state/queries/notifications/types' +import { + groupNotifications, + shouldFilterNotif, +} from '#/state/queries/notifications/util' + +import {atoms as a, useTheme} from '#/alf' +import {CenteredView, ScrollView} from '#/view/com/util/Views' +import {H1, H3, P, Text} from '#/components/Typography' +import {useGlobalLabelStrings} from '#/lib/moderation/useGlobalLabelStrings' +import * as Toggle from '#/components/forms/Toggle' +import * as ToggleButton from '#/components/forms/ToggleButton' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' +import { + ChevronBottom_Stroke2_Corner0_Rounded as ChevronBottom, + ChevronTop_Stroke2_Corner0_Rounded as ChevronTop, +} from '#/components/icons/Chevron' +import {ScreenHider} from '../../components/moderation/ScreenHider' +import {ProfileHeaderStandard} from '#/screens/Profile/Header/ProfileHeaderStandard' +import {ProfileCard} from '../com/profile/ProfileCard' +import {FeedItem} from '../com/posts/FeedItem' +import {FeedItem as NotifFeedItem} from '../com/notifications/FeedItem' +import {PostThreadItem} from '../com/post-thread/PostThreadItem' +import {Divider} from '#/components/Divider' + +const LABEL_VALUES: (keyof typeof LABELS)[] = Object.keys( + LABELS, +) as (keyof typeof LABELS)[] + +export const DebugModScreen = ({}: NativeStackScreenProps< + CommonNavigatorParams, + 'DebugMod' +>) => { + const t = useTheme() + const [scenario, setScenario] = React.useState<string[]>(['label']) + const [scenarioSwitches, setScenarioSwitches] = React.useState<string[]>([]) + const [label, setLabel] = React.useState<string[]>([LABEL_VALUES[0]]) + const [target, setTarget] = React.useState<string[]>(['account']) + const [visibility, setVisiblity] = React.useState<string[]>(['warn']) + const [customLabelDef, setCustomLabelDef] = + React.useState<ComAtprotoLabelDefs.LabelValueDefinition>({ + identifier: 'custom', + blurs: 'content', + severity: 'alert', + defaultSetting: 'warn', + locales: [ + { + lang: 'en', + name: 'Custom label', + description: 'A custom label created in this test environment', + }, + ], + }) + const [view, setView] = React.useState<string[]>(['post']) + const labelStrings = useGlobalLabelStrings() + const {currentAccount} = useSession() + + const isTargetMe = + scenario[0] === 'label' && scenarioSwitches.includes('targetMe') + const isSelfLabel = + scenario[0] === 'label' && scenarioSwitches.includes('selfLabel') + const noAdult = + scenario[0] === 'label' && scenarioSwitches.includes('noAdult') + const isLoggedOut = + scenario[0] === 'label' && scenarioSwitches.includes('loggedOut') + const isFollowing = scenarioSwitches.includes('following') + + const did = + isTargetMe && currentAccount ? currentAccount.did : 'did:web:bob.test' + + const profile = React.useMemo(() => { + const mockedProfile = mock.profileViewBasic({ + handle: `bob.test`, + displayName: 'Bob Robertson', + description: 'User with this as their bio', + labels: + scenario[0] === 'label' && target[0] === 'account' + ? [ + mock.label({ + src: isSelfLabel ? did : undefined, + val: label[0], + uri: `at://${did}/`, + }), + ] + : scenario[0] === 'label' && target[0] === 'profile' + ? [ + mock.label({ + src: isSelfLabel ? did : undefined, + val: label[0], + uri: `at://${did}/app.bsky.actor.profile/self`, + }), + ] + : undefined, + viewer: mock.actorViewerState({ + following: isFollowing + ? `at://${currentAccount?.did || ''}/app.bsky.graph.follow/1234` + : undefined, + muted: scenario[0] === 'mute', + mutedByList: undefined, + blockedBy: undefined, + blocking: + scenario[0] === 'block' + ? `at://did:web:alice.test/app.bsky.actor.block/fake` + : undefined, + blockingByList: undefined, + }), + }) + mockedProfile.did = did + mockedProfile.avatar = 'https://bsky.social/about/images/favicon-32x32.png' + mockedProfile.banner = + 'https://bsky.social/about/images/social-card-default-gradient.png' + return mockedProfile + }, [scenario, target, label, isSelfLabel, did, isFollowing, currentAccount]) + + const post = React.useMemo(() => { + return mock.postView({ + record: mock.post({ + text: "This is the body of the post. It's where the text goes. You get the idea.", + }), + author: profile, + labels: + scenario[0] === 'label' && target[0] === 'post' + ? [ + mock.label({ + src: isSelfLabel ? did : undefined, + val: label[0], + uri: `at://${did}/app.bsky.feed.post/fake`, + }), + ] + : undefined, + embed: + target[0] === 'embed' + ? mock.embedRecordView({ + record: mock.post({ + text: 'Embed', + }), + labels: + scenario[0] === 'label' && target[0] === 'embed' + ? [ + mock.label({ + src: isSelfLabel ? did : undefined, + val: label[0], + uri: `at://${did}/app.bsky.feed.post/fake`, + }), + ] + : undefined, + author: profile, + }) + : { + $type: 'app.bsky.embed.images#view', + images: [ + { + thumb: + 'https://bsky.social/about/images/social-card-default-gradient.png', + fullsize: + 'https://bsky.social/about/images/social-card-default-gradient.png', + alt: '', + }, + ], + }, + }) + }, [scenario, label, target, profile, isSelfLabel, did]) + + const replyNotif = React.useMemo(() => { + const notif = mock.replyNotification({ + record: mock.post({ + text: "This is the body of the post. It's where the text goes. You get the idea.", + reply: { + parent: { + uri: `at://${did}/app.bsky.feed.post/fake-parent`, + cid: 'bafyreiclp443lavogvhj3d2ob2cxbfuscni2k5jk7bebjzg7khl3esabwq', + }, + root: { + uri: `at://${did}/app.bsky.feed.post/fake-parent`, + cid: 'bafyreiclp443lavogvhj3d2ob2cxbfuscni2k5jk7bebjzg7khl3esabwq', + }, + }, + }), + author: profile, + labels: + scenario[0] === 'label' && target[0] === 'post' + ? [ + mock.label({ + src: isSelfLabel ? did : undefined, + val: label[0], + uri: `at://${did}/app.bsky.feed.post/fake`, + }), + ] + : undefined, + }) + const [item] = groupNotifications([notif]) + item.subject = mock.postView({ + record: notif.record as AppBskyFeedPost.Record, + author: profile, + labels: notif.labels, + }) + return item + }, [scenario, label, target, profile, isSelfLabel, did]) + + const followNotif = React.useMemo(() => { + const notif = mock.followNotification({ + author: profile, + subjectDid: currentAccount?.did || '', + }) + const [item] = groupNotifications([notif]) + return item + }, [profile, currentAccount]) + + const modOpts = React.useMemo(() => { + return { + userDid: isLoggedOut ? '' : isTargetMe ? did : 'did:web:alice.test', + prefs: { + adultContentEnabled: !noAdult, + labels: { + [label[0]]: visibility[0] as LabelPreference, + }, + labelers: [ + { + did: 'did:plc:fake-labeler', + labels: {[label[0]]: visibility[0] as LabelPreference}, + }, + ], + mutedWords: [], + hiddenPosts: [], + }, + labelDefs: { + 'did:plc:fake-labeler': [ + interpretLabelValueDefinition(customLabelDef, 'did:plc:fake-labeler'), + ], + }, + } + }, [label, visibility, noAdult, isLoggedOut, isTargetMe, did, customLabelDef]) + + const profileModeration = React.useMemo(() => { + return moderateProfile(profile, modOpts) + }, [profile, modOpts]) + const postModeration = React.useMemo(() => { + return moderatePost(post, modOpts) + }, [post, modOpts]) + + return ( + <moderationOptsOverrideContext.Provider value={modOpts}> + <ScrollView> + <CenteredView style={[t.atoms.bg, a.px_lg, a.py_lg]}> + <H1 style={[a.text_5xl, a.font_bold, a.pb_lg]}>Moderation states</H1> + + <Heading title="" subtitle="Scenario" /> + <ToggleButton.Group + label="Scenario" + values={scenario} + onChange={setScenario}> + <ToggleButton.Button name="label" label="Label"> + Label + </ToggleButton.Button> + <ToggleButton.Button name="block" label="Block"> + Block + </ToggleButton.Button> + <ToggleButton.Button name="mute" label="Mute"> + Mute + </ToggleButton.Button> + </ToggleButton.Group> + + {scenario[0] === 'label' && ( + <> + <View + style={[ + a.border, + a.rounded_sm, + a.mt_lg, + a.mb_lg, + a.p_lg, + t.atoms.border_contrast_medium, + ]}> + <Toggle.Group + label="Toggle" + type="radio" + values={label} + onChange={setLabel}> + <View style={[a.flex_row, a.gap_md, a.flex_wrap]}> + {LABEL_VALUES.map(labelValue => { + let targetFixed = target[0] + if ( + targetFixed !== 'account' && + targetFixed !== 'profile' + ) { + targetFixed = 'content' + } + const disabled = + isSelfLabel && + LABELS[labelValue].flags.includes('no-self') + return ( + <Toggle.Item + key={labelValue} + name={labelValue} + label={labelStrings[labelValue].name} + disabled={disabled} + style={disabled ? {opacity: 0.5} : undefined}> + <Toggle.Radio /> + <Toggle.Label>{labelValue}</Toggle.Label> + </Toggle.Item> + ) + })} + <Toggle.Item + name="custom" + label="Custom label" + disabled={isSelfLabel} + style={isSelfLabel ? {opacity: 0.5} : undefined}> + <Toggle.Radio /> + <Toggle.Label>Custom label</Toggle.Label> + </Toggle.Item> + </View> + </Toggle.Group> + + {label[0] === 'custom' ? ( + <CustomLabelForm + def={customLabelDef} + setDef={setCustomLabelDef} + /> + ) : ( + <> + <View style={{height: 10}} /> + <Divider /> + </> + )} + + <View style={{height: 10}} /> + + <SmallToggler label="Advanced"> + <Toggle.Group + label="Toggle" + type="checkbox" + values={scenarioSwitches} + onChange={setScenarioSwitches}> + <View style={[a.gap_md, a.flex_row, a.flex_wrap, a.pt_md]}> + <Toggle.Item name="targetMe" label="Target is me"> + <Toggle.Checkbox /> + <Toggle.Label>Target is me</Toggle.Label> + </Toggle.Item> + <Toggle.Item name="following" label="Following target"> + <Toggle.Checkbox /> + <Toggle.Label>Following target</Toggle.Label> + </Toggle.Item> + <Toggle.Item name="selfLabel" label="Self label"> + <Toggle.Checkbox /> + <Toggle.Label>Self label</Toggle.Label> + </Toggle.Item> + <Toggle.Item name="noAdult" label="Adult disabled"> + <Toggle.Checkbox /> + <Toggle.Label>Adult disabled</Toggle.Label> + </Toggle.Item> + <Toggle.Item name="loggedOut" label="Logged out"> + <Toggle.Checkbox /> + <Toggle.Label>Logged out</Toggle.Label> + </Toggle.Item> + </View> + </Toggle.Group> + + {LABELS[label[0] as keyof typeof LABELS]?.configurable !== + false && ( + <View style={[a.mt_md]}> + <Text + style={[a.font_bold, a.text_xs, t.atoms.text, a.pb_sm]}> + Preference + </Text> + <Toggle.Group + label="Preference" + type="radio" + values={visibility} + onChange={setVisiblity}> + <View + style={[ + a.flex_row, + a.gap_md, + a.flex_wrap, + a.align_center, + ]}> + <Toggle.Item name="hide" label="Hide"> + <Toggle.Radio /> + <Toggle.Label>Hide</Toggle.Label> + </Toggle.Item> + <Toggle.Item name="warn" label="Warn"> + <Toggle.Radio /> + <Toggle.Label>Warn</Toggle.Label> + </Toggle.Item> + <Toggle.Item name="ignore" label="Ignore"> + <Toggle.Radio /> + <Toggle.Label>Ignore</Toggle.Label> + </Toggle.Item> + </View> + </Toggle.Group> + </View> + )} + </SmallToggler> + </View> + + <View style={[a.flex_row, a.flex_wrap, a.gap_md]}> + <View> + <Text + style={[ + a.font_bold, + a.text_xs, + t.atoms.text, + a.pl_md, + a.pb_xs, + ]}> + Target + </Text> + <View + style={[ + a.border, + a.rounded_full, + a.px_md, + a.py_sm, + t.atoms.border_contrast_medium, + t.atoms.bg, + ]}> + <Toggle.Group + label="Target" + type="radio" + values={target} + onChange={setTarget}> + <View style={[a.flex_row, a.gap_md, a.flex_wrap]}> + <Toggle.Item name="account" label="Account"> + <Toggle.Radio /> + <Toggle.Label>Account</Toggle.Label> + </Toggle.Item> + <Toggle.Item name="profile" label="Profile"> + <Toggle.Radio /> + <Toggle.Label>Profile</Toggle.Label> + </Toggle.Item> + <Toggle.Item name="post" label="Post"> + <Toggle.Radio /> + <Toggle.Label>Post</Toggle.Label> + </Toggle.Item> + <Toggle.Item name="embed" label="Embed"> + <Toggle.Radio /> + <Toggle.Label>Embed</Toggle.Label> + </Toggle.Item> + </View> + </Toggle.Group> + </View> + </View> + </View> + </> + )} + + <Spacer /> + + <Heading title="" subtitle="Results" /> + + <ToggleButton.Group label="Results" values={view} onChange={setView}> + <ToggleButton.Button name="post" label="Post"> + Post + </ToggleButton.Button> + <ToggleButton.Button name="notifications" label="Notifications"> + Notifications + </ToggleButton.Button> + <ToggleButton.Button name="account" label="Account"> + Account + </ToggleButton.Button> + <ToggleButton.Button name="data" label="Data"> + Data + </ToggleButton.Button> + </ToggleButton.Group> + + <View + style={[ + a.border, + a.rounded_sm, + a.mt_lg, + a.p_md, + t.atoms.border_contrast_medium, + ]}> + {view[0] === 'post' && ( + <> + <Heading title="Post" subtitle="in feed" /> + <MockPostFeedItem post={post} moderation={postModeration} /> + + <Heading title="Post" subtitle="viewed directly" /> + <MockPostThreadItem post={post} moderation={postModeration} /> + + <Heading title="Post" subtitle="reply in thread" /> + <MockPostThreadItem + post={post} + moderation={postModeration} + reply + /> + </> + )} + + {view[0] === 'notifications' && ( + <> + <Heading title="Notification" subtitle="quote or reply" /> + <MockNotifItem notif={replyNotif} moderationOpts={modOpts} /> + <View style={{height: 20}} /> + <Heading title="Notification" subtitle="follow or like" /> + <MockNotifItem notif={followNotif} moderationOpts={modOpts} /> + </> + )} + + {view[0] === 'account' && ( + <> + <Heading title="Account" subtitle="in listing" /> + <MockAccountCard + profile={profile} + moderation={profileModeration} + /> + + <Heading title="Account" subtitle="viewing directly" /> + <MockAccountScreen + profile={profile} + moderation={profileModeration} + moderationOpts={modOpts} + /> + </> + )} + + {view[0] === 'data' && ( + <> + <ModerationUIView + label="Profile Moderation UI" + mod={profileModeration} + /> + <ModerationUIView + label="Post Moderation UI" + mod={postModeration} + /> + <DataView + label={label[0]} + data={LABELS[label[0] as keyof typeof LABELS]} + /> + <DataView + label="Profile Moderation Data" + data={profileModeration} + /> + <DataView label="Post Moderation Data" data={postModeration} /> + </> + )} + </View> + + <View style={{height: 400}} /> + </CenteredView> + </ScrollView> + </moderationOptsOverrideContext.Provider> + ) +} + +function Heading({title, subtitle}: {title: string; subtitle?: string}) { + const t = useTheme() + return ( + <H3 style={[a.text_3xl, a.font_bold, a.pb_md]}> + {title}{' '} + {!!subtitle && ( + <H3 style={[t.atoms.text_contrast_medium, a.text_lg]}>{subtitle}</H3> + )} + </H3> + ) +} + +function CustomLabelForm({ + def, + setDef, +}: { + def: ComAtprotoLabelDefs.LabelValueDefinition + setDef: React.Dispatch< + React.SetStateAction<ComAtprotoLabelDefs.LabelValueDefinition> + > +}) { + const t = useTheme() + return ( + <View + style={[ + a.flex_row, + a.flex_wrap, + a.gap_md, + t.atoms.bg_contrast_25, + a.rounded_md, + a.p_md, + a.mt_md, + ]}> + <View> + <Text style={[a.font_bold, a.text_xs, t.atoms.text, a.pl_md, a.pb_xs]}> + Blurs + </Text> + <View + style={[ + a.border, + a.rounded_full, + a.px_md, + a.py_sm, + t.atoms.border_contrast_medium, + t.atoms.bg, + ]}> + <Toggle.Group + label="Blurs" + type="radio" + values={[def.blurs]} + onChange={values => setDef(v => ({...v, blurs: values[0]}))}> + <View style={[a.flex_row, a.gap_md, a.flex_wrap]}> + <Toggle.Item name="content" label="Content"> + <Toggle.Radio /> + <Toggle.Label>Content</Toggle.Label> + </Toggle.Item> + <Toggle.Item name="media" label="Media"> + <Toggle.Radio /> + <Toggle.Label>Media</Toggle.Label> + </Toggle.Item> + <Toggle.Item name="none" label="None"> + <Toggle.Radio /> + <Toggle.Label>None</Toggle.Label> + </Toggle.Item> + </View> + </Toggle.Group> + </View> + </View> + <View> + <Text style={[a.font_bold, a.text_xs, t.atoms.text, a.pl_md, a.pb_xs]}> + Severity + </Text> + <View + style={[ + a.border, + a.rounded_full, + a.px_md, + a.py_sm, + t.atoms.border_contrast_medium, + t.atoms.bg, + ]}> + <Toggle.Group + label="Severity" + type="radio" + values={[def.severity]} + onChange={values => setDef(v => ({...v, severity: values[0]}))}> + <View style={[a.flex_row, a.gap_md, a.flex_wrap, a.align_center]}> + <Toggle.Item name="alert" label="Alert"> + <Toggle.Radio /> + <Toggle.Label>Alert</Toggle.Label> + </Toggle.Item> + <Toggle.Item name="inform" label="Inform"> + <Toggle.Radio /> + <Toggle.Label>Inform</Toggle.Label> + </Toggle.Item> + <Toggle.Item name="none" label="None"> + <Toggle.Radio /> + <Toggle.Label>None</Toggle.Label> + </Toggle.Item> + </View> + </Toggle.Group> + </View> + </View> + </View> + ) +} + +function Toggler({label, children}: React.PropsWithChildren<{label: string}>) { + const t = useTheme() + const [show, setShow] = React.useState(false) + return ( + <View style={a.mb_md}> + <View + style={[ + t.atoms.border_contrast_medium, + a.border, + a.rounded_sm, + a.p_xs, + ]}> + <Button + variant="solid" + color="secondary" + label="Toggle visibility" + size="small" + onPress={() => setShow(!show)}> + <ButtonText>{label}</ButtonText> + <ButtonIcon + icon={show ? ChevronTop : ChevronBottom} + position="right" + /> + </Button> + {show && children} + </View> + </View> + ) +} + +function SmallToggler({ + label, + children, +}: React.PropsWithChildren<{label: string}>) { + const [show, setShow] = React.useState(false) + return ( + <View> + <View style={[a.flex_row]}> + <Button + variant="ghost" + color="secondary" + label="Toggle visibility" + size="tiny" + onPress={() => setShow(!show)}> + <ButtonText>{label}</ButtonText> + <ButtonIcon + icon={show ? ChevronTop : ChevronBottom} + position="right" + /> + </Button> + </View> + {show && children} + </View> + ) +} + +function DataView({label, data}: {label: string; data: any}) { + return ( + <Toggler label={label}> + <Text style={[{fontFamily: 'monospace'}, a.p_md]}> + {JSON.stringify(data, null, 2)} + </Text> + </Toggler> + ) +} + +function ModerationUIView({ + mod, + label, +}: { + mod: ModerationDecision + label: string +}) { + return ( + <Toggler label={label}> + <View style={a.p_lg}> + {[ + 'profileList', + 'profileView', + 'avatar', + 'banner', + 'displayName', + 'contentList', + 'contentView', + 'contentMedia', + ].map(key => { + const ui = mod.ui(key as keyof ModerationBehavior) + return ( + <View key={key} style={[a.flex_row, a.gap_md]}> + <Text style={[a.font_bold, {width: 100}]}>{key}</Text> + <Flag v={ui.filter} label="Filter" /> + <Flag v={ui.blur} label="Blur" /> + <Flag v={ui.alert} label="Alert" /> + <Flag v={ui.inform} label="Inform" /> + <Flag v={ui.noOverride} label="No-override" /> + </View> + ) + })} + </View> + </Toggler> + ) +} + +function Spacer() { + return <View style={{height: 30}} /> +} + +function MockPostFeedItem({ + post, + moderation, +}: { + post: AppBskyFeedDefs.PostView + moderation: ModerationDecision +}) { + const t = useTheme() + if (moderation.ui('contentList').filter) { + return ( + <P style={[t.atoms.bg_contrast_25, a.px_lg, a.py_md, a.mb_lg]}> + Filtered from the feed + </P> + ) + } + return ( + <FeedItem + post={post} + record={post.record as AppBskyFeedPost.Record} + moderation={moderation} + reason={undefined} + /> + ) +} + +function MockPostThreadItem({ + post, + reply, +}: { + post: AppBskyFeedDefs.PostView + moderation: ModerationDecision + reply?: boolean +}) { + return ( + <PostThreadItem + // @ts-ignore + post={post} + record={post.record as AppBskyFeedPost.Record} + depth={reply ? 1 : 0} + isHighlightedPost={!reply} + treeView={false} + prevPost={undefined} + nextPost={undefined} + hasPrecedingItem={false} + onPostReply={() => {}} + /> + ) +} + +function MockNotifItem({ + notif, + moderationOpts, +}: { + notif: FeedNotification + moderationOpts: ModerationOpts +}) { + const t = useTheme() + if (shouldFilterNotif(notif.notification, moderationOpts)) { + return ( + <P style={[t.atoms.bg_contrast_25, a.px_lg, a.py_md]}> + Filtered from the feed + </P> + ) + } + return <NotifFeedItem item={notif} moderationOpts={moderationOpts} /> +} + +function MockAccountCard({ + profile, + moderation, +}: { + profile: AppBskyActorDefs.ProfileViewBasic + moderation: ModerationDecision +}) { + const t = useTheme() + + if (moderation.ui('profileList').filter) { + return ( + <P style={[t.atoms.bg_contrast_25, a.px_lg, a.py_md, a.mb_lg]}> + Filtered from the listing + </P> + ) + } + + return <ProfileCard profile={profile} /> +} + +function MockAccountScreen({ + profile, + moderation, + moderationOpts, +}: { + profile: AppBskyActorDefs.ProfileViewBasic + moderation: ModerationDecision + moderationOpts: ModerationOpts +}) { + const t = useTheme() + const {_} = useLingui() + return ( + <View style={[t.atoms.border_contrast_medium, a.border, a.mb_md]}> + <ScreenHider + style={{}} + screenDescription={_(msg`profile`)} + modui={moderation.ui('profileView')}> + <ProfileHeaderStandard + // @ts-ignore ProfileViewBasic is close enough -prf + profile={profile} + moderationOpts={moderationOpts} + descriptionRT={new RichText({text: profile.description as string})} + /> + </ScreenHider> + </View> + ) +} + +function Flag({v, label}: {v: boolean | undefined; label: string}) { + const t = useTheme() + return ( + <View style={[a.flex_row, a.align_center, a.gap_xs]}> + <View + style={[ + a.justify_center, + a.align_center, + a.rounded_xs, + a.border, + t.atoms.border_contrast_medium, + { + backgroundColor: t.palette.contrast_25, + width: 14, + height: 14, + }, + ]}> + {v && <Check size="xs" fill={t.palette.contrast_900} />} + </View> + <P style={a.text_xs}>{label}</P> + </View> + ) +} diff --git a/src/view/screens/Feeds.tsx b/src/view/screens/Feeds.tsx index 7216fd109..2e3bf08db 100644 --- a/src/view/screens/Feeds.tsx +++ b/src/view/screens/Feeds.tsx @@ -16,6 +16,7 @@ import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {ComposeIcon2, CogIcon, MagnifyingGlassIcon2} from 'lib/icons' import {s} from 'lib/styles' +import {atoms as a, useTheme} from '#/alf' import {SearchInput, SearchInputRef} from 'view/com/util/forms/SearchInput' import {UserAvatar} from 'view/com/util/UserAvatar' import { @@ -41,8 +42,11 @@ import { import {cleanError} from 'lib/strings/errors' import {useComposerControls} from '#/state/shell/composer' import {useSession} from '#/state/session' -import {isNative} from '#/platform/detection' +import {isNative, isWeb} from '#/platform/detection' import {HITSLOP_10} from 'lib/constants' +import {IconCircle} from '#/components/IconCircle' +import {ListSparkle_Stroke2_Corner0_Rounded} from '#/components/icons/ListSparkle' +import {ListMagnifyingGlass_Stroke2_Corner0_Rounded} from '#/components/icons/ListMagnifyingGlass' type Props = NativeStackScreenProps<FeedsTabNavigatorParams, 'Feeds'> @@ -215,12 +219,7 @@ export function FeedsScreen(_props: Props) { // pendingItems: this.rootStore.preferences.savedFeeds.length || 3, }) } else { - if (preferences?.feeds?.saved.length === 0) { - slices.push({ - key: 'savedFeedNoResults', - type: 'savedFeedNoResults', - }) - } else { + if (preferences?.feeds?.saved.length !== 0) { const {saved, pinned} = preferences.feeds slices = slices.concat( @@ -400,46 +399,48 @@ export function FeedsScreen(_props: Props) { ) { return ( <View style={s.p10}> - <ActivityIndicator /> + <ActivityIndicator size="large" /> </View> ) } else if (item.type === 'savedFeedsHeader') { - if (!isMobile) { - return ( - <View - style={[ - pal.view, - styles.header, - pal.border, - { - borderBottomWidth: 1, - }, - ]}> - <Text type="title-lg" style={[pal.text, s.bold]}> - <Trans>My Feeds</Trans> - </Text> - <View style={styles.headerBtnGroup}> - <Pressable - accessibilityRole="button" - hitSlop={HITSLOP_10} - onPress={searchInputRef.current?.focus}> - <MagnifyingGlassIcon2 - size={22} - strokeWidth={2} - style={pal.icon} - /> - </Pressable> - <Link - href="/settings/saved-feeds" - accessibilityLabel={_(msg`Edit My Feeds`)} - accessibilityHint=""> - <CogIcon strokeWidth={1.5} style={pal.icon} size={28} /> - </Link> + return ( + <> + {!isMobile && ( + <View + style={[ + pal.view, + styles.header, + pal.border, + { + borderBottomWidth: 1, + }, + ]}> + <Text type="title-lg" style={[pal.text, s.bold]}> + <Trans>Feeds</Trans> + </Text> + <View style={styles.headerBtnGroup}> + <Pressable + accessibilityRole="button" + hitSlop={HITSLOP_10} + onPress={searchInputRef.current?.focus}> + <MagnifyingGlassIcon2 + size={22} + strokeWidth={2} + style={pal.icon} + /> + </Pressable> + <Link + href="/settings/saved-feeds" + accessibilityLabel={_(msg`Edit My Feeds`)} + accessibilityHint=""> + <CogIcon strokeWidth={1.5} style={pal.icon} size={28} /> + </Link> + </View> </View> - </View> - ) - } - return <View /> + )} + {preferences?.feeds?.saved?.length !== 0 && <FeedsSavedHeader />} + </> + ) } else if (item.type === 'savedFeedNoResults') { return ( <View @@ -457,47 +458,17 @@ export function FeedsScreen(_props: Props) { } else if (item.type === 'popularFeedsHeader') { return ( <> - <View - style={[ - pal.view, - styles.header, - { - // This is first in the flatlist without a session -esb - marginTop: hasSession ? 16 : 0, - paddingLeft: isMobile ? 12 : undefined, - paddingRight: 10, - paddingBottom: isMobile ? 6 : undefined, - }, - ]}> - <Text type="title-lg" style={[pal.text, s.bold]}> - <Trans>Discover new feeds</Trans> - </Text> - - {!isMobile && ( - <SearchInput - ref={searchInputRef} - query={query} - onChangeQuery={onChangeQuery} - onPressCancelSearch={onPressCancelSearch} - onSubmitQuery={onSubmitQuery} - setIsInputFocused={onChangeSearchFocus} - style={{flex: 1, maxWidth: 250}} - /> - )} + <FeedsAboutHeader /> + <View style={{paddingHorizontal: 12, paddingBottom: 12}}> + <SearchInput + ref={searchInputRef} + query={query} + onChangeQuery={onChangeQuery} + onPressCancelSearch={onPressCancelSearch} + onSubmitQuery={onSubmitQuery} + setIsInputFocused={onChangeSearchFocus} + /> </View> - - {isMobile && ( - <View style={{paddingHorizontal: 8, paddingBottom: 10}}> - <SearchInput - ref={searchInputRef} - query={query} - onChangeQuery={onChangeQuery} - onPressCancelSearch={onPressCancelSearch} - onSubmitQuery={onSubmitQuery} - setIsInputFocused={onChangeSearchFocus} - /> - </View> - )} </> ) } else if (item.type === 'popularFeedsLoading') { @@ -529,15 +500,20 @@ export function FeedsScreen(_props: Props) { return null }, [ - _, - hasSession, isMobile, - pal, + pal.view, + pal.border, + pal.text, + pal.icon, + pal.textLight, + _, + preferences?.feeds?.saved?.length, query, onChangeQuery, onPressCancelSearch, onSubmitQuery, onChangeSearchFocus, + hasSession, ], ) @@ -552,8 +528,6 @@ export function FeedsScreen(_props: Props) { /> )} - {preferences ? <View /> : <ActivityIndicator />} - <List ref={listRef} style={[!isTabletOrDesktop && s.flex1, styles.list]} @@ -660,6 +634,71 @@ function SavedFeedLoadingPlaceholder() { ) } +function FeedsSavedHeader() { + const t = useTheme() + + return ( + <View + style={ + isWeb + ? [ + a.flex_row, + a.px_md, + a.py_lg, + a.gap_md, + a.border_b, + t.atoms.border_contrast_low, + ] + : [ + {flexDirection: 'row-reverse'}, + a.p_lg, + a.gap_md, + a.border_b, + t.atoms.border_contrast_low, + ] + }> + <IconCircle icon={ListSparkle_Stroke2_Corner0_Rounded} size="lg" /> + <View style={[a.flex_1, a.gap_xs]}> + <Text style={[a.flex_1, a.text_2xl, a.font_bold, t.atoms.text]}> + <Trans>My Feeds</Trans> + </Text> + <Text style={[t.atoms.text_contrast_high]}> + <Trans>All the feeds you've saved, right in one place.</Trans> + </Text> + </View> + </View> + ) +} + +function FeedsAboutHeader() { + const t = useTheme() + + return ( + <View + style={ + isWeb + ? [a.flex_row, a.px_md, a.pt_lg, a.pb_lg, a.gap_md] + : [{flexDirection: 'row-reverse'}, a.p_lg, a.gap_md] + }> + <IconCircle + icon={ListMagnifyingGlass_Stroke2_Corner0_Rounded} + size="lg" + /> + <View style={[a.flex_1, a.gap_sm]}> + <Text style={[a.flex_1, a.text_2xl, a.font_bold, t.atoms.text]}> + <Trans>Discover New Feeds</Trans> + </Text> + <Text style={[t.atoms.text_contrast_high]}> + <Trans> + Custom feeds built by the community bring you new experiences and + help you find the content you love. + </Trans> + </Text> + </View> + </View> + ) +} + const styles = StyleSheet.create({ container: { flex: 1, diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index cb2abf1bc..99ac8c44a 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -6,7 +6,7 @@ import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed' import {FollowingEmptyState} from 'view/com/posts/FollowingEmptyState' import {FollowingEndOfFeed} from 'view/com/posts/FollowingEndOfFeed' import {CustomFeedEmptyState} from 'view/com/posts/CustomFeedEmptyState' -import {FeedsTabBar} from '../com/pager/FeedsTabBar' +import {HomeHeader} from '../com/home/HomeHeader' import {Pager, RenderTabBarFnProps, PagerRef} from 'view/com/pager/Pager' import {FeedPage} from 'view/com/feeds/FeedPage' import {HomeLoggedOutCTA} from '../com/auth/HomeLoggedOutCTA' @@ -17,11 +17,12 @@ import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types' import {emitSoftReset} from '#/state/events' import {useSession} from '#/state/session' import {useSelectedFeed, useSetSelectedFeed} from '#/state/shell/selected-feed' +import {useSetTitle} from '#/lib/hooks/useSetTitle' type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home'> export function HomeScreen(props: Props) { const {data: preferences} = usePreferencesQuery() - const {feeds: pinnedFeedInfos, isLoading: isPinnedFeedsLoading} = + const {data: pinnedFeedInfos, isLoading: isPinnedFeedsLoading} = usePinnedFeedsInfos() if (preferences && pinnedFeedInfos && !isPinnedFeedsLoading) { return ( @@ -66,6 +67,8 @@ function HomeScreenReady({ const selectedIndex = Math.max(0, maybeFoundIndex) const selectedFeed = allFeeds[selectedIndex] + useSetTitle(pinnedFeedInfos[selectedIndex]?.displayName) + const pagerRef = React.useRef<PagerRef>(null) const lastPagerReportedIndexRef = React.useRef(selectedIndex) React.useLayoutEffect(() => { @@ -118,16 +121,16 @@ function HomeScreenReady({ const renderTabBar = React.useCallback( (props: RenderTabBarFnProps) => { return ( - <FeedsTabBar + <HomeHeader key="FEEDS_TAB_BAR" - selectedPage={props.selectedPage} - onSelect={props.onSelect} + {...props} testID="homeScreenFeedTabs" onPressSelected={onPressSelected} + feeds={pinnedFeedInfos} /> ) }, - [onPressSelected], + [onPressSelected, pinnedFeedInfos], ) const renderFollowingEmptyState = React.useCallback(() => { diff --git a/src/view/screens/LanguageSettings.tsx b/src/view/screens/LanguageSettings.tsx index 819840a46..b86cd46e1 100644 --- a/src/view/screens/LanguageSettings.tsx +++ b/src/view/screens/LanguageSettings.tsx @@ -97,7 +97,7 @@ export function LanguageSettingsScreen(_props: Props) { <Text style={[pal.text, s.pb10]}> <Trans> Select your app language for the default text to display in the - app + app. </Trans> </Text> @@ -296,7 +296,7 @@ export function LanguageSettingsScreen(_props: Props) { type="button" style={[pal.text, {flexShrink: 1, overflow: 'hidden'}]} numberOfLines={1}> - {myLanguages.length ? myLanguages : 'Select languages'} + {myLanguages.length ? myLanguages : _(msg`Select languages`)} </Text> </Button> </View> diff --git a/src/view/screens/Moderation.tsx b/src/view/screens/Moderation.tsx deleted file mode 100644 index 8f1fe75b6..000000000 --- a/src/view/screens/Moderation.tsx +++ /dev/null @@ -1,285 +0,0 @@ -import React from 'react' -import { - ActivityIndicator, - StyleSheet, - TouchableOpacity, - View, -} from 'react-native' -import {useFocusEffect} from '@react-navigation/native' -import { - FontAwesomeIcon, - FontAwesomeIconStyle, -} from '@fortawesome/react-native-fontawesome' -import {ComAtprotoLabelDefs} from '@atproto/api' -import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' -import {s} from 'lib/styles' -import {CenteredView} from '../com/util/Views' -import {ViewHeader} from '../com/util/ViewHeader' -import {Link, TextLink} from '../com/util/Link' -import {Text} from '../com/util/text/Text' -import {usePalette} from 'lib/hooks/usePalette' -import {useAnalytics} from 'lib/analytics/analytics' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {useSetMinimalShellMode} from '#/state/shell' -import {useModalControls} from '#/state/modals' -import {Trans, msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {ToggleButton} from '../com/util/forms/ToggleButton' -import {useSession} from '#/state/session' -import { - useProfileQuery, - useProfileUpdateMutation, -} from '#/state/queries/profile' -import {ScrollView} from '../com/util/Views' - -type Props = NativeStackScreenProps<CommonNavigatorParams, 'Moderation'> -export function ModerationScreen({}: Props) { - const pal = usePalette('default') - const {_} = useLingui() - const setMinimalShellMode = useSetMinimalShellMode() - const {screen, track} = useAnalytics() - const {isTabletOrDesktop} = useWebMediaQueries() - const {openModal} = useModalControls() - - useFocusEffect( - React.useCallback(() => { - screen('Moderation') - setMinimalShellMode(false) - }, [screen, setMinimalShellMode]), - ) - - const onPressContentFiltering = React.useCallback(() => { - track('Moderation:ContentfilteringButtonClicked') - openModal({name: 'content-filtering-settings'}) - }, [track, openModal]) - - return ( - <CenteredView - style={[ - s.hContentRegion, - pal.border, - isTabletOrDesktop ? styles.desktopContainer : pal.viewLight, - ]} - testID="moderationScreen"> - <ViewHeader title={_(msg`Moderation`)} showOnDesktop /> - <ScrollView contentContainerStyle={[styles.noBorder]}> - <View style={styles.spacer} /> - <TouchableOpacity - testID="contentFilteringBtn" - style={[styles.linkCard, pal.view]} - onPress={onPressContentFiltering} - accessibilityRole="tab" - accessibilityHint="Content filtering" - accessibilityLabel=""> - <View style={[styles.iconContainer, pal.btn]}> - <FontAwesomeIcon - icon="eye" - style={pal.text as FontAwesomeIconStyle} - /> - </View> - <Text type="lg" style={pal.text}> - <Trans>Content filtering</Trans> - </Text> - </TouchableOpacity> - <Link - testID="moderationlistsBtn" - style={[styles.linkCard, pal.view]} - href="/moderation/modlists"> - <View style={[styles.iconContainer, pal.btn]}> - <FontAwesomeIcon - icon="users-slash" - style={pal.text as FontAwesomeIconStyle} - /> - </View> - <Text type="lg" style={pal.text}> - <Trans>Moderation lists</Trans> - </Text> - </Link> - <Link - testID="mutedAccountsBtn" - style={[styles.linkCard, pal.view]} - href="/moderation/muted-accounts"> - <View style={[styles.iconContainer, pal.btn]}> - <FontAwesomeIcon - icon="user-slash" - style={pal.text as FontAwesomeIconStyle} - /> - </View> - <Text type="lg" style={pal.text}> - <Trans>Muted accounts</Trans> - </Text> - </Link> - <Link - testID="blockedAccountsBtn" - style={[styles.linkCard, pal.view]} - href="/moderation/blocked-accounts"> - <View style={[styles.iconContainer, pal.btn]}> - <FontAwesomeIcon - icon="ban" - style={pal.text as FontAwesomeIconStyle} - /> - </View> - <Text type="lg" style={pal.text}> - <Trans>Blocked accounts</Trans> - </Text> - </Link> - <Text - type="xl-bold" - style={[ - pal.text, - { - paddingHorizontal: 18, - paddingTop: 18, - paddingBottom: 6, - }, - ]}> - <Trans>Logged-out visibility</Trans> - </Text> - <PwiOptOut /> - </ScrollView> - </CenteredView> - ) -} - -function PwiOptOut() { - const pal = usePalette('default') - const {_} = useLingui() - const {currentAccount} = useSession() - const {data: profile} = useProfileQuery({did: currentAccount?.did}) - const updateProfile = useProfileUpdateMutation() - - const isOptedOut = - profile?.labels?.some(l => l.val === '!no-unauthenticated') || false - const canToggle = profile && !updateProfile.isPending - - const onToggleOptOut = React.useCallback(() => { - if (!profile) { - return - } - let wasAdded = false - updateProfile.mutate({ - profile, - updates: existing => { - // create labels attr if needed - existing.labels = ComAtprotoLabelDefs.isSelfLabels(existing.labels) - ? existing.labels - : { - $type: 'com.atproto.label.defs#selfLabels', - values: [], - } - - // toggle the label - const hasLabel = existing.labels.values.some( - l => l.val === '!no-unauthenticated', - ) - if (hasLabel) { - wasAdded = false - existing.labels.values = existing.labels.values.filter( - l => l.val !== '!no-unauthenticated', - ) - } else { - wasAdded = true - existing.labels.values.push({val: '!no-unauthenticated'}) - } - - // delete if no longer needed - if (existing.labels.values.length === 0) { - delete existing.labels - } - return existing - }, - checkCommitted: res => { - const exists = !!res.data.labels?.some( - l => l.val === '!no-unauthenticated', - ) - return exists === wasAdded - }, - }) - }, [updateProfile, profile]) - - return ( - <View style={[pal.view, styles.toggleCard]}> - <View - style={{flexDirection: 'row', alignItems: 'center', paddingRight: 14}}> - <ToggleButton - type="default-light" - label={_( - msg`Discourage apps from showing my account to logged-out users`, - )} - labelType="lg" - isSelected={isOptedOut} - onPress={canToggle ? onToggleOptOut : undefined} - style={[canToggle ? undefined : {opacity: 0.5}, {flex: 1}]} - /> - {updateProfile.isPending && <ActivityIndicator />} - </View> - <View - style={{ - flexDirection: 'column', - gap: 10, - paddingLeft: 66, - paddingRight: 12, - paddingBottom: 10, - marginBottom: 64, - }}> - <Text style={pal.textLight}> - <Trans> - Bluesky will not show your profile and posts to logged-out users. - Other apps may not honor this request. This does not make your - account private. - </Trans> - </Text> - <Text style={[pal.textLight, {fontWeight: '500'}]}> - <Trans> - Note: Bluesky is an open and public network. This setting only - limits the visibility of your content on the Bluesky app and - website, and other apps may not respect this setting. Your content - may still be shown to logged-out users by other apps and websites. - </Trans> - </Text> - <TextLink - style={pal.link} - href="https://blueskyweb.zendesk.com/hc/en-us/articles/15835264007693-Data-Privacy" - text={_(msg`Learn more about what is public on Bluesky.`)} - /> - </View> - </View> - ) -} - -const styles = StyleSheet.create({ - desktopContainer: { - borderLeftWidth: 1, - borderRightWidth: 1, - }, - spacer: { - height: 6, - }, - linkCard: { - flexDirection: 'row', - alignItems: 'center', - paddingVertical: 12, - paddingHorizontal: 18, - marginBottom: 1, - }, - toggleCard: { - paddingVertical: 8, - paddingTop: 2, - paddingHorizontal: 6, - marginBottom: 1, - }, - iconContainer: { - alignItems: 'center', - justifyContent: 'center', - width: 40, - height: 40, - borderRadius: 30, - marginRight: 12, - }, - noBorder: { - borderBottomWidth: 0, - borderRightWidth: 0, - borderLeftWidth: 0, - borderTopWidth: 0, - }, -}) diff --git a/src/view/screens/ModerationBlockedAccounts.tsx b/src/view/screens/ModerationBlockedAccounts.tsx index 09d77987f..eb3b27048 100644 --- a/src/view/screens/ModerationBlockedAccounts.tsx +++ b/src/view/screens/ModerationBlockedAccounts.tsx @@ -131,7 +131,7 @@ export function ModerationBlockedAccounts({}: Props) { <Text type="lg" style={[pal.text, styles.emptyText]}> <Trans> You have not blocked any accounts yet. To block an account, go - to their profile and selected "Block account" from the menu on + to their profile and select "Block account" from the menu on their account. </Trans> </Text> diff --git a/src/view/screens/ModerationMutedAccounts.tsx b/src/view/screens/ModerationMutedAccounts.tsx index 1aff19dd3..911ace778 100644 --- a/src/view/screens/ModerationMutedAccounts.tsx +++ b/src/view/screens/ModerationMutedAccounts.tsx @@ -130,8 +130,8 @@ export function ModerationMutedAccounts({}: Props) { <Text type="lg" style={[pal.text, styles.emptyText]}> <Trans> You have not muted any accounts yet. To mute an account, go to - their profile and selected "Mute account" from the menu on - their account. + their profile and select "Mute account" from the menu on their + account. </Trans> </Text> </View> diff --git a/src/view/screens/NotFound.tsx b/src/view/screens/NotFound.tsx index dfa840abb..7d51619b3 100644 --- a/src/view/screens/NotFound.tsx +++ b/src/view/screens/NotFound.tsx @@ -51,7 +51,13 @@ export const NotFoundScreen = () => { </Text> <Button type="primary" - label={canGoBack ? 'Go back' : 'Go home'} + label={canGoBack ? _(msg`Go Back`) : _(msg`Go Home`)} + accessibilityLabel={canGoBack ? _(msg`Go back`) : _(msg`Go home`)} + accessibilityHint={ + canGoBack + ? _(msg`Returns to previous page`) + : _(msg`Returns to home page`) + } onPress={onPressHome} /> </View> diff --git a/src/view/screens/PostThread.tsx b/src/view/screens/PostThread.tsx index aa09ab9ed..ba1fa130e 100644 --- a/src/view/screens/PostThread.tsx +++ b/src/view/screens/PostThread.tsx @@ -59,11 +59,7 @@ export function PostThreadScreen({route}: Props) { uri: thread.post.uri, cid: thread.post.cid, text: thread.record.text, - author: { - handle: thread.post.author.handle, - displayName: thread.post.author.displayName, - avatar: thread.post.author.avatar, - }, + author: thread.post.author, embed: thread.post.embed, }, onPost: () => diff --git a/src/view/screens/PreferencesHomeFeed.tsx b/src/view/screens/PreferencesFollowingFeed.tsx index 7ad870937..b4acbcd44 100644 --- a/src/view/screens/PreferencesHomeFeed.tsx +++ b/src/view/screens/PreferencesFollowingFeed.tsx @@ -78,9 +78,9 @@ function RepliesThresholdInput({ type Props = NativeStackScreenProps< CommonNavigatorParams, - 'PreferencesHomeFeed' + 'PreferencesFollowingFeed' > -export function PreferencesHomeFeed({navigation}: Props) { +export function PreferencesFollowingFeed({navigation}: Props) { const pal = usePalette('default') const {_} = useLingui() const {isTabletOrDesktop} = useWebMediaQueries() @@ -101,14 +101,14 @@ export function PreferencesHomeFeed({navigation}: Props) { styles.container, isTabletOrDesktop && styles.desktopContainer, ]}> - <ViewHeader title={_(msg`Home Feed Preferences`)} showOnDesktop /> + <ViewHeader title={_(msg`Following Feed Preferences`)} showOnDesktop /> <View style={[ styles.titleSection, isTabletOrDesktop && {paddingTop: 20, paddingBottom: 20}, ]}> <Text type="xl" style={[pal.textLight, styles.description]}> - <Trans>Fine-tune the content you see on your home screen.</Trans> + <Trans>Fine-tune the content you see on your Following feed.</Trans> </Text> </View> @@ -260,7 +260,7 @@ export function PreferencesHomeFeed({navigation}: Props) { <Text style={[pal.text, s.pb10]}> <Trans> Set this setting to "Yes" to show samples of your saved feeds in - your following feed. This is an experimental feature. + your Following feed. This is an experimental feature. </Trans> </Text> <ToggleButton diff --git a/src/view/screens/Profile.tsx b/src/view/screens/Profile.tsx index 64e067593..6073b9571 100644 --- a/src/view/screens/Profile.tsx +++ b/src/view/screens/Profile.tsx @@ -1,53 +1,45 @@ import React, {useMemo} from 'react' -import {StyleSheet, View} from 'react-native' -import {useFocusEffect} from '@react-navigation/native' +import {StyleSheet} from 'react-native' import { AppBskyActorDefs, moderateProfile, ModerationOpts, RichText as RichTextAPI, } from '@atproto/api' -import {msg, Trans} from '@lingui/macro' +import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' -import {CenteredView} from '../com/util/Views' -import {ListRef} from '../com/util/List' -import {ScreenHider} from 'view/com/util/moderation/ScreenHider' -import {Feed} from 'view/com/posts/Feed' -import {ProfileLists} from '../com/lists/ProfileLists' -import {ProfileFeedgens} from '../com/feeds/ProfileFeedgens' -import {ProfileHeader, ProfileHeaderLoading} from '../com/profile/ProfileHeader' -import {PagerWithHeader} from 'view/com/pager/PagerWithHeader' -import {ErrorScreen} from '../com/util/error/ErrorScreen' -import {EmptyState} from '../com/util/EmptyState' -import {FAB} from '../com/util/fab/FAB' -import {s, colors} from 'lib/styles' -import {useAnalytics} from 'lib/analytics/analytics' -import {ComposeIcon2} from 'lib/icons' -import {useSetTitle} from 'lib/hooks/useSetTitle' -import {combinedDisplayName} from 'lib/strings/display-names' -import { - FeedDescriptor, - resetProfilePostsQueries, -} from '#/state/queries/post-feed' -import {useResolveDidQuery} from '#/state/queries/resolve-uri' -import {useProfileQuery} from '#/state/queries/profile' +import {useFocusEffect} from '@react-navigation/native' +import {useQueryClient} from '@tanstack/react-query' + +import {cleanError} from '#/lib/strings/errors' +import {isInvalidHandle} from '#/lib/strings/handles' import {useProfileShadow} from '#/state/cache/profile-shadow' -import {useSession, getAgent} from '#/state/session' +import {listenSoftReset} from '#/state/events' +import {useLabelerInfoQuery} from '#/state/queries/labeler' +import {resetProfilePostsQueries} from '#/state/queries/post-feed' import {useModerationOpts} from '#/state/queries/preferences' -import {useProfileExtraInfoQuery} from '#/state/queries/profile-extra-info' -import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' +import {useProfileQuery} from '#/state/queries/profile' +import {useResolveDidQuery} from '#/state/queries/resolve-uri' +import {getAgent, useSession} from '#/state/session' import {useSetDrawerSwipeDisabled, useSetMinimalShellMode} from '#/state/shell' -import {cleanError} from '#/lib/strings/errors' -import {LoadLatestBtn} from '../com/util/load-latest/LoadLatestBtn' -import {useQueryClient} from '@tanstack/react-query' import {useComposerControls} from '#/state/shell/composer' -import {listenSoftReset} from '#/state/events' -import {truncateAndInvalidate} from '#/state/queries/util' -import {Text} from '#/view/com/util/text/Text' -import {usePalette} from 'lib/hooks/usePalette' -import {isNative} from '#/platform/detection' -import {isInvalidHandle} from '#/lib/strings/handles' +import {useAnalytics} from 'lib/analytics/analytics' +import {useSetTitle} from 'lib/hooks/useSetTitle' +import {ComposeIcon2} from 'lib/icons' +import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types' +import {combinedDisplayName} from 'lib/strings/display-names' +import {colors, s} from 'lib/styles' +import {PagerWithHeader} from 'view/com/pager/PagerWithHeader' +import {ProfileHeader, ProfileHeaderLoading} from '#/screens/Profile/Header' +import {ProfileFeedSection} from '#/screens/Profile/Sections/Feed' +import {ProfileLabelsSection} from '#/screens/Profile/Sections/Labels' +import {ScreenHider} from '#/components/moderation/ScreenHider' +import {ProfileFeedgens} from '../com/feeds/ProfileFeedgens' +import {ProfileLists} from '../com/lists/ProfileLists' +import {ErrorScreen} from '../com/util/error/ErrorScreen' +import {FAB} from '../com/util/fab/FAB' +import {ListRef} from '../com/util/List' +import {CenteredView} from '../com/util/Views' interface SectionRef { scrollToTop: () => void @@ -57,6 +49,7 @@ type Props = NativeStackScreenProps<CommonNavigatorParams, 'Profile'> export function ProfileScreen({route}: Props) { const {_} = useLingui() const {currentAccount} = useSession() + const queryClient = useQueryClient() const name = route.params.name === 'me' ? currentAccount?.did : route.params.name const moderationOpts = useModerationOpts() @@ -87,9 +80,9 @@ export function ProfileScreen({route}: Props) { // When we open the profile, we want to reset the posts query if we are blocked. React.useEffect(() => { if (resolvedDid && profile?.viewer?.blockedBy) { - resetProfilePostsQueries(resolvedDid) + resetProfilePostsQueries(queryClient, resolvedDid) } - }, [profile?.viewer?.blockedBy, resolvedDid]) + }, [queryClient, profile?.viewer?.blockedBy, resolvedDid]) // Most pushes will happen here, since we will have only placeholder data if (isLoadingDid || isLoadingProfile) { @@ -148,16 +141,24 @@ function ProfileScreenLoaded({ const setMinimalShellMode = useSetMinimalShellMode() const {openComposer} = useComposerControls() const {screen, track} = useAnalytics() + const { + data: labelerInfo, + error: labelerError, + isLoading: isLabelerLoading, + } = useLabelerInfoQuery({ + did: profile.did, + enabled: !!profile.associated?.labeler, + }) const [currentPage, setCurrentPage] = React.useState(0) const {_} = useLingui() const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled() - const extraInfoQuery = useProfileExtraInfoQuery(profile.did) const postsSectionRef = React.useRef<SectionRef>(null) const repliesSectionRef = React.useRef<SectionRef>(null) const mediaSectionRef = React.useRef<SectionRef>(null) const likesSectionRef = React.useRef<SectionRef>(null) const feedsSectionRef = React.useRef<SectionRef>(null) const listsSectionRef = React.useRef<SectionRef>(null) + const labelsSectionRef = React.useRef<SectionRef>(null) useSetTitle(combinedDisplayName(profile)) @@ -171,44 +172,75 @@ function ProfileScreenLoaded({ ) const isMe = profile.did === currentAccount?.did + const hasLabeler = !!profile.associated?.labeler + const showFiltersTab = hasLabeler + const showPostsTab = true const showRepliesTab = hasSession + const showMediaTab = !hasLabeler const showLikesTab = isMe - const showFeedsTab = hasSession && (isMe || extraInfoQuery.data?.hasFeedgens) - const showListsTab = hasSession && (isMe || extraInfoQuery.data?.hasLists) + const showFeedsTab = + hasSession && (isMe || (profile.associated?.feedgens || 0) > 0) + const showListsTab = + hasSession && (isMe || (profile.associated?.lists || 0) > 0) + const sectionTitles = useMemo<string[]>(() => { return [ - _(msg`Posts`), + showFiltersTab ? _(msg`Labels`) : undefined, + showListsTab && hasLabeler ? _(msg`Lists`) : undefined, + showPostsTab ? _(msg`Posts`) : undefined, showRepliesTab ? _(msg`Replies`) : undefined, - _(msg`Media`), + showMediaTab ? _(msg`Media`) : undefined, showLikesTab ? _(msg`Likes`) : undefined, showFeedsTab ? _(msg`Feeds`) : undefined, - showListsTab ? _(msg`Lists`) : undefined, + showListsTab && !hasLabeler ? _(msg`Lists`) : undefined, ].filter(Boolean) as string[] - }, [showRepliesTab, showLikesTab, showFeedsTab, showListsTab, _]) + }, [ + showPostsTab, + showRepliesTab, + showMediaTab, + showLikesTab, + showFeedsTab, + showListsTab, + showFiltersTab, + hasLabeler, + _, + ]) let nextIndex = 0 - const postsIndex = nextIndex++ + let filtersIndex: number | null = null + let postsIndex: number | null = null let repliesIndex: number | null = null + let mediaIndex: number | null = null + let likesIndex: number | null = null + let feedsIndex: number | null = null + let listsIndex: number | null = null + if (showFiltersTab) { + filtersIndex = nextIndex++ + } + if (showPostsTab) { + postsIndex = nextIndex++ + } if (showRepliesTab) { repliesIndex = nextIndex++ } - const mediaIndex = nextIndex++ - let likesIndex: number | null = null + if (showMediaTab) { + mediaIndex = nextIndex++ + } if (showLikesTab) { likesIndex = nextIndex++ } - let feedsIndex: number | null = null if (showFeedsTab) { feedsIndex = nextIndex++ } - let listsIndex: number | null = null if (showListsTab) { listsIndex = nextIndex++ } const scrollSectionToTop = React.useCallback( (index: number) => { - if (index === postsIndex) { + if (index === filtersIndex) { + labelsSectionRef.current?.scrollToTop() + } else if (index === postsIndex) { postsSectionRef.current?.scrollToTop() } else if (index === repliesIndex) { repliesSectionRef.current?.scrollToTop() @@ -222,7 +254,15 @@ function ProfileScreenLoaded({ listsSectionRef.current?.scrollToTop() } }, - [postsIndex, repliesIndex, mediaIndex, likesIndex, feedsIndex, listsIndex], + [ + filtersIndex, + postsIndex, + repliesIndex, + mediaIndex, + likesIndex, + feedsIndex, + listsIndex, + ], ) useFocusEffect( @@ -278,6 +318,7 @@ function ProfileScreenLoaded({ return ( <ProfileHeader profile={profile} + labeler={labelerInfo} descriptionRT={hasDescription ? descriptionRT : null} moderationOpts={moderationOpts} hideBackButton={hideBackButton} @@ -286,6 +327,7 @@ function ProfileScreenLoaded({ ) }, [ profile, + labelerInfo, descriptionRT, hasDescription, moderationOpts, @@ -297,8 +339,8 @@ function ProfileScreenLoaded({ <ScreenHider testID="profileView" style={styles.container} - screenDescription="profile" - moderation={moderation.account}> + screenDescription={_(msg`profile`)} + modui={moderation.ui('profileView')}> <PagerWithHeader testID="profilePager" isHeaderReady={!showPlaceholder} @@ -306,19 +348,45 @@ function ProfileScreenLoaded({ onPageSelected={onPageSelected} onCurrentPageSelected={onCurrentPageSelected} renderHeader={renderHeader}> - {({headerHeight, isFocused, scrollElRef}) => ( - <FeedSection - ref={postsSectionRef} - feed={`author|${profile.did}|posts_and_author_threads`} - headerHeight={headerHeight} - isFocused={isFocused} - scrollElRef={scrollElRef as ListRef} - ignoreFilterFor={profile.did} - /> - )} + {showFiltersTab + ? ({headerHeight, scrollElRef}) => ( + <ProfileLabelsSection + ref={labelsSectionRef} + labelerInfo={labelerInfo} + labelerError={labelerError} + isLabelerLoading={isLabelerLoading} + moderationOpts={moderationOpts} + scrollElRef={scrollElRef as ListRef} + headerHeight={headerHeight} + /> + ) + : null} + {showListsTab && !!profile.associated?.labeler + ? ({headerHeight, isFocused, scrollElRef}) => ( + <ProfileLists + ref={listsSectionRef} + did={profile.did} + scrollElRef={scrollElRef as ListRef} + headerOffset={headerHeight} + enabled={isFocused} + /> + ) + : null} + {showPostsTab + ? ({headerHeight, isFocused, scrollElRef}) => ( + <ProfileFeedSection + ref={postsSectionRef} + feed={`author|${profile.did}|posts_and_author_threads`} + headerHeight={headerHeight} + isFocused={isFocused} + scrollElRef={scrollElRef as ListRef} + ignoreFilterFor={profile.did} + /> + ) + : null} {showRepliesTab ? ({headerHeight, isFocused, scrollElRef}) => ( - <FeedSection + <ProfileFeedSection ref={repliesSectionRef} feed={`author|${profile.did}|posts_with_replies`} headerHeight={headerHeight} @@ -328,19 +396,21 @@ function ProfileScreenLoaded({ /> ) : null} - {({headerHeight, isFocused, scrollElRef}) => ( - <FeedSection - ref={mediaSectionRef} - feed={`author|${profile.did}|posts_with_media`} - headerHeight={headerHeight} - isFocused={isFocused} - scrollElRef={scrollElRef as ListRef} - ignoreFilterFor={profile.did} - /> - )} + {showMediaTab + ? ({headerHeight, isFocused, scrollElRef}) => ( + <ProfileFeedSection + ref={mediaSectionRef} + feed={`author|${profile.did}|posts_with_media`} + headerHeight={headerHeight} + isFocused={isFocused} + scrollElRef={scrollElRef as ListRef} + ignoreFilterFor={profile.did} + /> + ) + : null} {showLikesTab ? ({headerHeight, isFocused, scrollElRef}) => ( - <FeedSection + <ProfileFeedSection ref={likesSectionRef} feed={`likes|${profile.did}`} headerHeight={headerHeight} @@ -361,7 +431,7 @@ function ProfileScreenLoaded({ /> ) : null} - {showListsTab + {showListsTab && !profile.associated?.labeler ? ({headerHeight, isFocused, scrollElRef}) => ( <ProfileLists ref={listsSectionRef} @@ -387,77 +457,6 @@ function ProfileScreenLoaded({ ) } -interface FeedSectionProps { - feed: FeedDescriptor - headerHeight: number - isFocused: boolean - scrollElRef: ListRef - ignoreFilterFor?: string -} -const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>( - function FeedSectionImpl( - {feed, headerHeight, isFocused, scrollElRef, ignoreFilterFor}, - ref, - ) { - const {_} = useLingui() - const queryClient = useQueryClient() - const [hasNew, setHasNew] = React.useState(false) - const [isScrolledDown, setIsScrolledDown] = React.useState(false) - - const onScrollToTop = React.useCallback(() => { - scrollElRef.current?.scrollToOffset({ - animated: isNative, - offset: -headerHeight, - }) - truncateAndInvalidate(queryClient, FEED_RQKEY(feed)) - setHasNew(false) - }, [scrollElRef, headerHeight, queryClient, feed, setHasNew]) - React.useImperativeHandle(ref, () => ({ - scrollToTop: onScrollToTop, - })) - - const renderPostsEmpty = React.useCallback(() => { - return <EmptyState icon="feed" message={_(msg`This feed is empty!`)} /> - }, [_]) - - return ( - <View> - <Feed - testID="postsFeed" - enabled={isFocused} - feed={feed} - scrollElRef={scrollElRef} - onHasNew={setHasNew} - onScrolledDownChange={setIsScrolledDown} - renderEmptyState={renderPostsEmpty} - headerOffset={headerHeight} - renderEndOfFeed={ProfileEndOfFeed} - ignoreFilterFor={ignoreFilterFor} - /> - {(isScrolledDown || hasNew) && ( - <LoadLatestBtn - onPress={onScrollToTop} - label={_(msg`Load new posts`)} - showIndicator={hasNew} - /> - )} - </View> - ) - }, -) - -function ProfileEndOfFeed() { - const pal = usePalette('default') - - return ( - <View style={[pal.border, {paddingTop: 32, borderTopWidth: 1}]}> - <Text style={[pal.textLight, pal.border, {textAlign: 'center'}]}> - <Trans>End of feed</Trans> - </Text> - </View> - ) -} - function useRichText(text: string): [RichTextAPI, boolean] { const [prevText, setPrevText] = React.useState(text) const [rawRT, setRawRT] = React.useState(() => new RichTextAPI({text})) @@ -491,6 +490,8 @@ const styles = StyleSheet.create({ container: { flexDirection: 'column', height: '100%', + // @ts-ignore Web-only. + overflowAnchor: 'none', // Fixes jumps when switching tabs while scrolled down. }, loading: { paddingVertical: 10, diff --git a/src/view/screens/ProfileFeed.tsx b/src/view/screens/ProfileFeed.tsx index be9eec816..8eeeb5d90 100644 --- a/src/view/screens/ProfileFeed.tsx +++ b/src/view/screens/ProfileFeed.tsx @@ -1,11 +1,9 @@ import React, {useMemo, useCallback} from 'react' -import {Dimensions, StyleSheet, View} from 'react-native' +import {StyleSheet, View, Pressable} from 'react-native' import {NativeStackScreenProps} from '@react-navigation/native-stack' import {useIsFocused, useNavigation} from '@react-navigation/native' import {useQueryClient} from '@tanstack/react-query' import {usePalette} from 'lib/hooks/usePalette' -import {HeartIcon, HeartIconSolid} from 'lib/icons' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {CommonNavigatorParams} from 'lib/routes/types' import {makeRecordUri} from 'lib/strings/url-helpers' import {s} from 'lib/styles' @@ -13,11 +11,11 @@ import {FeedDescriptor} from '#/state/queries/post-feed' import {PagerWithHeader} from 'view/com/pager/PagerWithHeader' import {ProfileSubpageHeader} from 'view/com/profile/ProfileSubpageHeader' import {Feed} from 'view/com/posts/Feed' -import {TextLink} from 'view/com/util/Link' +import {InlineLink} from '#/components/Link' import {ListRef} from 'view/com/util/List' import {Button} from 'view/com/util/forms/Button' import {Text} from 'view/com/util/text/Text' -import {RichText} from 'view/com/util/text/RichText' +import {RichText} from '#/components/RichText' import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn' import {FAB} from 'view/com/util/fab/FAB' import {EmptyState} from 'view/com/util/EmptyState' @@ -29,20 +27,15 @@ import {shareUrl} from 'lib/sharing' import {toShareUrl} from 'lib/strings/url-helpers' import {Haptics} from 'lib/haptics' import {useAnalytics} from 'lib/analytics/analytics' -import {NativeDropdown, DropdownItem} from 'view/com/util/forms/NativeDropdown' -import {useScrollHandlers} from '#/lib/ScrollContext' -import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED' import {makeCustomFeedLink} from 'lib/routes/links' import {pluralize} from 'lib/strings/helpers' -import {CenteredView, ScrollView} from 'view/com/util/Views' +import {CenteredView} from 'view/com/util/Views' import {NavigationProp} from 'lib/routes/types' -import {sanitizeHandle} from 'lib/strings/handles' -import {makeProfileLink} from 'lib/routes/links' import {ComposeIcon2} from 'lib/icons' import {logger} from '#/logger' import {Trans, msg} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {useModalControls} from '#/state/modals' +import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog' import {useFeedSourceInfoQuery, FeedSourceFeedInfo} from '#/state/queries/feed' import {useResolveUriQuery} from '#/state/queries/resolve-uri' import { @@ -59,8 +52,21 @@ import {useComposerControls} from '#/state/shell/composer' import {truncateAndInvalidate} from '#/state/queries/util' import {isNative} from '#/platform/detection' import {listenSoftReset} from '#/state/events' +import {atoms as a, useTheme} from '#/alf' +import * as Menu from '#/components/Menu' +import {HITSLOP_20} from '#/lib/constants' +import {DotGrid_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid' +import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' +import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' +import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' +import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox' +import { + Heart2_Stroke2_Corner0_Rounded as HeartOutline, + Heart2_Filled_Stroke2_Corner0_Rounded as HeartFilled, +} from '#/components/icons/Heart2' +import {Button as NewButton, ButtonText} from '#/components/Button' -const SECTION_TITLES = ['Posts', 'About'] +const SECTION_TITLES = ['Posts'] interface SectionRef { scrollToTop: () => void @@ -102,8 +108,8 @@ export function ProfileFeedScreen(props: Props) { <View style={{flexDirection: 'row'}}> <Button type="default" - accessibilityLabel={_(msg`Go Back`)} - accessibilityHint="Return to previous page" + accessibilityLabel={_(msg`Go back`)} + accessibilityHint={_(msg`Returns to previous page`)} onPress={onPressBack} style={{flexShrink: 1}}> <Text type="button" style={pal.text}> @@ -147,9 +153,9 @@ export function ProfileFeedScreenInner({ feedInfo: FeedSourceFeedInfo }) { const {_} = useLingui() - const pal = usePalette('default') + const t = useTheme() const {hasSession, currentAccount} = useSession() - const {openModal} = useModalControls() + const reportDialogControl = useReportDialogControl() const {openComposer} = useComposerControls() const {track} = useAnalytics() const feedSectionRef = React.useRef<SectionRef>(null) @@ -199,9 +205,11 @@ export function ProfileFeedScreenInner({ if (isSaved) { await removeFeed({uri: feedInfo.uri}) resetRemoveFeed() + Toast.show(_(msg`Removed from your feeds`)) } else { await saveFeed({uri: feedInfo.uri}) resetSaveFeed() + Toast.show(_(msg`Saved to your feeds`)) } } catch (err) { Toast.show( @@ -245,13 +253,8 @@ export function ProfileFeedScreenInner({ }, [feedInfo, track]) const onPressReport = React.useCallback(() => { - if (!feedInfo) return - openModal({ - name: 'report', - uri: feedInfo.uri, - cid: feedInfo.cid, - }) - }, [openModal, feedInfo]) + reportDialogControl.open() + }, [reportDialogControl]) const onCurrentPageSelected = React.useCallback( (index: number) => { @@ -262,134 +265,144 @@ export function ProfileFeedScreenInner({ [feedSectionRef], ) - // render - // = - - const dropdownItems: DropdownItem[] = React.useMemo(() => { - return [ - hasSession && { - testID: 'feedHeaderDropdownToggleSavedBtn', - label: isSaved ? _(msg`Remove from my feeds`) : _(msg`Add to my feeds`), - onPress: isSavePending || isRemovePending ? undefined : onToggleSaved, - icon: isSaved - ? { - ios: { - name: 'trash', - }, - android: 'ic_delete', - web: ['far', 'trash-can'], - } - : { - ios: { - name: 'plus', - }, - android: '', - web: 'plus', - }, - }, - hasSession && { - testID: 'feedHeaderDropdownReportBtn', - label: _(msg`Report feed`), - onPress: onPressReport, - icon: { - ios: { - name: 'exclamationmark.triangle', - }, - android: 'ic_menu_report_image', - web: 'circle-exclamation', - }, - }, - { - testID: 'feedHeaderDropdownShareBtn', - label: _(msg`Share feed`), - onPress: onPressShare, - icon: { - ios: { - name: 'square.and.arrow.up', - }, - android: 'ic_menu_share', - web: 'share', - }, - }, - ].filter(Boolean) as DropdownItem[] - }, [ - hasSession, - onToggleSaved, - onPressReport, - onPressShare, - isSaved, - isSavePending, - isRemovePending, - _, - ]) - const renderHeader = useCallback(() => { return ( - <ProfileSubpageHeader - isLoading={false} - href={feedInfo.route.href} - title={feedInfo?.displayName} - avatar={feedInfo?.avatar} - isOwner={feedInfo.creatorDid === currentAccount?.did} - creator={ - feedInfo - ? {did: feedInfo.creatorDid, handle: feedInfo.creatorHandle} - : undefined - } - avatarType="algo"> - {feedInfo && hasSession && ( - <> - <Button - disabled={isSavePending || isRemovePending} - type="default" - label={isSaved ? _(msg`Unsave`) : _(msg`Save`)} - onPress={onToggleSaved} - style={styles.btn} - /> - <Button - testID={isPinned ? 'unpinBtn' : 'pinBtn'} - disabled={isPinPending || isUnpinPending} - type={isPinned ? 'default' : 'inverted'} - label={isPinned ? _(msg`Unpin`) : _(msg`Pin to home`)} - onPress={onTogglePinned} - style={styles.btn} - /> - </> - )} - <NativeDropdown - testID="headerDropdownBtn" - items={dropdownItems} - accessibilityLabel={_(msg`More options`)} - accessibilityHint=""> - <View style={[pal.viewLight, styles.btn]}> - <FontAwesomeIcon - icon="ellipsis" - size={20} - color={pal.colors.text} - /> + <> + <ProfileSubpageHeader + isLoading={false} + href={feedInfo.route.href} + title={feedInfo?.displayName} + avatar={feedInfo?.avatar} + isOwner={feedInfo.creatorDid === currentAccount?.did} + creator={ + feedInfo + ? {did: feedInfo.creatorDid, handle: feedInfo.creatorHandle} + : undefined + } + avatarType="algo"> + <View style={[a.flex_row, a.align_center, a.gap_sm]}> + {feedInfo && hasSession && ( + <NewButton + testID={isPinned ? 'unpinBtn' : 'pinBtn'} + disabled={isPinPending || isUnpinPending} + size="small" + variant="solid" + color={isPinned ? 'secondary' : 'primary'} + label={isPinned ? _(msg`Unpin from home`) : _(msg`Pin to home`)} + onPress={onTogglePinned}> + <ButtonText> + {isPinned ? _(msg`Unpin`) : _(msg`Pin to Home`)} + </ButtonText> + </NewButton> + )} + <Menu.Root> + <Menu.Trigger label={_(msg`Open feed options menu`)}> + {({props, state}) => { + return ( + <Pressable + {...props} + hitSlop={HITSLOP_20} + style={[ + a.justify_center, + a.align_center, + a.rounded_full, + {height: 36, width: 36}, + t.atoms.bg_contrast_50, + (state.hovered || state.pressed) && [ + t.atoms.bg_contrast_100, + ], + ]} + testID="headerDropdownBtn"> + <Ellipsis + size="lg" + fill={t.atoms.text_contrast_medium.color} + /> + </Pressable> + ) + }} + </Menu.Trigger> + + <Menu.Outer> + <Menu.Group> + {hasSession && ( + <> + <Menu.Item + disabled={isSavePending || isRemovePending} + testID="feedHeaderDropdownToggleSavedBtn" + label={ + isSaved + ? _(msg`Remove from my feeds`) + : _(msg`Save to my feeds`) + } + onPress={onToggleSaved}> + <Menu.ItemText> + {isSaved + ? _(msg`Remove from my feeds`) + : _(msg`Save to my feeds`)} + </Menu.ItemText> + <Menu.ItemIcon + icon={isSaved ? Trash : Plus} + position="right" + /> + </Menu.Item> + + <Menu.Item + testID="feedHeaderDropdownReportBtn" + label={_(msg`Report feed`)} + onPress={onPressReport}> + <Menu.ItemText>{_(msg`Report feed`)}</Menu.ItemText> + <Menu.ItemIcon icon={CircleInfo} position="right" /> + </Menu.Item> + </> + )} + + <Menu.Item + testID="feedHeaderDropdownShareBtn" + label={_(msg`Share feed`)} + onPress={onPressShare}> + <Menu.ItemText>{_(msg`Share feed`)}</Menu.ItemText> + <Menu.ItemIcon icon={Share} position="right" /> + </Menu.Item> + </Menu.Group> + </Menu.Outer> + </Menu.Root> </View> - </NativeDropdown> - </ProfileSubpageHeader> + </ProfileSubpageHeader> + <AboutSection + feedOwnerDid={feedInfo.creatorDid} + feedRkey={feedInfo.route.params.rkey} + feedInfo={feedInfo} + /> + </> ) }, [ _, hasSession, - pal, feedInfo, isPinned, onTogglePinned, onToggleSaved, - dropdownItems, currentAccount?.did, isPinPending, isRemovePending, isSavePending, isSaved, isUnpinPending, + onPressReport, + onPressShare, + t, ]) return ( <View style={s.hContentRegion}> + <ReportDialog + control={reportDialogControl} + params={{ + type: 'feedgen', + uri: feedInfo.uri, + cid: feedInfo.cid, + }} + /> <PagerWithHeader items={SECTION_TITLES} isHeaderReady={true} @@ -404,18 +417,6 @@ export function ProfileFeedScreenInner({ isFocused={isScreenFocused && isFocused} /> )} - {({headerHeight, scrollElRef}) => ( - <AboutSection - feedOwnerDid={feedInfo.creatorDid} - feedRkey={feedInfo.route.params.rkey} - feedInfo={feedInfo} - headerHeight={headerHeight} - scrollElRef={ - scrollElRef as React.MutableRefObject<ScrollView | null> - } - isOwner={feedInfo.creatorDid === currentAccount?.did} - /> - )} </PagerWithHeader> {hasSession && ( <FAB @@ -504,21 +505,14 @@ function AboutSection({ feedOwnerDid, feedRkey, feedInfo, - headerHeight, - scrollElRef, - isOwner, }: { feedOwnerDid: string feedRkey: string feedInfo: FeedSourceFeedInfo - headerHeight: number - scrollElRef: React.MutableRefObject<ScrollView | null> - isOwner: boolean }) { + const t = useTheme() const pal = usePalette('default') const {_} = useLingui() - const scrollHandlers = useScrollHandlers() - const onScroll = useAnimatedScrollHandler(scrollHandlers) const [likeUri, setLikeUri] = React.useState(feedInfo.likeUri) const {hasSession} = useSession() const {track} = useAnalytics() @@ -554,80 +548,47 @@ function AboutSection({ }, [likeUri, isLiked, feedInfo, likeFeed, unlikeFeed, track, _]) return ( - <ScrollView - ref={scrollElRef} - onScroll={onScroll} - scrollEventThrottle={1} - contentContainerStyle={{ - paddingTop: headerHeight, - minHeight: Dimensions.get('window').height * 1.5, - }}> - <View - style={[ - { - borderTopWidth: 1, - paddingVertical: 20, - paddingHorizontal: 20, - gap: 12, - }, - pal.border, - ]}> + <View style={[styles.aboutSectionContainer]}> + <View style={[a.pt_sm]}> {feedInfo.description ? ( <RichText testID="listDescription" - type="lg" - style={pal.text} - richText={feedInfo.description} + style={[a.text_md]} + value={feedInfo.description} /> ) : ( <Text type="lg" style={[{fontStyle: 'italic'}, pal.textLight]}> <Trans>No description</Trans> </Text> )} - <View style={{flexDirection: 'row', alignItems: 'center', gap: 10}}> - <Button - type="default" - testID="toggleLikeBtn" - accessibilityLabel={_(msg`Like this feed`)} - accessibilityHint="" - disabled={!hasSession || isLikePending || isUnlikePending} - onPress={onToggleLiked} - style={{paddingHorizontal: 10}}> - {isLiked ? ( - <HeartIconSolid size={19} style={s.likeColor} /> - ) : ( - <HeartIcon strokeWidth={3} size={19} style={pal.textLight} /> - )} - </Button> - {typeof likeCount === 'number' && ( - <TextLink - href={makeCustomFeedLink(feedOwnerDid, feedRkey, 'liked-by')} - text={_( - msg`Liked by ${likeCount} ${pluralize(likeCount, 'user')}`, - )} - style={[pal.textLight, s.semiBold]} - /> - )} - </View> - <Text type="md" style={[pal.textLight]} numberOfLines={1}> - {isOwner ? ( - <Trans>Created by you</Trans> + </View> + + <View style={[a.flex_row, a.gap_sm, a.align_center, a.pb_sm]}> + <NewButton + size="small" + variant="solid" + color="secondary" + shape="round" + label={isLiked ? _(msg`Unlike this feed`) : _(msg`Like this feed`)} + testID="toggleLikeBtn" + disabled={!hasSession || isLikePending || isUnlikePending} + onPress={onToggleLiked}> + {isLiked ? ( + <HeartFilled size="md" fill={s.likeColor.color} /> ) : ( - <Trans> - Created by{' '} - <TextLink - text={sanitizeHandle(feedInfo.creatorHandle, '@')} - href={makeProfileLink({ - did: feedInfo.creatorDid, - handle: feedInfo.creatorHandle, - })} - style={pal.textLight} - /> - </Trans> + <HeartOutline size="md" fill={t.atoms.text_contrast_medium.color} /> )} - </Text> + </NewButton> + {typeof likeCount === 'number' && ( + <InlineLink + label={_(msg`View users who like this feed`)} + to={makeCustomFeedLink(feedOwnerDid, feedRkey, 'liked-by')} + style={[t.atoms.text_contrast_medium, a.font_bold]}> + {_(msg`Liked by ${likeCount} ${pluralize(likeCount, 'user')}`)} + </InlineLink> + )} </View> - </ScrollView> + </View> ) } @@ -647,4 +608,9 @@ const styles = StyleSheet.create({ paddingVertical: 14, borderRadius: 6, }, + aboutSectionContainer: { + paddingVertical: 4, + paddingHorizontal: 16, + gap: 12, + }, }) diff --git a/src/view/screens/ProfileFollowers.tsx b/src/view/screens/ProfileFollowers.tsx index 2cad08cb5..6f8ecc2e8 100644 --- a/src/view/screens/ProfileFollowers.tsx +++ b/src/view/screens/ProfileFollowers.tsx @@ -21,7 +21,7 @@ export const ProfileFollowersScreen = ({route}: Props) => { ) return ( - <View> + <View style={{flex: 1}}> <ViewHeader title={_(msg`Followers`)} /> <ProfileFollowersComponent name={name} /> </View> diff --git a/src/view/screens/ProfileFollows.tsx b/src/view/screens/ProfileFollows.tsx index 80502b98b..bdab20153 100644 --- a/src/view/screens/ProfileFollows.tsx +++ b/src/view/screens/ProfileFollows.tsx @@ -21,7 +21,7 @@ export const ProfileFollowsScreen = ({route}: Props) => { ) return ( - <View> + <View style={{flex: 1}}> <ViewHeader title={_(msg`Following`)} /> <ProfileFollowsComponent name={name} /> </View> diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx index 796464883..58b89f239 100644 --- a/src/view/screens/ProfileList.tsx +++ b/src/view/screens/ProfileList.tsx @@ -14,7 +14,7 @@ import {NativeDropdown, DropdownItem} from 'view/com/util/forms/NativeDropdown' import {CenteredView} from 'view/com/util/Views' import {EmptyState} from 'view/com/util/EmptyState' import {LoadingScreen} from 'view/com/util/LoadingScreen' -import {RichText} from 'view/com/util/text/RichText' +import {RichText} from '#/components/RichText' import {Button} from 'view/com/util/forms/Button' import {TextLink} from 'view/com/util/Link' import {ListRef} from 'view/com/util/List' @@ -39,6 +39,7 @@ import {Trans, msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useSetMinimalShellMode} from '#/state/shell' import {useModalControls} from '#/state/modals' +import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog' import {useResolveUriQuery} from '#/state/queries/resolve-uri' import { useListQuery, @@ -60,6 +61,9 @@ import { import {logger} from '#/logger' import {useAnalytics} from '#/lib/analytics/analytics' import {listenSoftReset} from '#/state/events' +import {atoms as a, useTheme} from '#/alf' +import * as Prompt from '#/components/Prompt' +import {useDialogControl} from '#/components/Dialog' const SECTION_TITLES_CURATE = ['Posts', 'About'] const SECTION_TITLES_MOD = ['About'] @@ -233,7 +237,8 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { const {_} = useLingui() const navigation = useNavigation<NavigationProp>() const {currentAccount} = useSession() - const {openModal, closeModal} = useModalControls() + const reportDialogControl = useReportDialogControl() + const {openModal} = useModalControls() const listMuteMutation = useListMuteMutation() const listBlockMutation = useListBlockMutation() const listDeleteMutation = useListDeleteMutation() @@ -250,6 +255,10 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { const {mutate: setSavedFeeds} = useSetSaveFeedsMutation() const {track} = useAnalytics() + const deleteListPromptControl = useDialogControl() + const subscribeMutePromptControl = useDialogControl() + const subscribeBlockPromptControl = useDialogControl() + const isPinned = preferences?.feeds?.pinned?.includes(list.uri) const isSaved = preferences?.feeds?.saved?.includes(list.uri) @@ -268,32 +277,19 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { } }, [list.uri, isPinned, pinFeed, unpinFeed, _]) - const onSubscribeMute = useCallback(() => { - openModal({ - name: 'confirm', - title: _(msg`Mute these accounts?`), - message: _( - msg`Muting is private. Muted accounts can interact with you, but you will not see their posts or receive notifications from them.`, - ), - confirmBtnText: _(msg`Mute this List`), - async onPressConfirm() { - try { - await listMuteMutation.mutateAsync({uri: list.uri, mute: true}) - Toast.show(_(msg`List muted`)) - track('Lists:Mute') - } catch { - Toast.show( - _( - msg`There was an issue. Please check your internet connection and try again.`, - ), - ) - } - }, - onPressCancel() { - closeModal() - }, - }) - }, [openModal, closeModal, list, listMuteMutation, track, _]) + const onSubscribeMute = useCallback(async () => { + try { + await listMuteMutation.mutateAsync({uri: list.uri, mute: true}) + Toast.show(_(msg`List muted`)) + track('Lists:Mute') + } catch { + Toast.show( + _( + msg`There was an issue. Please check your internet connection and try again.`, + ), + ) + } + }, [list, listMuteMutation, track, _]) const onUnsubscribeMute = useCallback(async () => { try { @@ -309,32 +305,19 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { } }, [list, listMuteMutation, track, _]) - const onSubscribeBlock = useCallback(() => { - openModal({ - name: 'confirm', - title: _(msg`Block these accounts?`), - message: _( - msg`Blocking is public. Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`, - ), - confirmBtnText: _(msg`Block this List`), - async onPressConfirm() { - try { - await listBlockMutation.mutateAsync({uri: list.uri, block: true}) - Toast.show(_(msg`List blocked`)) - track('Lists:Block') - } catch { - Toast.show( - _( - msg`There was an issue. Please check your internet connection and try again.`, - ), - ) - } - }, - onPressCancel() { - closeModal() - }, - }) - }, [openModal, closeModal, list, listBlockMutation, track, _]) + const onSubscribeBlock = useCallback(async () => { + try { + await listBlockMutation.mutateAsync({uri: list.uri, block: true}) + Toast.show(_(msg`List blocked`)) + track('Lists:Block') + } catch { + Toast.show( + _( + msg`There was an issue. Please check your internet connection and try again.`, + ), + ) + } + }, [list, listBlockMutation, track, _]) const onUnsubscribeBlock = useCallback(async () => { try { @@ -357,34 +340,26 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { }) }, [openModal, list]) - const onPressDelete = useCallback(() => { - openModal({ - name: 'confirm', - title: _(msg`Delete List`), - message: _(msg`Are you sure?`), - async onPressConfirm() { - await listDeleteMutation.mutateAsync({uri: list.uri}) - - if (isSaved || isPinned) { - const {saved, pinned} = preferences!.feeds - - setSavedFeeds({ - saved: isSaved ? saved.filter(uri => uri !== list.uri) : saved, - pinned: isPinned ? pinned.filter(uri => uri !== list.uri) : pinned, - }) - } + const onPressDelete = useCallback(async () => { + await listDeleteMutation.mutateAsync({uri: list.uri}) - Toast.show(_(msg`List deleted`)) - track('Lists:Delete') - if (navigation.canGoBack()) { - navigation.goBack() - } else { - navigation.navigate('Home') - } - }, - }) + if (isSaved || isPinned) { + const {saved, pinned} = preferences!.feeds + + setSavedFeeds({ + saved: isSaved ? saved.filter(uri => uri !== list.uri) : saved, + pinned: isPinned ? pinned.filter(uri => uri !== list.uri) : pinned, + }) + } + + Toast.show(_(msg`List deleted`)) + track('Lists:Delete') + if (navigation.canGoBack()) { + navigation.goBack() + } else { + navigation.navigate('Home') + } }, [ - openModal, list, listDeleteMutation, navigation, @@ -397,12 +372,8 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { ]) const onPressReport = useCallback(() => { - openModal({ - name: 'report', - uri: list.uri, - cid: list.cid, - }) - }, [openModal, list]) + reportDialogControl.open() + }, [reportDialogControl]) const onPressShare = useCallback(() => { const url = toShareUrl(`/profile/${list.creator.did}/lists/${rkey}`) @@ -442,7 +413,7 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { items.push({ testID: 'listHeaderDropdownDeleteBtn', label: _(msg`Delete List`), - onPress: onPressDelete, + onPress: deleteListPromptControl.open, icon: { ios: { name: 'trash', @@ -488,7 +459,9 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { items.push({ testID: 'listHeaderDropdownMuteBtn', label: isMuting ? _(msg`Un-mute list`) : _(msg`Mute list`), - onPress: isMuting ? onUnsubscribeMute : onSubscribeMute, + onPress: isMuting + ? onUnsubscribeMute + : subscribeMutePromptControl.open, icon: { ios: { name: isMuting ? 'eye' : 'eye.slash', @@ -503,7 +476,9 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { items.push({ testID: 'listHeaderDropdownBlockBtn', label: isBlocking ? _(msg`Un-block list`) : _(msg`Block list`), - onPress: isBlocking ? onUnsubscribeBlock : onSubscribeBlock, + onPress: isBlocking + ? onUnsubscribeBlock + : subscribeBlockPromptControl.open, icon: { ios: { name: 'person.fill.xmark', @@ -516,24 +491,24 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { } return items }, [ - isOwner, - onPressShare, - onPressEdit, - onPressDelete, - onPressReport, _, + onPressShare, + isOwner, isModList, isPinned, - unpinFeed, + isCurateList, + onPressEdit, + deleteListPromptControl.open, + onPressReport, isPending, + unpinFeed, list.uri, - isCurateList, - isMuting, isBlocking, + isMuting, onUnsubscribeMute, - onSubscribeMute, + subscribeMutePromptControl.open, onUnsubscribeBlock, - onSubscribeBlock, + subscribeBlockPromptControl.open, ]) const subscribeDropdownItems: DropdownItem[] = useMemo(() => { @@ -541,7 +516,7 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { { testID: 'subscribeDropdownMuteBtn', label: _(msg`Mute accounts`), - onPress: onSubscribeMute, + onPress: subscribeMutePromptControl.open, icon: { ios: { name: 'speaker.slash', @@ -553,7 +528,7 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { { testID: 'subscribeDropdownBlockBtn', label: _(msg`Block accounts`), - onPress: onSubscribeBlock, + onPress: subscribeBlockPromptControl.open, icon: { ios: { name: 'person.fill.xmark', @@ -563,7 +538,7 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { }, }, ] - }, [onSubscribeMute, onSubscribeBlock, _]) + }, [_, subscribeMutePromptControl.open, subscribeBlockPromptControl.open]) return ( <ProfileSubpageHeader @@ -573,6 +548,14 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { isOwner={list.creator.did === currentAccount?.did} creator={list.creator} avatarType="list"> + <ReportDialog + control={reportDialogControl} + params={{ + type: 'list', + uri: list.uri, + cid: list.cid, + }} + /> {isCurateList || isPinned ? ( <Button testID={isPinned ? 'unpinBtn' : 'pinBtn'} @@ -619,6 +602,38 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { <FontAwesomeIcon icon="ellipsis" size={20} color={pal.colors.text} /> </View> </NativeDropdown> + + <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" + /> + + <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" + /> </ProfileSubpageHeader> ) } @@ -698,6 +713,7 @@ const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>( ref, ) { const pal = usePalette('default') + const t = useTheme() const {_} = useLingui() const {isMobile} = useWebMediaQueries() const {currentAccount} = useSession() @@ -742,9 +758,8 @@ const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>( {descriptionRT ? ( <RichText testID="listDescription" - type="lg" - style={pal.text} - richText={descriptionRT} + style={[a.text_md]} + value={descriptionRT} /> ) : ( <Text @@ -792,7 +807,7 @@ const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>( paddingBottom: isMobile ? 14 : 18, }, ]}> - <Text type="lg-bold"> + <Text type="lg-bold" style={t.atoms.text}> <Trans>Users</Trans> </Text> {isOwner && ( @@ -817,14 +832,18 @@ const AboutSection = React.forwardRef<SectionRef, AboutSectionProps>( </View> ) }, [ - pal, - list, isMobile, + pal.border, + pal.textLight, + pal.colors.link, + pal.link, descriptionRT, isCurateList, isOwner, - onPressAddUser, + list.creator, + t.atoms.text, _, + onPressAddUser, ]) const renderEmptyState = useCallback(() => { @@ -894,7 +913,7 @@ function ErrorScreen({error}: {error: string}) { <View style={{flexDirection: 'row'}}> <Button type="default" - accessibilityLabel={_(msg`Go Back`)} + accessibilityLabel={_(msg`Go back`)} accessibilityHint={_(msg`Return to previous page`)} onPress={onPressBack} style={{flexShrink: 1}}> diff --git a/src/view/screens/Search/Search.tsx b/src/view/screens/Search/Search.tsx index 142726701..c0f4cf195 100644 --- a/src/view/screens/Search/Search.tsx +++ b/src/view/screens/Search/Search.tsx @@ -1,58 +1,60 @@ import React from 'react' import { - View, - StyleSheet, ActivityIndicator, - TextInput, - Pressable, Platform, + Pressable, + StyleSheet, + TextInput, + View, } from 'react-native' -import {ScrollView, CenteredView} from '#/view/com/util/Views' -import {List} from '#/view/com/util/List' import {AppBskyActorDefs, AppBskyFeedDefs, moderateProfile} from '@atproto/api' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' import { FontAwesomeIcon, FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' -import {useFocusEffect} from '@react-navigation/native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import AsyncStorage from '@react-native-async-storage/async-storage' +import {useFocusEffect, useNavigation} from '@react-navigation/native' +import {useAnalytics} from '#/lib/analytics/analytics' +import {HITSLOP_10} from '#/lib/constants' +import {usePalette} from '#/lib/hooks/usePalette' +import {MagnifyingGlassIcon} from '#/lib/icons' +import {NavigationProp} from '#/lib/routes/types' +import {augmentSearchQuery} from '#/lib/strings/helpers' +import {s} from '#/lib/styles' import {logger} from '#/logger' +import {isNative, isWeb} from '#/platform/detection' +import {listenSoftReset} from '#/state/events' +import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete' +import {useActorSearch} from '#/state/queries/actor-search' +import {useModerationOpts} from '#/state/queries/preferences' +import {useSearchPostsQuery} from '#/state/queries/search-posts' +import {useGetSuggestedFollowersByActor} from '#/state/queries/suggested-follows' +import {useSession} from '#/state/session' +import {useSetDrawerOpen} from '#/state/shell' +import {useSetDrawerSwipeDisabled, useSetMinimalShellMode} from '#/state/shell' +import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import { NativeStackScreenProps, SearchTabNavigatorParams, } from 'lib/routes/types' -import {Text} from '#/view/com/util/text/Text' -import {ProfileCardFeedLoadingPlaceholder} from 'view/com/util/LoadingPlaceholder' -import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard' -import {Post} from '#/view/com/post/Post' +import {useTheme} from 'lib/ThemeContext' import {Pager} from '#/view/com/pager/Pager' import {TabBar} from '#/view/com/pager/TabBar' -import {HITSLOP_10} from '#/lib/constants' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {usePalette} from '#/lib/hooks/usePalette' -import {useTheme} from 'lib/ThemeContext' -import {useSession} from '#/state/session' -import {useGetSuggestedFollowersByActor} from '#/state/queries/suggested-follows' -import {useSearchPostsQuery} from '#/state/queries/search-posts' -import {useActorSearch} from '#/state/queries/actor-search' -import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete' -import {useSetDrawerOpen} from '#/state/shell' -import {useAnalytics} from '#/lib/analytics/analytics' -import {MagnifyingGlassIcon} from '#/lib/icons' -import {useModerationOpts} from '#/state/queries/preferences' +import {Post} from '#/view/com/post/Post' +import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard' +import {List} from '#/view/com/util/List' +import {Text} from '#/view/com/util/text/Text' +import {CenteredView, ScrollView} from '#/view/com/util/Views' import { MATCH_HANDLE, SearchLinkCard, SearchProfileCard, } from '#/view/shell/desktop/Search' -import {useSetMinimalShellMode, useSetDrawerSwipeDisabled} from '#/state/shell' -import {isNative, isWeb} from '#/platform/detection' -import {listenSoftReset} from '#/state/events' -import {s} from '#/lib/styles' -import AsyncStorage from '@react-native-async-storage/async-storage' -import {augmentSearchQuery} from '#/lib/strings/helpers' +import {ProfileCardFeedLoadingPlaceholder} from 'view/com/util/LoadingPlaceholder' +import {atoms as a} from '#/alf' function Loader() { const pal = usePalette('default') @@ -140,6 +142,7 @@ function SearchScreenSuggestedFollows() { friends.slice(0, 4).map(friend => getSuggestedFollowsByActor(friend.did).then(foafsRes => { for (const user of foafsRes.suggestions) { + if (user.associated?.labeler) continue friendsOfFriends.set(user.did, user) } }), @@ -448,6 +451,7 @@ export function SearchScreenInner({ export function SearchScreen( props: NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>, ) { + const navigation = useNavigation<NavigationProp>() const theme = useTheme() const textInput = React.useRef<TextInput>(null) const {_} = useLingui() @@ -472,6 +476,27 @@ export function SearchScreen( React.useState(false) const [searchHistory, setSearchHistory] = React.useState<string[]>([]) + /** + * The Search screen's `q` param + */ + const queryParam = props.route?.params?.q + + /** + * If `true`, this means we received new instructions from the router. This + * is handled in a effect, and used to update the value of `query` locally + * within this screen. + */ + const routeParamsMismatch = queryParam && queryParam !== query + + React.useEffect(() => { + if (queryParam && routeParamsMismatch) { + // reset immediately and let local state take over + navigation.setParams({q: ''}) + // update query for next search + setQuery(queryParam) + } + }, [queryParam, routeParamsMismatch, navigation]) + React.useEffect(() => { const loadSearchHistory = async () => { try { @@ -749,19 +774,27 @@ export function SearchScreen( {searchHistory.length > 0 && ( <View style={styles.searchHistoryContent}> <Text style={[pal.text, styles.searchHistoryTitle]}> - Recent Searches + <Trans>Recent Searches</Trans> </Text> {searchHistory.map((historyItem, index) => ( - <View key={index} style={styles.historyItemContainer}> + <View + key={index} + style={[ + a.flex_row, + a.mt_md, + a.justify_center, + a.justify_between, + ]}> <Pressable accessibilityRole="button" onPress={() => handleHistoryItemClick(historyItem)} - style={styles.historyItem}> + style={[a.flex_1, a.py_sm]}> <Text style={pal.text}>{historyItem}</Text> </Pressable> <Pressable accessibilityRole="button" - onPress={() => handleRemoveHistoryItem(historyItem)}> + onPress={() => handleRemoveHistoryItem(historyItem)} + style={[a.px_md, a.py_xs, a.justify_center]}> <FontAwesomeIcon icon="xmark" size={16} @@ -774,6 +807,8 @@ export function SearchScreen( )} </View> </CenteredView> + ) : routeParamsMismatch ? ( + <ActivityIndicator /> ) : ( <SearchScreenInner query={query} /> )} @@ -846,13 +881,4 @@ const styles = StyleSheet.create({ searchHistoryTitle: { fontWeight: 'bold', }, - historyItem: { - paddingVertical: 8, - }, - historyItemContainer: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - paddingVertical: 8, - }, }) diff --git a/src/view/screens/Settings/ExportCarDialog.tsx b/src/view/screens/Settings/ExportCarDialog.tsx index 720cd4f09..ba8fad2df 100644 --- a/src/view/screens/Settings/ExportCarDialog.tsx +++ b/src/view/screens/Settings/ExportCarDialog.tsx @@ -76,10 +76,11 @@ export function ExportCarDialog({ This feature is in beta. You can read more about repository exports in{' '} <InlineLink - to="https://atproto.com/blog/repo-export" + to="https://docs.bsky.app/blog/repo-export" style={[a.text_sm]}> - this blogpost. + this blogpost </InlineLink> + . </Trans> </P> diff --git a/src/view/screens/Settings/index.tsx b/src/view/screens/Settings/index.tsx index 9abf0f2bd..790ce5ee9 100644 --- a/src/view/screens/Settings/index.tsx +++ b/src/view/screens/Settings/index.tsx @@ -3,70 +3,71 @@ import { ActivityIndicator, Linking, Platform, - StyleSheet, Pressable, + StyleSheet, TextStyle, TouchableOpacity, View, ViewStyle, } from 'react-native' -import {useFocusEffect, useNavigation} from '@react-navigation/native' import { FontAwesomeIcon, FontAwesomeIconStyle, } from '@fortawesome/react-native-fontawesome' -import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types' -import * as AppInfo from 'lib/app-info' -import {usePalette} from 'lib/hooks/usePalette' -import {useCustomPalette} from 'lib/hooks/useCustomPalette' -import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' -import {useAccountSwitcher} from 'lib/hooks/useAccountSwitcher' -import {useAnalytics} from 'lib/analytics/analytics' -import {NavigationProp} from 'lib/routes/types' -import {HandIcon, HashtagIcon} from 'lib/icons' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' import Clipboard from '@react-native-clipboard/clipboard' -import {makeProfileLink} from 'lib/routes/links' -import {RQKEY as RQKEY_PROFILE} from '#/state/queries/profile' +import {useFocusEffect, useNavigation} from '@react-navigation/native' +import {useQueryClient} from '@tanstack/react-query' + +import {isNative} from '#/platform/detection' import {useModalControls} from '#/state/modals' -import { - useSetMinimalShellMode, - useThemePrefs, - useSetThemePrefs, - useOnboardingDispatch, -} from '#/state/shell' +import {clearLegacyStorage} from '#/state/persisted/legacy' +// TODO import {useInviteCodesQuery} from '#/state/queries/invites' +import {clear as clearStorage} from '#/state/persisted/store' import { useRequireAltTextEnabled, useSetRequireAltTextEnabled, } from '#/state/preferences' -import {useSession, useSessionApi, SessionAccount} from '#/state/session' -import {useProfileQuery} from '#/state/queries/profile' -import {useClearPreferencesMutation} from '#/state/queries/preferences' -// TODO import {useInviteCodesQuery} from '#/state/queries/invites' -import {clear as clearStorage} from '#/state/persisted/store' -import {clearLegacyStorage} from '#/state/persisted/legacy' -import {STATUS_PAGE_URL} from 'lib/constants' -import {Trans, msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {useQueryClient} from '@tanstack/react-query' -import {useLoggedOutViewControls} from '#/state/shell/logged-out' -import {useCloseAllActiveElements} from '#/state/util' import { useInAppBrowser, useSetInAppBrowser, } from '#/state/preferences/in-app-browser' -import {isNative} from '#/platform/detection' -import {useDialogControl} from '#/components/Dialog' - -import {s, colors} from 'lib/styles' -import {ScrollView} from 'view/com/util/Views' +import {useClearPreferencesMutation} from '#/state/queries/preferences' +import {RQKEY as RQKEY_PROFILE} from '#/state/queries/profile' +import {useProfileQuery} from '#/state/queries/profile' +import {SessionAccount, useSession, useSessionApi} from '#/state/session' +import { + useOnboardingDispatch, + useSetMinimalShellMode, + useSetThemePrefs, + useThemePrefs, +} from '#/state/shell' +import {useLoggedOutViewControls} from '#/state/shell/logged-out' +import {useCloseAllActiveElements} from '#/state/util' +import {useAnalytics} from 'lib/analytics/analytics' +import * as AppInfo from 'lib/app-info' +import {STATUS_PAGE_URL} from 'lib/constants' +import {useAccountSwitcher} from 'lib/hooks/useAccountSwitcher' +import {useCustomPalette} from 'lib/hooks/useCustomPalette' +import {usePalette} from 'lib/hooks/usePalette' +import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' +import {HandIcon, HashtagIcon} from 'lib/icons' +import {makeProfileLink} from 'lib/routes/links' +import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types' +import {NavigationProp} from 'lib/routes/types' +import {colors, s} from 'lib/styles' +import {AccountDropdownBtn} from 'view/com/util/AccountDropdownBtn' +import {SelectableBtn} from 'view/com/util/forms/SelectableBtn' +import {ToggleButton} from 'view/com/util/forms/ToggleButton' import {Link, TextLink} from 'view/com/util/Link' +import {SimpleViewHeader} from 'view/com/util/SimpleViewHeader' import {Text} from 'view/com/util/text/Text' import * as Toast from 'view/com/util/Toast' import {UserAvatar} from 'view/com/util/UserAvatar' -import {ToggleButton} from 'view/com/util/forms/ToggleButton' -import {SelectableBtn} from 'view/com/util/forms/SelectableBtn' -import {AccountDropdownBtn} from 'view/com/util/AccountDropdownBtn' -import {SimpleViewHeader} from 'view/com/util/SimpleViewHeader' +import {ScrollView} from 'view/com/util/Views' +import {useDialogControl} from '#/components/Dialog' +import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings' import {ExportCarDialog} from './ExportCarDialog' function SettingsAccountCard({account}: {account: SessionAccount}) { @@ -81,7 +82,11 @@ function SettingsAccountCard({account}: {account: SessionAccount}) { const contents = ( <View style={[pal.view, styles.linkCard]}> <View style={styles.avi}> - <UserAvatar size={40} avatar={profile?.avatar} /> + <UserAvatar + size={40} + avatar={profile?.avatar} + type={profile?.associated?.labeler ? 'labeler' : 'user'} + /> </View> <View style={[s.flex1]}> <Text type="md-bold" style={pal.text}> @@ -95,7 +100,9 @@ function SettingsAccountCard({account}: {account: SessionAccount}) { {isCurrentAccount ? ( <TouchableOpacity testID="signOutBtn" - onPress={logout} + onPress={() => { + logout('Settings') + }} accessibilityRole="button" accessibilityLabel={_(msg`Sign out`)} accessibilityHint={`Signs ${profile?.displayName} out of Bluesky`}> @@ -124,7 +131,9 @@ function SettingsAccountCard({account}: {account: SessionAccount}) { testID={`switchToAccountBtn-${account.handle}`} key={account.did} onPress={ - isSwitchingAccounts ? undefined : () => onPressSwitchAccount(account) + isSwitchingAccounts + ? undefined + : () => onPressSwitchAccount(account, 'Settings') } accessibilityRole="button" accessibilityLabel={_(msg`Switch to ${account.handle}`)} @@ -159,6 +168,7 @@ export function SettingsScreen({}: Props) { const {setShowLoggedOut} = useLoggedOutViewControls() const closeAllActiveElements = useCloseAllActiveElements() const exportCarControl = useDialogControl() + const birthdayControl = useDialogControl() // const primaryBg = useCustomPalette<ViewStyle>({ // light: {backgroundColor: colors.blue0}, @@ -241,8 +251,8 @@ export function SettingsScreen({}: Props) { Toast.show(_(msg`Copied build version to clipboard`)) }, [_]) - const openHomeFeedPreferences = React.useCallback(() => { - navigation.navigate('PreferencesHomeFeed') + const openFollowingFeedPreferences = React.useCallback(() => { + navigation.navigate('PreferencesFollowingFeed') }, [navigation]) const openThreadsPreferences = React.useCallback(() => { @@ -261,6 +271,10 @@ export function SettingsScreen({}: Props) { navigation.navigate('Debug') }, [navigation]) + const onPressDebugModeration = React.useCallback(() => { + navigation.navigate('DebugMod') + }, [navigation]) + const onPressSavedFeeds = React.useCallback(() => { navigation.navigate('SavedFeeds') }, [navigation]) @@ -269,6 +283,10 @@ export function SettingsScreen({}: Props) { Linking.openURL(STATUS_PAGE_URL) }, []) + const onPressBirthday = React.useCallback(() => { + birthdayControl.open() + }, [birthdayControl]) + const clearAllStorage = React.useCallback(async () => { await clearStorage() Toast.show(_(msg`Storage cleared, you need to restart the app now.`)) @@ -281,6 +299,7 @@ export function SettingsScreen({}: Props) { return ( <View style={s.hContentRegion} testID="settingsScreen"> <ExportCarDialog control={exportCarControl} /> + <BirthDateSettingsDialog control={birthdayControl} /> <SimpleViewHeader showBackButton={isMobile} @@ -339,7 +358,7 @@ export function SettingsScreen({}: Props) { <Text type="lg-medium" style={pal.text}> <Trans>Birthday:</Trans>{' '} </Text> - <Link onPress={() => openModal({name: 'birth-date-settings'})}> + <Link onPress={onPressBirthday}> <Text type="lg" style={pal.link}> <Trans>Show</Trans> </Text> @@ -472,20 +491,20 @@ export function SettingsScreen({}: Props) { label={_(msg`System`)} left onSelect={() => setColorMode('system')} - accessibilityHint={_(msg`Set color theme to system setting`)} + accessibilityHint={_(msg`Sets color theme to system setting`)} /> <SelectableBtn selected={colorMode === 'light'} label={_(msg`Light`)} onSelect={() => setColorMode('light')} - accessibilityHint={_(msg`Set color theme to light`)} + accessibilityHint={_(msg`Sets color theme to light`)} /> <SelectableBtn selected={colorMode === 'dark'} label={_(msg`Dark`)} right onSelect={() => setColorMode('dark')} - accessibilityHint={_(msg`Set color theme to dark`)} + accessibilityHint={_(msg`Sets color theme to dark`)} /> </View> </View> @@ -504,14 +523,14 @@ export function SettingsScreen({}: Props) { label={_(msg`Dim`)} left onSelect={() => setDarkTheme('dim')} - accessibilityHint={_(msg`Set dark theme to the dim theme`)} + accessibilityHint={_(msg`Sets dark theme to the dim theme`)} /> <SelectableBtn selected={darkTheme === 'dark'} label={_(msg`Dark`)} right onSelect={() => setDarkTheme('dark')} - accessibilityHint={_(msg`Set dark theme to the dark theme`)} + accessibilityHint={_(msg`Sets dark theme to the dark theme`)} /> </View> </View> @@ -529,10 +548,10 @@ export function SettingsScreen({}: Props) { pal.view, isSwitchingAccounts && styles.dimmed, ]} - onPress={openHomeFeedPreferences} + onPress={openFollowingFeedPreferences} accessibilityRole="button" - accessibilityHint="" - accessibilityLabel={_(msg`Opens the home feed preferences`)}> + accessibilityLabel={_(msg`Following feed preferences`)} + accessibilityHint={_(msg`Opens the Following feed preferences`)}> <View style={[styles.iconContainer, pal.btn]}> <FontAwesomeIcon icon="sliders" @@ -540,7 +559,7 @@ export function SettingsScreen({}: Props) { /> </View> <Text type="lg" style={pal.text}> - <Trans>Home Feed Preferences</Trans> + <Trans>Following Feed Preferences</Trans> </Text> </TouchableOpacity> <TouchableOpacity @@ -552,8 +571,8 @@ export function SettingsScreen({}: Props) { ]} onPress={openThreadsPreferences} accessibilityRole="button" - accessibilityHint="" - accessibilityLabel={_(msg`Opens the threads preferences`)}> + accessibilityLabel={_(msg`Thread preferences`)} + accessibilityHint={_(msg`Opens the threads preferences`)}> <View style={[styles.iconContainer, pal.btn]}> <FontAwesomeIcon icon={['far', 'comments']} @@ -572,9 +591,10 @@ export function SettingsScreen({}: Props) { pal.view, isSwitchingAccounts && styles.dimmed, ]} - accessibilityHint="My Saved Feeds" - accessibilityLabel={_(msg`Opens screen with all saved feeds`)} - onPress={onPressSavedFeeds}> + onPress={onPressSavedFeeds} + accessibilityRole="button" + accessibilityLabel={_(msg`My saved feeds`)} + accessibilityHint={_(msg`Opens screen with all saved feeds`)}> <View style={[styles.iconContainer, pal.btn]}> <HashtagIcon style={pal.text} size={18} strokeWidth={3} /> </View> @@ -673,7 +693,7 @@ export function SettingsScreen({}: Props) { onPress={onPressAppPasswords} accessibilityRole="button" accessibilityLabel={_(msg`App password settings`)} - accessibilityHint={_(msg`Opens the app password settings page`)}> + accessibilityHint={_(msg`Opens the app password settings`)}> <View style={[styles.iconContainer, pal.btn]}> <FontAwesomeIcon icon="lock" @@ -694,7 +714,9 @@ export function SettingsScreen({}: Props) { onPress={isSwitchingAccounts ? undefined : onPressChangeHandle} accessibilityRole="button" accessibilityLabel={_(msg`Change handle`)} - accessibilityHint={_(msg`Choose a new Bluesky username or create`)}> + accessibilityHint={_( + msg`Opens modal for choosing a new Bluesky handle`, + )}> <View style={[styles.iconContainer, pal.btn]}> <FontAwesomeIcon icon="at" @@ -730,7 +752,9 @@ export function SettingsScreen({}: Props) { onPress={() => openModal({name: 'change-password'})} accessibilityRole="button" accessibilityLabel={_(msg`Change password`)} - accessibilityHint={_(msg`Change your Bluesky password`)}> + accessibilityHint={_( + msg`Opens modal for changing your Bluesky password`, + )}> <View style={[styles.iconContainer, pal.btn]}> <FontAwesomeIcon icon="lock" @@ -752,7 +776,7 @@ export function SettingsScreen({}: Props) { accessibilityRole="button" accessibilityLabel={_(msg`Export my data`)} accessibilityHint={_( - msg`Download Bluesky account data (repository)`, + msg`Opens modal for downloading your Bluesky account data (repository)`, )}> <View style={[styles.iconContainer, pal.btn]}> <FontAwesomeIcon @@ -771,7 +795,7 @@ export function SettingsScreen({}: Props) { accessibilityRole="button" accessibilityLabel={_(msg`Delete account`)} accessibilityHint={_( - msg`Opens modal for account deletion confirmation. Requires email code.`, + msg`Opens modal for account deletion confirmation. Requires email code`, )}> <View style={[styles.iconContainer, dangerBg]}> <FontAwesomeIcon @@ -789,8 +813,8 @@ export function SettingsScreen({}: Props) { style={[pal.view, styles.linkCardNoIcon]} onPress={onPressSystemLog} accessibilityRole="button" - accessibilityHint="Open system log" - accessibilityLabel={_(msg`Opens the system log page`)}> + accessibilityLabel={_(msg`Open system log`)} + accessibilityHint={_(msg`Opens the system log page`)}> <Text type="lg" style={pal.text}> <Trans>System log</Trans> </Text> @@ -809,9 +833,19 @@ export function SettingsScreen({}: Props) { </TouchableOpacity> <TouchableOpacity style={[pal.view, styles.linkCardNoIcon]} + onPress={onPressDebugModeration} + accessibilityRole="button" + accessibilityLabel={_(msg`Open storybook page`)} + accessibilityHint={_(msg`Opens the storybook page`)}> + <Text type="lg" style={pal.text}> + <Trans>Debug Moderation</Trans> + </Text> + </TouchableOpacity> + <TouchableOpacity + style={[pal.view, styles.linkCardNoIcon]} onPress={onPressResetPreferences} accessibilityRole="button" - accessibilityLabel={_(msg`Reset preferences`)} + accessibilityLabel={_(msg`Reset preferences state`)} accessibilityHint={_(msg`Resets the preferences state`)}> <Text type="lg" style={pal.text}> <Trans>Reset preferences state</Trans> @@ -821,7 +855,7 @@ export function SettingsScreen({}: Props) { style={[pal.view, styles.linkCardNoIcon]} onPress={onPressResetOnboarding} accessibilityRole="button" - accessibilityLabel={_(msg`Reset onboarding`)} + accessibilityLabel={_(msg`Reset onboarding state`)} accessibilityHint={_(msg`Resets the onboarding state`)}> <Text type="lg" style={pal.text}> <Trans>Reset onboarding state</Trans> @@ -832,7 +866,7 @@ export function SettingsScreen({}: Props) { onPress={clearAllLegacyStorage} accessibilityRole="button" accessibilityLabel={_(msg`Clear all legacy storage data`)} - accessibilityHint={_(msg`Clear all legacy storage data`)}> + accessibilityHint={_(msg`Clears all legacy storage data`)}> <Text type="lg" style={pal.text}> <Trans> Clear all legacy storage data (restart after this) @@ -844,7 +878,7 @@ export function SettingsScreen({}: Props) { onPress={clearAllStorage} accessibilityRole="button" accessibilityLabel={_(msg`Clear all storage data`)} - accessibilityHint={_(msg`Clear all storage data`)}> + accessibilityHint={_(msg`Clears all storage data`)}> <Text type="lg" style={pal.text}> <Trans>Clear all storage data (restart after this)</Trans> </Text> @@ -856,9 +890,7 @@ export function SettingsScreen({}: Props) { accessibilityRole="button" onPress={onPressBuildInfo}> <Text type="sm" style={[styles.buildInfo, pal.textLight]}> - <Trans> - Build version {AppInfo.appVersion} {AppInfo.updateChannel} - </Trans> + <Trans>Version {AppInfo.appVersion}</Trans> </Text> </TouchableOpacity> <Text type="sm" style={[pal.textLight]}> @@ -933,7 +965,7 @@ function EmailConfirmationNotice() { ]} accessibilityRole="button" accessibilityLabel={_(msg`Verify my email`)} - accessibilityHint="" + accessibilityHint={_(msg`Opens modal for email verification`)} onPress={() => openModal({name: 'verify-email'})}> <FontAwesomeIcon icon="envelope" diff --git a/src/view/screens/Storybook/Buttons.tsx b/src/view/screens/Storybook/Buttons.tsx index 320db13ff..ad2fff3f4 100644 --- a/src/view/screens/Storybook/Buttons.tsx +++ b/src/view/screens/Storybook/Buttons.tsx @@ -129,6 +129,15 @@ export function Buttons() { <ButtonIcon icon={Globe} position="left" /> <ButtonText>Link out</ButtonText> </Button> + + <Button + variant="gradient" + color="gradient_sky" + size="tiny" + label="Link out"> + <ButtonIcon icon={Globe} position="left" /> + <ButtonText>Link out</ButtonText> + </Button> </View> <View style={[a.flex_row, a.gap_md, a.align_start]}> @@ -149,6 +158,14 @@ export function Buttons() { <ButtonIcon icon={ChevronLeft} /> </Button> <Button + variant="gradient" + color="gradient_sunset" + size="tiny" + shape="round" + label="Link out"> + <ButtonIcon icon={ChevronLeft} /> + </Button> + <Button variant="outline" color="primary" size="large" @@ -164,6 +181,14 @@ export function Buttons() { label="Link out"> <ButtonIcon icon={ChevronLeft} /> </Button> + <Button + variant="ghost" + color="primary" + size="tiny" + shape="round" + label="Link out"> + <ButtonIcon icon={ChevronLeft} /> + </Button> </View> <View style={[a.flex_row, a.gap_md, a.align_start]}> @@ -184,6 +209,14 @@ export function Buttons() { <ButtonIcon icon={ChevronLeft} /> </Button> <Button + variant="gradient" + color="gradient_sunset" + size="tiny" + shape="square" + label="Link out"> + <ButtonIcon icon={ChevronLeft} /> + </Button> + <Button variant="outline" color="primary" size="large" @@ -199,6 +232,14 @@ export function Buttons() { label="Link out"> <ButtonIcon icon={ChevronLeft} /> </Button> + <Button + variant="ghost" + color="primary" + size="tiny" + shape="square" + label="Link out"> + <ButtonIcon icon={ChevronLeft} /> + </Button> </View> </View> ) diff --git a/src/view/screens/Storybook/Dialogs.tsx b/src/view/screens/Storybook/Dialogs.tsx index db568c6bd..c2eaf19ac 100644 --- a/src/view/screens/Storybook/Dialogs.tsx +++ b/src/view/screens/Storybook/Dialogs.tsx @@ -9,7 +9,8 @@ import * as Prompt from '#/components/Prompt' import {useDialogStateControlContext} from '#/state/dialogs' export function Dialogs() { - const control = Dialog.useDialogControl() + const scrollable = Dialog.useDialogControl() + const basic = Dialog.useDialogControl() const prompt = Prompt.usePromptControl() const {closeAllDialogs} = useDialogStateControlContext() @@ -20,8 +21,31 @@ export function Dialogs() { color="secondary" size="small" onPress={() => { - control.open() + scrollable.open() prompt.open() + basic.open() + }} + label="Open basic dialog"> + Open all dialogs + </Button> + + <Button + variant="outline" + color="secondary" + size="small" + onPress={() => { + scrollable.open() + }} + label="Open basic dialog"> + Open scrollable dialog + </Button> + + <Button + variant="outline" + color="secondary" + size="small" + onPress={() => { + basic.open() }} label="Open basic dialog"> Open basic dialog @@ -44,13 +68,22 @@ export function Dialogs() { </Prompt.Description> <Prompt.Actions> <Prompt.Cancel>Cancel</Prompt.Cancel> - <Prompt.Action>Confirm</Prompt.Action> + <Prompt.Action onPress={() => {}}>Confirm</Prompt.Action> </Prompt.Actions> </Prompt.Outer> + <Dialog.Outer control={basic}> + <Dialog.Handle /> + + <Dialog.Inner label="test"> + <H3 nativeID="dialog-title">Dialog</H3> + <P nativeID="dialog-description">A basic dialog</P> + </Dialog.Inner> + </Dialog.Outer> + <Dialog.Outer - control={control} - nativeOptions={{sheet: {snapPoints: ['90%']}}}> + control={scrollable} + nativeOptions={{sheet: {snapPoints: ['100%']}}}> <Dialog.Handle /> <Dialog.ScrollableInner @@ -77,9 +110,13 @@ export function Dialogs() { variant="outline" color="primary" size="small" - onPress={() => control.close()} + onPress={() => + scrollable.close(() => { + console.log('CLOSED') + }) + } label="Open basic dialog"> - Close basic dialog + Close dialog </Button> </View> </View> diff --git a/src/view/screens/Storybook/Icons.tsx b/src/view/screens/Storybook/Icons.tsx index 73466e077..9d7dc0aa8 100644 --- a/src/view/screens/Storybook/Icons.tsx +++ b/src/view/screens/Storybook/Icons.tsx @@ -6,6 +6,7 @@ import {H1} from '#/components/Typography' import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe' import {ArrowTopRight_Stroke2_Corner0_Rounded as ArrowTopRight} from '#/components/icons/ArrowTopRight' import {CalendarDays_Stroke2_Corner0_Rounded as CalendarDays} from '#/components/icons/CalendarDays' +import {Loader} from '#/components/Loader' export function Icons() { const t = useTheme() @@ -36,6 +37,14 @@ export function Icons() { <CalendarDays size="lg" fill={t.atoms.text.color} /> <CalendarDays size="xl" fill={t.atoms.text.color} /> </View> + + <View style={[a.flex_row, a.gap_xl]}> + <Loader size="xs" fill={t.atoms.text.color} /> + <Loader size="sm" fill={t.atoms.text.color} /> + <Loader size="md" fill={t.atoms.text.color} /> + <Loader size="lg" fill={t.atoms.text.color} /> + <Loader size="xl" fill={t.atoms.text.color} /> + </View> </View> ) } diff --git a/src/view/screens/Storybook/Links.tsx b/src/view/screens/Storybook/Links.tsx index 2828e74bf..f9ecfba55 100644 --- a/src/view/screens/Storybook/Links.tsx +++ b/src/view/screens/Storybook/Links.tsx @@ -4,7 +4,7 @@ import {View} from 'react-native' import {useTheme, atoms as a} from '#/alf' import {ButtonText} from '#/components/Button' import {InlineLink, Link} from '#/components/Link' -import {H1, H3, Text} from '#/components/Typography' +import {H1, Text} from '#/components/Typography' export function Links() { const t = useTheme() @@ -13,26 +13,19 @@ export function Links() { <H1>Links</H1> <View style={[a.gap_md, a.align_start]}> - <InlineLink - to="https://bsky.social" - warnOnMismatchingTextChild - style={[a.text_md]}> - External + <InlineLink to="https://google.com" style={[a.text_lg]}> + https://google.com </InlineLink> - <InlineLink to="https://bsky.social" style={[a.text_md]}> - <H3>External with custom children</H3> + <InlineLink to="https://google.com" style={[a.text_lg]}> + External with custom children (google.com) </InlineLink> <InlineLink to="https://bsky.social" - warnOnMismatchingTextChild - style={[a.text_lg]}> - https://bsky.social + style={[a.text_md, t.atoms.text_contrast_low]}> + Internal (bsky.social) </InlineLink> - <InlineLink - to="https://bsky.app/profile/bsky.app" - warnOnMismatchingTextChild - style={[a.text_md]}> - Internal + <InlineLink to="https://bsky.app/profile/bsky.app" style={[a.text_md]}> + Internal (bsky.app) </InlineLink> <Link diff --git a/src/view/screens/Storybook/Menus.tsx b/src/view/screens/Storybook/Menus.tsx new file mode 100644 index 000000000..2f2b14721 --- /dev/null +++ b/src/view/screens/Storybook/Menus.tsx @@ -0,0 +1,79 @@ +import React from 'react' +import {View} from 'react-native' + +import {atoms as a, useTheme} from '#/alf' +import {Text} from '#/components/Typography' +import * as Menu from '#/components/Menu' +import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2' +// import {useDialogStateControlContext} from '#/state/dialogs' + +export function Menus() { + const t = useTheme() + const menuControl = Menu.useMenuControl() + // const {closeAllDialogs} = useDialogStateControlContext() + + return ( + <View style={[a.gap_md]}> + <View style={[a.flex_row, a.align_start]}> + <Menu.Root control={menuControl}> + <Menu.Trigger label="Open basic menu"> + {({state, props}) => { + return ( + <Text + {...props} + style={[ + a.py_sm, + a.px_md, + a.rounded_sm, + t.atoms.bg_contrast_50, + (state.hovered || state.focused || state.pressed) && [ + t.atoms.bg_contrast_200, + ], + ]}> + Open + </Text> + ) + }} + </Menu.Trigger> + + <Menu.Outer> + <Menu.Group> + <Menu.Item label="Click me" onPress={() => {}}> + <Menu.ItemIcon icon={Search} /> + <Menu.ItemText>Click me</Menu.ItemText> + </Menu.Item> + + <Menu.Item + label="Another item" + onPress={() => menuControl.close()}> + <Menu.ItemText>Another item</Menu.ItemText> + </Menu.Item> + </Menu.Group> + + <Menu.Divider /> + + <Menu.Group> + <Menu.Item label="Click me" onPress={() => {}}> + <Menu.ItemIcon icon={Search} /> + <Menu.ItemText>Click me</Menu.ItemText> + </Menu.Item> + + <Menu.Item + label="Another item" + onPress={() => menuControl.close()}> + <Menu.ItemText>Another item</Menu.ItemText> + </Menu.Item> + </Menu.Group> + + <Menu.Divider /> + + <Menu.Item label="Click me" onPress={() => {}}> + <Menu.ItemIcon icon={Search} /> + <Menu.ItemText>Click me</Menu.ItemText> + </Menu.Item> + </Menu.Outer> + </Menu.Root> + </View> + </View> + ) +} diff --git a/src/view/screens/Storybook/Palette.tsx b/src/view/screens/Storybook/Palette.tsx index b521fe860..42000aa81 100644 --- a/src/view/screens/Storybook/Palette.tsx +++ b/src/view/screens/Storybook/Palette.tsx @@ -1,179 +1,185 @@ import React from 'react' import {View} from 'react-native' -import * as tokens from '#/alf/tokens' -import {atoms as a} from '#/alf' +import {atoms as a, useTheme} from '#/alf' export function Palette() { + const t = useTheme() return ( <View style={[a.gap_md]}> <View style={[a.flex_row, a.gap_md]}> - <View - style={[a.flex_1, {height: 60, backgroundColor: tokens.color.gray_0}]} - /> - <View - style={[ - a.flex_1, - {height: 60, backgroundColor: tokens.color.gray_25}, - ]} - /> + <View style={[a.flex_1, t.atoms.bg_contrast_25, {height: 60}]} /> + <View style={[a.flex_1, t.atoms.bg_contrast_50, {height: 60}]} /> + <View style={[a.flex_1, t.atoms.bg_contrast_100, {height: 60}]} /> + <View style={[a.flex_1, t.atoms.bg_contrast_200, {height: 60}]} /> + <View style={[a.flex_1, t.atoms.bg_contrast_300, {height: 60}]} /> + <View style={[a.flex_1, t.atoms.bg_contrast_400, {height: 60}]} /> + <View style={[a.flex_1, t.atoms.bg_contrast_500, {height: 60}]} /> + <View style={[a.flex_1, t.atoms.bg_contrast_600, {height: 60}]} /> + <View style={[a.flex_1, t.atoms.bg_contrast_700, {height: 60}]} /> + <View style={[a.flex_1, t.atoms.bg_contrast_800, {height: 60}]} /> + <View style={[a.flex_1, t.atoms.bg_contrast_900, {height: 60}]} /> + <View style={[a.flex_1, t.atoms.bg_contrast_950, {height: 60}]} /> + <View style={[a.flex_1, t.atoms.bg_contrast_975, {height: 60}]} /> + </View> + + <View style={[a.flex_row, a.gap_md]}> <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.gray_50}, + {height: 60, backgroundColor: t.palette.primary_25}, ]} /> <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.gray_100}, + {height: 60, backgroundColor: t.palette.primary_50}, ]} /> <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.gray_200}, + {height: 60, backgroundColor: t.palette.primary_100}, ]} /> <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.gray_300}, + {height: 60, backgroundColor: t.palette.primary_200}, ]} /> <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.gray_400}, + {height: 60, backgroundColor: t.palette.primary_300}, ]} /> <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.gray_500}, + {height: 60, backgroundColor: t.palette.primary_400}, ]} /> <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.gray_600}, + {height: 60, backgroundColor: t.palette.primary_500}, ]} /> <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.gray_700}, + {height: 60, backgroundColor: t.palette.primary_600}, ]} /> <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.gray_800}, + {height: 60, backgroundColor: t.palette.primary_700}, ]} /> <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.gray_900}, + {height: 60, backgroundColor: t.palette.primary_800}, ]} /> <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.gray_950}, + {height: 60, backgroundColor: t.palette.primary_900}, ]} /> <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.gray_975}, + {height: 60, backgroundColor: t.palette.primary_950}, ]} /> <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.gray_1000}, + {height: 60, backgroundColor: t.palette.primary_975}, ]} /> </View> - <View style={[a.flex_row, a.gap_md]}> <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.blue_25}, + {height: 60, backgroundColor: t.palette.positive_25}, ]} /> <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.blue_50}, + {height: 60, backgroundColor: t.palette.positive_50}, ]} /> <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.blue_100}, + {height: 60, backgroundColor: t.palette.positive_100}, ]} /> <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.blue_200}, + {height: 60, backgroundColor: t.palette.positive_200}, ]} /> <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.blue_300}, + {height: 60, backgroundColor: t.palette.positive_300}, ]} /> <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.blue_400}, + {height: 60, backgroundColor: t.palette.positive_400}, ]} /> <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.blue_500}, + {height: 60, backgroundColor: t.palette.positive_500}, ]} /> <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.blue_600}, + {height: 60, backgroundColor: t.palette.positive_600}, ]} /> <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.blue_700}, + {height: 60, backgroundColor: t.palette.positive_700}, ]} /> <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.blue_800}, + {height: 60, backgroundColor: t.palette.positive_800}, ]} /> <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.blue_900}, + {height: 60, backgroundColor: t.palette.positive_900}, ]} /> <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.blue_950}, + {height: 60, backgroundColor: t.palette.positive_950}, ]} /> <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.blue_975}, + {height: 60, backgroundColor: t.palette.positive_975}, ]} /> </View> @@ -181,153 +187,79 @@ export function Palette() { <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.green_25}, - ]} - /> - <View - style={[ - a.flex_1, - {height: 60, backgroundColor: tokens.color.green_50}, - ]} - /> - <View - style={[ - a.flex_1, - {height: 60, backgroundColor: tokens.color.green_100}, - ]} - /> - <View - style={[ - a.flex_1, - {height: 60, backgroundColor: tokens.color.green_200}, - ]} - /> - <View - style={[ - a.flex_1, - {height: 60, backgroundColor: tokens.color.green_300}, - ]} - /> - <View - style={[ - a.flex_1, - {height: 60, backgroundColor: tokens.color.green_400}, + {height: 60, backgroundColor: t.palette.negative_25}, ]} /> <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.green_500}, + {height: 60, backgroundColor: t.palette.negative_50}, ]} /> <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.green_600}, - ]} - /> - <View - style={[ - a.flex_1, - {height: 60, backgroundColor: tokens.color.green_700}, - ]} - /> - <View - style={[ - a.flex_1, - {height: 60, backgroundColor: tokens.color.green_800}, - ]} - /> - <View - style={[ - a.flex_1, - {height: 60, backgroundColor: tokens.color.green_900}, - ]} - /> - <View - style={[ - a.flex_1, - {height: 60, backgroundColor: tokens.color.green_950}, - ]} - /> - <View - style={[ - a.flex_1, - {height: 60, backgroundColor: tokens.color.green_975}, - ]} - /> - </View> - <View style={[a.flex_row, a.gap_md]}> - <View - style={[a.flex_1, {height: 60, backgroundColor: tokens.color.red_25}]} - /> - <View - style={[a.flex_1, {height: 60, backgroundColor: tokens.color.red_50}]} - /> - <View - style={[ - a.flex_1, - {height: 60, backgroundColor: tokens.color.red_100}, + {height: 60, backgroundColor: t.palette.negative_100}, ]} /> <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.red_200}, + {height: 60, backgroundColor: t.palette.negative_200}, ]} /> <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.red_300}, + {height: 60, backgroundColor: t.palette.negative_300}, ]} /> <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.red_400}, + {height: 60, backgroundColor: t.palette.negative_400}, ]} /> <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.red_500}, + {height: 60, backgroundColor: t.palette.negative_500}, ]} /> <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.red_600}, + {height: 60, backgroundColor: t.palette.negative_600}, ]} /> <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.red_700}, + {height: 60, backgroundColor: t.palette.negative_700}, ]} /> <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.red_800}, + {height: 60, backgroundColor: t.palette.negative_800}, ]} /> <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.red_900}, + {height: 60, backgroundColor: t.palette.negative_900}, ]} /> <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.red_950}, + {height: 60, backgroundColor: t.palette.negative_950}, ]} /> <View style={[ a.flex_1, - {height: 60, backgroundColor: tokens.color.red_975}, + {height: 60, backgroundColor: t.palette.negative_975}, ]} /> </View> diff --git a/src/view/screens/Storybook/Typography.tsx b/src/view/screens/Storybook/Typography.tsx index 5d3a96f4d..f0d67c528 100644 --- a/src/view/screens/Storybook/Typography.tsx +++ b/src/view/screens/Storybook/Typography.tsx @@ -8,7 +8,9 @@ import {RichText} from '#/components/RichText' export function Typography() { return ( <View style={[a.gap_md]}> - <Text style={[a.text_5xl]}>atoms.text_5xl</Text> + <Text selectable style={[a.text_5xl]}> + atoms.text_5xl + </Text> <Text style={[a.text_4xl]}>atoms.text_4xl</Text> <Text style={[a.text_3xl]}>atoms.text_3xl</Text> <Text style={[a.text_2xl]}>atoms.text_2xl</Text> @@ -20,11 +22,14 @@ export function Typography() { <Text style={[a.text_2xs]}>atoms.text_2xs</Text> <RichText - resolveFacets + // TODO: This only supports already resolved facets. + // Resolving them on read is bad anyway. value={`This is rich text. It can have mentions like @bsky.app or links like https://bsky.social`} /> <RichText - resolveFacets + selectable + // TODO: This only supports already resolved facets. + // Resolving them on read is bad anyway. value={`This is rich text. It can have mentions like @bsky.app or links like https://bsky.social`} style={[a.text_xl]} /> diff --git a/src/view/screens/Storybook/index.tsx b/src/view/screens/Storybook/index.tsx index 40929555e..3a2e2f369 100644 --- a/src/view/screens/Storybook/index.tsx +++ b/src/view/screens/Storybook/index.tsx @@ -16,6 +16,7 @@ import {Dialogs} from './Dialogs' import {Breakpoints} from './Breakpoints' import {Shadows} from './Shadows' import {Icons} from './Icons' +import {Menus} from './Menus' export function Storybook() { const t = useTheme() @@ -66,6 +67,7 @@ export function Storybook() { </Button> </View> + <Dialogs /> <ThemeProvider theme="light"> <Theming /> </ThemeProvider> @@ -84,6 +86,7 @@ export function Storybook() { <Links /> <Forms /> <Dialogs /> + <Menus /> <Breakpoints /> </View> </CenteredView> |