Bluesky app fork with some witchin' additions 💫

[Settings] Thread prefs revamp (#5772)

* thread preferences screen

* minor tweaks

* more spacing

* replace gate with IS_INTERNAL

* [Settings] Following feed prefs revamp (#5773)

* gated new settings screen

* Following feed prefs

* Update src/screens/Settings/FollowingFeedPreferences.tsx

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update src/screens/Settings/FollowingFeedPreferences.tsx

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* replace pref following feed gate

* Update src/screens/Settings/FollowingFeedPreferences.tsx

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* use "Experimental" as the header

---------

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* [Settings] External media prefs revamp (#5774)

* gated new settings screen

* external media prefs revamp

* replace gate ext media embeds

* Update src/screens/Settings/ExternalMediaPreferences.tsx

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* add imports for translation

* alternate list style on native

---------

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* [Settings] Languages revamp (partial) (#5775)

* language settings (lazy restyle)

* replace gate

* fix text determining flex space

* [Settings] App passwords revamp (#5777)

* rework app passwords screen

* Apply surfdude's copy changes

Thanks @surfdude29!

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* format

* replace gate

* use admonition for input error and animate

---------

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* [Settings] Change handle dialog (#5781)

* new change handle dialog

* animations native only

* overflow hidden on togglebutton animation

* add a low-contrast border

* extract out copybutton

* finish change handle dialog

* invalidate query on success

* web fixes

* error message for rate limit exceeded

* typo

* em dash!

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* another em dash

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* set maxwidth of suffixtext

* Copy tweak

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

---------

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* [Settings] Notifs settings revamp (#5884)

* rename, move, and restyle notif settings

* bold "experimental:"

---------

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

authored by samuel.fm

surfdude29 and committed by
GitHub
aa6aad65 d85dcc3d

+1991 -128
+1
assets/icons/beaker_stroke2_corner2_rounded.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M13.5 4a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3ZM10 5a1 1 0 1 0 0-2 1 1 0 0 0 0 2ZM8 6a1 1 0 0 0 0 2v2.64q-.319.374-.711.8l-.129.142c-.312.342-.649.711-.974 1.092-.731.857-1.488 1.866-1.89 2.99A4.9 4.9 0 0 0 4 17.298 4.7 4.7 0 0 0 8.702 22h6.596A4.7 4.7 0 0 0 20 17.298a4.8 4.8 0 0 0-.297-1.634c-.401-1.124-1.157-2.133-1.89-2.99-.324-.38-.66-.75-.973-1.092h0l-.129-.141c-.26-.286-.5-.55-.711-.8V8a1 1 0 1 0 0-2H8Zm2 5.35V8h4v3.35l.22.275c.306.383.661.777 1.013 1.163l.13.143h0c.315.345.628.688.93 1.042.372.435.704.861.974 1.28l-.159.025c-.845.13-1.838.242-2.581.222-.842-.022-1.475-.217-2.227-.454l-.027-.008c-.746-.235-1.61-.507-2.746-.538-.743-.02-1.617.064-2.38.165q.26-.342.56-.692c.302-.354.615-.697.93-1.042l.13-.143c.352-.386.707-.78 1.014-1.163L10 11.35Zm7.41 5.905q.316-.048.586-.095.004.07.004.138A2.7 2.7 0 0 1 15.298 20H8.702A2.7 2.7 0 0 1 6 17.298q0-.213.039-.434c.236-.043.53-.093.853-.142.845-.13 1.837-.242 2.581-.222.842.022 1.475.217 2.227.454l.027.008c.746.235 1.61.507 2.746.538.931.024 2.07-.113 2.937-.245Z" clip-rule="evenodd"/></svg>
+3 -3
src/Navigation.tsx
··· 55 55 import {ModerationMutedAccounts} from '#/view/screens/ModerationMutedAccounts' 56 56 import {NotFoundScreen} from '#/view/screens/NotFound' 57 57 import {NotificationsScreen} from '#/view/screens/Notifications' 58 - import {NotificationsSettingsScreen} from '#/view/screens/NotificationsSettings' 59 58 import {PostThreadScreen} from '#/view/screens/PostThread' 60 59 import {PreferencesExternalEmbeds} from '#/view/screens/PreferencesExternalEmbeds' 61 60 import {PreferencesFollowingFeed} from '#/view/screens/PreferencesFollowingFeed' ··· 87 86 import {ProfileKnownFollowersScreen} from '#/screens/Profile/KnownFollowers' 88 87 import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy' 89 88 import {AppearanceSettingsScreen} from '#/screens/Settings/AppearanceSettings' 89 + import {NotificationSettingsScreen} from '#/screens/Settings/NotificationSettings' 90 90 import { 91 91 StarterPackScreen, 92 92 StarterPackScreenShort, ··· 378 378 options={{title: title(msg`Chat settings`), requireAuth: true}} 379 379 /> 380 380 <Stack.Screen 381 - name="NotificationsSettings" 382 - getComponent={() => NotificationsSettingsScreen} 381 + name="NotificationSettings" 382 + getComponent={() => NotificationSettingsScreen} 383 383 options={{title: title(msg`Notification settings`), requireAuth: true}} 384 384 /> 385 385 <Stack.Screen
+1 -1
src/components/Admonition.tsx
··· 8 8 import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning' 9 9 import {Text as BaseText, TextProps} from '#/components/Typography' 10 10 11 - const colors = { 11 + export const colors = { 12 12 warning: { 13 13 light: '#DFBC00', 14 14 dark: '#BFAF1F',
+1
src/components/forms/TextField.tsx
··· 326 326 <Text 327 327 accessibilityLabel={label} 328 328 accessibilityHint={accessibilityHint} 329 + numberOfLines={1} 329 330 style={[ 330 331 a.z_20, 331 332 a.pr_sm,
+5
src/components/icons/Beaker.tsx
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + export const Beaker_Stroke2_Corner2_Rounded = createSinglePathSVG({ 4 + path: 'M13.5 4a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3ZM10 5a1 1 0 1 0 0-2 1 1 0 0 0 0 2ZM8 6a1 1 0 0 0 0 2v2.64q-.319.374-.711.8l-.129.142c-.312.342-.649.711-.974 1.092-.731.857-1.488 1.866-1.89 2.99A4.9 4.9 0 0 0 4 17.298 4.7 4.7 0 0 0 8.702 22h6.596A4.7 4.7 0 0 0 20 17.298c0-.575-.114-1.122-.297-1.634-.401-1.124-1.157-2.133-1.89-2.99-.324-.38-.66-.75-.973-1.092h0l-.129-.141c-.26-.286-.5-.55-.711-.8V8a1 1 0 1 0 0-2H8Zm2 5.35V8h4v3.35l.22.275c.306.383.661.777 1.013 1.163l.13.143h0c.315.345.628.688.93 1.042.372.435.704.861.974 1.28l-.159.025c-.845.13-1.838.242-2.581.222-.842-.022-1.475-.217-2.227-.454l-.027-.008c-.746-.235-1.61-.507-2.746-.538-.743-.02-1.617.064-2.38.165q.26-.342.56-.692c.302-.354.615-.697.93-1.042l.13-.143c.352-.386.707-.78 1.014-1.163L10 11.35Zm7.41 5.905q.316-.048.586-.095.004.07.004.138A2.7 2.7 0 0 1 15.298 20H8.702A2.7 2.7 0 0 1 6 17.298q0-.213.039-.434c.236-.043.53-.093.853-.142.845-.13 1.837-.242 2.581-.222.842.022 1.475.217 2.227.454l.027.008c.746.235 1.61.507 2.746.538.931.024 2.07-.113 2.937-.245Z', 5 + })
+1 -1
src/lib/routes/types.ts
··· 48 48 Hashtag: {tag: string; author?: string} 49 49 MessagesConversation: {conversation: string; embed?: string} 50 50 MessagesSettings: undefined 51 - NotificationsSettings: undefined 51 + NotificationSettings: undefined 52 52 Feeds: undefined 53 53 Start: {name: string; rkey: string} 54 54 StarterPack: {name: string; rkey: string; new?: boolean}
+1 -1
src/routes.ts
··· 5 5 Search: '/search', 6 6 Feeds: '/feeds', 7 7 Notifications: '/notifications', 8 - NotificationsSettings: '/notifications/settings', 8 + NotificationSettings: '/notifications/settings', 9 9 Settings: '/settings', 10 10 Lists: '/lists', 11 11 // moderation
+5 -21
src/screens/Settings/AccountSettings.tsx
··· 2 2 import {msg, Trans} from '@lingui/macro' 3 3 import {useLingui} from '@lingui/react' 4 4 import {NativeStackScreenProps} from '@react-navigation/native-stack' 5 - import {useQueryClient} from '@tanstack/react-query' 6 5 7 6 import {CommonNavigatorParams} from '#/lib/routes/types' 8 7 import {useModalControls} from '#/state/modals' 9 - import {RQKEY as RQKEY_PROFILE} from '#/state/queries/profile' 10 - import {useProfileQuery} from '#/state/queries/profile' 11 8 import {useSession} from '#/state/session' 12 9 import {ExportCarDialog} from '#/view/screens/Settings/ExportCarDialog' 13 10 import * as SettingsList from '#/screens/Settings/components/SettingsList' ··· 25 22 import {Trash_Stroke2_Corner2_Rounded} from '#/components/icons/Trash' 26 23 import {Verified_Stroke2_Corner2_Rounded as VerifiedIcon} from '#/components/icons/Verified' 27 24 import * as Layout from '#/components/Layout' 25 + import {ChangeHandleDialog} from './components/ChangeHandleDialog' 28 26 import {DeactivateAccountDialog} from './components/DeactivateAccountDialog' 29 27 30 28 type Props = NativeStackScreenProps<CommonNavigatorParams, 'AccountSettings'> ··· 32 30 const t = useTheme() 33 31 const {_} = useLingui() 34 32 const {currentAccount} = useSession() 35 - const queryClient = useQueryClient() 36 - const {data: profile} = useProfileQuery({did: currentAccount?.did}) 37 33 const {openModal} = useModalControls() 38 34 const birthdayControl = useDialogControl() 35 + const changeHandleControl = useDialogControl() 39 36 const exportCarControl = useDialogControl() 40 37 const deactivateAccountControl = useDialogControl() 41 38 ··· 117 114 </SettingsList.PressableItem> 118 115 <SettingsList.PressableItem 119 116 label={_(msg`Handle`)} 120 - onPress={() => 121 - openModal({ 122 - name: 'change-handle', 123 - onChanged() { 124 - if (currentAccount) { 125 - // refresh my profile 126 - queryClient.invalidateQueries({ 127 - queryKey: RQKEY_PROFILE(currentAccount.did), 128 - }) 129 - } 130 - }, 131 - }) 132 - }> 117 + accessibilityHint={_(msg`Open change handle dialog`)} 118 + onPress={() => changeHandleControl.open()}> 133 119 <SettingsList.ItemIcon icon={AtIcon} /> 134 120 <SettingsList.ItemText> 135 121 <Trans>Handle</Trans> 136 122 </SettingsList.ItemText> 137 - {profile && ( 138 - <SettingsList.BadgeText>@{profile.handle}</SettingsList.BadgeText> 139 - )} 140 123 <SettingsList.Chevron /> 141 124 </SettingsList.PressableItem> 142 125 <SettingsList.Divider /> ··· 173 156 </Layout.Content> 174 157 175 158 <BirthDateSettingsDialog control={birthdayControl} /> 159 + <ChangeHandleDialog control={changeHandleControl} /> 176 160 <ExportCarDialog control={exportCarControl} /> 177 161 <DeactivateAccountDialog control={deactivateAccountControl} /> 178 162 </Layout.Screen>
+209
src/screens/Settings/AppPasswords.tsx
··· 1 + import React, {useCallback} from 'react' 2 + import {View} from 'react-native' 3 + import Animated, { 4 + FadeIn, 5 + FadeOut, 6 + LayoutAnimationConfig, 7 + LinearTransition, 8 + StretchOutY, 9 + } from 'react-native-reanimated' 10 + import {ComAtprotoServerListAppPasswords} from '@atproto/api' 11 + import {msg, Trans} from '@lingui/macro' 12 + import {useLingui} from '@lingui/react' 13 + import {NativeStackScreenProps} from '@react-navigation/native-stack' 14 + 15 + import {CommonNavigatorParams} from '#/lib/routes/types' 16 + import {cleanError} from '#/lib/strings/errors' 17 + import {isWeb} from '#/platform/detection' 18 + import { 19 + useAppPasswordDeleteMutation, 20 + useAppPasswordsQuery, 21 + } from '#/state/queries/app-passwords' 22 + import {EmptyState} from '#/view/com/util/EmptyState' 23 + import {ErrorScreen} from '#/view/com/util/error/ErrorScreen' 24 + import * as Toast from '#/view/com/util/Toast' 25 + import {atoms as a, useTheme} from '#/alf' 26 + import {Admonition, colors} from '#/components/Admonition' 27 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 28 + import {useDialogControl} from '#/components/Dialog' 29 + import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus' 30 + import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' 31 + import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning' 32 + import * as Layout from '#/components/Layout' 33 + import {Loader} from '#/components/Loader' 34 + import * as Prompt from '#/components/Prompt' 35 + import {Text} from '#/components/Typography' 36 + import {AddAppPasswordDialog} from './components/AddAppPasswordDialog' 37 + import * as SettingsList from './components/SettingsList' 38 + 39 + type Props = NativeStackScreenProps<CommonNavigatorParams, 'AppPasswords'> 40 + export function AppPasswordsScreen({}: Props) { 41 + const {_} = useLingui() 42 + const {data: appPasswords, error} = useAppPasswordsQuery() 43 + const createAppPasswordControl = useDialogControl() 44 + 45 + return ( 46 + <Layout.Screen testID="AppPasswordsScreen"> 47 + <Layout.Header title={_(msg`App Passwords`)} /> 48 + <Layout.Content> 49 + {error ? ( 50 + <ErrorScreen 51 + title={_(msg`Oops!`)} 52 + message={_(msg`There was an issue fetching your app passwords`)} 53 + details={cleanError(error)} 54 + /> 55 + ) : ( 56 + <SettingsList.Container> 57 + <SettingsList.Item> 58 + <Admonition type="tip" style={[a.flex_1]}> 59 + <Trans> 60 + Use app passwords to sign in to other Bluesky clients without 61 + giving full access to your account or password. 62 + </Trans> 63 + </Admonition> 64 + </SettingsList.Item> 65 + <SettingsList.Item> 66 + <Button 67 + label={_(msg`Add App Password`)} 68 + size="large" 69 + color="primary" 70 + variant="solid" 71 + onPress={() => createAppPasswordControl.open()} 72 + style={[a.flex_1]}> 73 + <ButtonIcon icon={PlusIcon} /> 74 + <ButtonText> 75 + <Trans>Add App Password</Trans> 76 + </ButtonText> 77 + </Button> 78 + </SettingsList.Item> 79 + <SettingsList.Divider /> 80 + <LayoutAnimationConfig skipEntering skipExiting> 81 + {appPasswords ? ( 82 + appPasswords.length > 0 ? ( 83 + <View style={[a.overflow_hidden]}> 84 + {appPasswords.map(appPassword => ( 85 + <Animated.View 86 + key={appPassword.name} 87 + style={a.w_full} 88 + entering={FadeIn} 89 + exiting={isWeb ? FadeOut : StretchOutY} 90 + layout={LinearTransition.delay(150)}> 91 + <SettingsList.Item> 92 + <AppPasswordCard appPassword={appPassword} /> 93 + </SettingsList.Item> 94 + </Animated.View> 95 + ))} 96 + </View> 97 + ) : ( 98 + <EmptyState 99 + icon="growth" 100 + message={_(msg`No app passwords yet`)} 101 + /> 102 + ) 103 + ) : ( 104 + <View 105 + style={[ 106 + a.flex_1, 107 + a.justify_center, 108 + a.align_center, 109 + a.py_4xl, 110 + ]}> 111 + <Loader size="xl" /> 112 + </View> 113 + )} 114 + </LayoutAnimationConfig> 115 + </SettingsList.Container> 116 + )} 117 + </Layout.Content> 118 + 119 + <AddAppPasswordDialog 120 + control={createAppPasswordControl} 121 + passwords={appPasswords?.map(p => p.name) || []} 122 + /> 123 + </Layout.Screen> 124 + ) 125 + } 126 + 127 + function AppPasswordCard({ 128 + appPassword, 129 + }: { 130 + appPassword: ComAtprotoServerListAppPasswords.AppPassword 131 + }) { 132 + const t = useTheme() 133 + const {i18n, _} = useLingui() 134 + const deleteControl = Prompt.usePromptControl() 135 + const {mutateAsync: deleteMutation} = useAppPasswordDeleteMutation() 136 + 137 + const onDelete = useCallback(async () => { 138 + await deleteMutation({name: appPassword.name}) 139 + Toast.show(_(msg`App password deleted`)) 140 + }, [deleteMutation, appPassword.name, _]) 141 + 142 + return ( 143 + <View 144 + style={[ 145 + a.w_full, 146 + a.border, 147 + a.rounded_sm, 148 + a.px_md, 149 + a.py_sm, 150 + t.atoms.bg_contrast_25, 151 + t.atoms.border_contrast_low, 152 + ]}> 153 + <View 154 + style={[ 155 + a.flex_row, 156 + a.justify_between, 157 + a.align_start, 158 + a.w_full, 159 + a.gap_sm, 160 + ]}> 161 + <View style={[a.gap_xs]}> 162 + <Text style={[t.atoms.text, a.text_md, a.font_bold]}> 163 + {appPassword.name} 164 + </Text> 165 + <Text style={[t.atoms.text_contrast_medium]}> 166 + <Trans> 167 + Created{' '} 168 + {i18n.date(appPassword.createdAt, { 169 + year: 'numeric', 170 + month: 'numeric', 171 + day: 'numeric', 172 + hour: '2-digit', 173 + minute: '2-digit', 174 + })} 175 + </Trans> 176 + </Text> 177 + </View> 178 + <Button 179 + label={_(msg`Delete app password`)} 180 + variant="ghost" 181 + color="negative" 182 + size="small" 183 + style={[a.bg_transparent]} 184 + onPress={() => deleteControl.open()}> 185 + <ButtonIcon icon={TrashIcon} /> 186 + </Button> 187 + </View> 188 + {appPassword.privileged && ( 189 + <View style={[a.flex_row, a.gap_sm, a.align_center, a.mt_md]}> 190 + <WarningIcon style={[{color: colors.warning[t.scheme]}]} /> 191 + <Text style={t.atoms.text_contrast_high}> 192 + <Trans>Allows access to direct messages</Trans> 193 + </Text> 194 + </View> 195 + )} 196 + 197 + <Prompt.Basic 198 + control={deleteControl} 199 + title={_(msg`Delete app password?`)} 200 + description={_( 201 + msg`Are you sure you want to delete the app password "${appPassword.name}"?`, 202 + )} 203 + onConfirm={onDelete} 204 + confirmButtonCta={_(msg`Delete`)} 205 + confirmButtonColor="negative" 206 + /> 207 + </View> 208 + ) 209 + }
+99
src/screens/Settings/ExternalMediaPreferences.tsx
··· 1 + import React, {Fragment} from 'react' 2 + import {View} from 'react-native' 3 + import {msg, Trans} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + 6 + import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' 7 + import { 8 + EmbedPlayerSource, 9 + externalEmbedLabels, 10 + } from '#/lib/strings/embed-player' 11 + import { 12 + useExternalEmbedsPrefs, 13 + useSetExternalEmbedPref, 14 + } from '#/state/preferences' 15 + import {atoms as a, native} from '#/alf' 16 + import {Admonition} from '#/components/Admonition' 17 + import * as Toggle from '#/components/forms/Toggle' 18 + import * as Layout from '#/components/Layout' 19 + import * as SettingsList from './components/SettingsList' 20 + 21 + type Props = NativeStackScreenProps< 22 + CommonNavigatorParams, 23 + 'PreferencesExternalEmbeds' 24 + > 25 + export function ExternalMediaPreferencesScreen({}: Props) { 26 + const {_} = useLingui() 27 + return ( 28 + <Layout.Screen testID="externalMediaPreferencesScreen"> 29 + <Layout.Header title={_(msg`External Media Preferences`)} /> 30 + <Layout.Content> 31 + <SettingsList.Container> 32 + <SettingsList.Item> 33 + <Admonition type="info" style={[a.flex_1]}> 34 + <Trans> 35 + External media may allow websites to collect information about 36 + you and your device. No information is sent or requested until 37 + you press the "play" button. 38 + </Trans> 39 + </Admonition> 40 + </SettingsList.Item> 41 + <SettingsList.Group iconInset={false}> 42 + <SettingsList.ItemText> 43 + <Trans>Enable media players for</Trans> 44 + </SettingsList.ItemText> 45 + <View style={[a.mt_sm, a.w_full]}> 46 + {native(<SettingsList.Divider style={[a.my_0]} />)} 47 + {Object.entries(externalEmbedLabels) 48 + // TODO: Remove special case when we disable the old integration. 49 + .filter(([key]) => key !== 'tenor') 50 + .map(([key, label]) => ( 51 + <Fragment key={key}> 52 + <PrefSelector 53 + source={key as EmbedPlayerSource} 54 + label={label} 55 + key={key} 56 + /> 57 + {native(<SettingsList.Divider style={[a.my_0]} />)} 58 + </Fragment> 59 + ))} 60 + </View> 61 + </SettingsList.Group> 62 + </SettingsList.Container> 63 + </Layout.Content> 64 + </Layout.Screen> 65 + ) 66 + } 67 + 68 + function PrefSelector({ 69 + source, 70 + label, 71 + }: { 72 + source: EmbedPlayerSource 73 + label: string 74 + }) { 75 + const setExternalEmbedPref = useSetExternalEmbedPref() 76 + const sources = useExternalEmbedsPrefs() 77 + 78 + return ( 79 + <Toggle.Item 80 + name={label} 81 + label={label} 82 + type="checkbox" 83 + value={sources?.[source] === 'show'} 84 + onChange={() => 85 + setExternalEmbedPref( 86 + source, 87 + sources?.[source] === 'show' ? 'hide' : 'show', 88 + ) 89 + } 90 + style={[ 91 + a.flex_1, 92 + a.py_md, 93 + native([a.justify_between, a.flex_row_reverse]), 94 + ]}> 95 + <Toggle.Platform /> 96 + <Toggle.LabelText style={[a.text_md]}>{label}</Toggle.LabelText> 97 + </Toggle.Item> 98 + ) 99 + }
+143
src/screens/Settings/FollowingFeedPreferences.tsx
··· 1 + import React from 'react' 2 + import {msg, Trans} from '@lingui/macro' 3 + import {useLingui} from '@lingui/react' 4 + 5 + import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' 6 + import { 7 + usePreferencesQuery, 8 + useSetFeedViewPreferencesMutation, 9 + } from '#/state/queries/preferences' 10 + import {atoms as a} from '#/alf' 11 + import {Admonition} from '#/components/Admonition' 12 + import * as Toggle from '#/components/forms/Toggle' 13 + import {Beaker_Stroke2_Corner2_Rounded as BeakerIcon} from '#/components/icons/Beaker' 14 + import {Bubbles_Stroke2_Corner2_Rounded as BubblesIcon} from '#/components/icons/Bubble' 15 + import {CloseQuote_Stroke2_Corner1_Rounded as QuoteIcon} from '#/components/icons/Quote' 16 + import {Repost_Stroke2_Corner2_Rounded as RepostIcon} from '#/components/icons/Repost' 17 + import * as Layout from '#/components/Layout' 18 + import * as SettingsList from './components/SettingsList' 19 + 20 + type Props = NativeStackScreenProps< 21 + CommonNavigatorParams, 22 + 'PreferencesFollowingFeed' 23 + > 24 + export function FollowingFeedPreferencesScreen({}: Props) { 25 + const {_} = useLingui() 26 + 27 + const {data: preferences} = usePreferencesQuery() 28 + const {mutate: setFeedViewPref, variables} = 29 + useSetFeedViewPreferencesMutation() 30 + 31 + const showReplies = !( 32 + variables?.hideReplies ?? preferences?.feedViewPrefs?.hideReplies 33 + ) 34 + 35 + const showReposts = !( 36 + variables?.hideReposts ?? preferences?.feedViewPrefs?.hideReposts 37 + ) 38 + 39 + const showQuotePosts = !( 40 + variables?.hideQuotePosts ?? preferences?.feedViewPrefs?.hideQuotePosts 41 + ) 42 + 43 + const mergeFeedEnabled = Boolean( 44 + variables?.lab_mergeFeedEnabled ?? 45 + preferences?.feedViewPrefs?.lab_mergeFeedEnabled, 46 + ) 47 + 48 + return ( 49 + <Layout.Screen testID="followingFeedPreferencesScreen"> 50 + <Layout.Header title={_(msg`Following Feed Preferences`)} /> 51 + <Layout.Content> 52 + <SettingsList.Container> 53 + <SettingsList.Item> 54 + <Admonition type="tip" style={[a.flex_1]}> 55 + <Trans>These settings only apply to the Following feed.</Trans> 56 + </Admonition> 57 + </SettingsList.Item> 58 + <Toggle.Item 59 + type="checkbox" 60 + name="show-replies" 61 + label={_(msg`Show replies`)} 62 + value={showReplies} 63 + onChange={value => 64 + setFeedViewPref({ 65 + hideReplies: !value, 66 + }) 67 + }> 68 + <SettingsList.Item> 69 + <SettingsList.ItemIcon icon={BubblesIcon} /> 70 + <SettingsList.ItemText> 71 + <Trans>Show replies</Trans> 72 + </SettingsList.ItemText> 73 + <Toggle.Platform /> 74 + </SettingsList.Item> 75 + </Toggle.Item> 76 + <Toggle.Item 77 + type="checkbox" 78 + name="show-reposts" 79 + label={_(msg`Show reposts`)} 80 + value={showReposts} 81 + onChange={value => 82 + setFeedViewPref({ 83 + hideReposts: !value, 84 + }) 85 + }> 86 + <SettingsList.Item> 87 + <SettingsList.ItemIcon icon={RepostIcon} /> 88 + <SettingsList.ItemText> 89 + <Trans>Show reposts</Trans> 90 + </SettingsList.ItemText> 91 + <Toggle.Platform /> 92 + </SettingsList.Item> 93 + </Toggle.Item> 94 + <Toggle.Item 95 + type="checkbox" 96 + name="show-quotes" 97 + label={_(msg`Show quote posts`)} 98 + value={showQuotePosts} 99 + onChange={value => 100 + setFeedViewPref({ 101 + hideQuotePosts: !value, 102 + }) 103 + }> 104 + <SettingsList.Item> 105 + <SettingsList.ItemIcon icon={QuoteIcon} /> 106 + <SettingsList.ItemText> 107 + <Trans>Show quote posts</Trans> 108 + </SettingsList.ItemText> 109 + <Toggle.Platform /> 110 + </SettingsList.Item> 111 + </Toggle.Item> 112 + <SettingsList.Divider /> 113 + <SettingsList.Group> 114 + <SettingsList.ItemIcon icon={BeakerIcon} /> 115 + <SettingsList.ItemText> 116 + <Trans>Experimental</Trans> 117 + </SettingsList.ItemText> 118 + <Toggle.Item 119 + type="checkbox" 120 + name="merge-feed" 121 + label={_( 122 + msg`Show samples of your saved feeds in your Following feed`, 123 + )} 124 + value={mergeFeedEnabled} 125 + onChange={value => 126 + setFeedViewPref({ 127 + lab_mergeFeedEnabled: value, 128 + }) 129 + } 130 + style={[a.w_full, a.gap_md]}> 131 + <Toggle.LabelText style={[a.flex_1]}> 132 + <Trans> 133 + Show samples of your saved feeds in your Following feed 134 + </Trans> 135 + </Toggle.LabelText> 136 + <Toggle.Platform /> 137 + </Toggle.Item> 138 + </SettingsList.Group> 139 + </SettingsList.Container> 140 + </Layout.Content> 141 + </Layout.Screen> 142 + ) 143 + }
+275
src/screens/Settings/LanguageSettings.tsx
··· 1 + import React, {useCallback, useMemo} from 'react' 2 + import {View} from 'react-native' 3 + import RNPickerSelect, {PickerSelectProps} from 'react-native-picker-select' 4 + import {msg, Trans} from '@lingui/macro' 5 + import {useLingui} from '@lingui/react' 6 + 7 + import {APP_LANGUAGES, LANGUAGES} from '#/lib/../locale/languages' 8 + import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' 9 + import {sanitizeAppLanguageSetting} from '#/locale/helpers' 10 + import {useModalControls} from '#/state/modals' 11 + import {useLanguagePrefs, useLanguagePrefsApi} from '#/state/preferences' 12 + import {atoms as a, useTheme, web} from '#/alf' 13 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 14 + import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check' 15 + import {ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon} from '#/components/icons/Chevron' 16 + import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus' 17 + import * as Layout from '#/components/Layout' 18 + import {Text} from '#/components/Typography' 19 + import * as SettingsList from './components/SettingsList' 20 + 21 + type Props = NativeStackScreenProps<CommonNavigatorParams, 'LanguageSettings'> 22 + export function LanguageSettingsScreen({}: Props) { 23 + const {_} = useLingui() 24 + const langPrefs = useLanguagePrefs() 25 + const setLangPrefs = useLanguagePrefsApi() 26 + const t = useTheme() 27 + 28 + const {openModal} = useModalControls() 29 + 30 + const onPressContentLanguages = useCallback(() => { 31 + openModal({name: 'content-languages-settings'}) 32 + }, [openModal]) 33 + 34 + const onChangePrimaryLanguage = useCallback( 35 + (value: Parameters<PickerSelectProps['onValueChange']>[0]) => { 36 + if (!value) return 37 + if (langPrefs.primaryLanguage !== value) { 38 + setLangPrefs.setPrimaryLanguage(value) 39 + } 40 + }, 41 + [langPrefs, setLangPrefs], 42 + ) 43 + 44 + const onChangeAppLanguage = useCallback( 45 + (value: Parameters<PickerSelectProps['onValueChange']>[0]) => { 46 + if (!value) return 47 + if (langPrefs.appLanguage !== value) { 48 + setLangPrefs.setAppLanguage(sanitizeAppLanguageSetting(value)) 49 + } 50 + }, 51 + [langPrefs, setLangPrefs], 52 + ) 53 + 54 + const myLanguages = useMemo(() => { 55 + return ( 56 + langPrefs.contentLanguages 57 + .map(lang => LANGUAGES.find(l => l.code2 === lang)) 58 + .filter(Boolean) 59 + // @ts-ignore 60 + .map(l => l.name) 61 + .join(', ') 62 + ) 63 + }, [langPrefs.contentLanguages]) 64 + 65 + return ( 66 + <Layout.Screen testID="PreferencesLanguagesScreen"> 67 + <Layout.Header title={_(msg`Languages`)} /> 68 + <Layout.Content> 69 + <SettingsList.Container> 70 + <SettingsList.Group iconInset={false}> 71 + <SettingsList.ItemText> 72 + <Trans>App Language</Trans> 73 + </SettingsList.ItemText> 74 + <View style={[a.gap_md, a.w_full]}> 75 + <Text style={[a.leading_snug]}> 76 + <Trans> 77 + Select your app language for the default text to display in 78 + the app. 79 + </Trans> 80 + </Text> 81 + <View style={[a.relative, web([a.w_full, {maxWidth: 400}])]}> 82 + <RNPickerSelect 83 + placeholder={{}} 84 + value={sanitizeAppLanguageSetting(langPrefs.appLanguage)} 85 + onValueChange={onChangeAppLanguage} 86 + items={APP_LANGUAGES.filter(l => Boolean(l.code2)).map(l => ({ 87 + label: l.name, 88 + value: l.code2, 89 + key: l.code2, 90 + }))} 91 + style={{ 92 + inputAndroid: { 93 + backgroundColor: t.atoms.bg_contrast_25.backgroundColor, 94 + color: t.atoms.text.color, 95 + fontSize: 14, 96 + letterSpacing: 0.5, 97 + fontWeight: a.font_bold.fontWeight, 98 + paddingHorizontal: 14, 99 + paddingVertical: 8, 100 + borderRadius: a.rounded_xs.borderRadius, 101 + }, 102 + inputIOS: { 103 + backgroundColor: t.atoms.bg_contrast_25.backgroundColor, 104 + color: t.atoms.text.color, 105 + fontSize: 14, 106 + letterSpacing: 0.5, 107 + fontWeight: a.font_bold.fontWeight, 108 + paddingHorizontal: 14, 109 + paddingVertical: 8, 110 + borderRadius: a.rounded_xs.borderRadius, 111 + }, 112 + inputWeb: { 113 + flex: 1, 114 + width: '100%', 115 + cursor: 'pointer', 116 + // @ts-ignore web only 117 + '-moz-appearance': 'none', 118 + '-webkit-appearance': 'none', 119 + appearance: 'none', 120 + outline: 0, 121 + borderWidth: 0, 122 + backgroundColor: t.atoms.bg_contrast_25.backgroundColor, 123 + color: t.atoms.text.color, 124 + fontSize: 14, 125 + fontFamily: 'inherit', 126 + letterSpacing: 0.5, 127 + fontWeight: a.font_bold.fontWeight, 128 + paddingHorizontal: 14, 129 + paddingVertical: 8, 130 + borderRadius: a.rounded_xs.borderRadius, 131 + }, 132 + }} 133 + /> 134 + 135 + <View 136 + style={[ 137 + a.absolute, 138 + t.atoms.bg_contrast_25, 139 + a.rounded_xs, 140 + a.pointer_events_none, 141 + a.align_center, 142 + a.justify_center, 143 + { 144 + top: 1, 145 + right: 1, 146 + bottom: 1, 147 + width: 40, 148 + }, 149 + ]}> 150 + <ChevronDownIcon style={[t.atoms.text]} /> 151 + </View> 152 + </View> 153 + </View> 154 + </SettingsList.Group> 155 + <SettingsList.Divider /> 156 + <SettingsList.Group iconInset={false}> 157 + <SettingsList.ItemText> 158 + <Trans>Primary Language</Trans> 159 + </SettingsList.ItemText> 160 + <View style={[a.gap_md, a.w_full]}> 161 + <Text style={[a.leading_snug]}> 162 + <Trans> 163 + Select your preferred language for translations in your feed. 164 + </Trans> 165 + </Text> 166 + <View style={[a.relative, web([a.w_full, {maxWidth: 400}])]}> 167 + <RNPickerSelect 168 + placeholder={{}} 169 + value={langPrefs.primaryLanguage} 170 + onValueChange={onChangePrimaryLanguage} 171 + items={LANGUAGES.filter(l => Boolean(l.code2)).map(l => ({ 172 + label: l.name, 173 + value: l.code2, 174 + key: l.code2 + l.code3, 175 + }))} 176 + style={{ 177 + inputAndroid: { 178 + backgroundColor: t.atoms.bg_contrast_25.backgroundColor, 179 + color: t.atoms.text.color, 180 + fontSize: 14, 181 + letterSpacing: 0.5, 182 + fontWeight: a.font_bold.fontWeight, 183 + paddingHorizontal: 14, 184 + paddingVertical: 8, 185 + borderRadius: a.rounded_xs.borderRadius, 186 + }, 187 + inputIOS: { 188 + backgroundColor: t.atoms.bg_contrast_25.backgroundColor, 189 + color: t.atoms.text.color, 190 + fontSize: 14, 191 + letterSpacing: 0.5, 192 + fontWeight: a.font_bold.fontWeight, 193 + paddingHorizontal: 14, 194 + paddingVertical: 8, 195 + borderRadius: a.rounded_xs.borderRadius, 196 + }, 197 + inputWeb: { 198 + flex: 1, 199 + width: '100%', 200 + cursor: 'pointer', 201 + // @ts-ignore web only 202 + '-moz-appearance': 'none', 203 + '-webkit-appearance': 'none', 204 + appearance: 'none', 205 + outline: 0, 206 + borderWidth: 0, 207 + backgroundColor: t.atoms.bg_contrast_25.backgroundColor, 208 + color: t.atoms.text.color, 209 + fontSize: 14, 210 + fontFamily: 'inherit', 211 + letterSpacing: 0.5, 212 + fontWeight: a.font_bold.fontWeight, 213 + paddingHorizontal: 14, 214 + paddingVertical: 8, 215 + borderRadius: a.rounded_xs.borderRadius, 216 + }, 217 + }} 218 + /> 219 + 220 + <View 221 + style={{ 222 + position: 'absolute', 223 + top: 1, 224 + right: 1, 225 + bottom: 1, 226 + width: 40, 227 + backgroundColor: t.atoms.bg_contrast_25.backgroundColor, 228 + borderRadius: a.rounded_xs.borderRadius, 229 + pointerEvents: 'none', 230 + alignItems: 'center', 231 + justifyContent: 'center', 232 + }}> 233 + <ChevronDownIcon style={t.atoms.text} /> 234 + </View> 235 + </View> 236 + </View> 237 + </SettingsList.Group> 238 + <SettingsList.Divider /> 239 + <SettingsList.Group iconInset={false}> 240 + <SettingsList.ItemText> 241 + <Trans>Content Languages</Trans> 242 + </SettingsList.ItemText> 243 + <View style={[a.gap_md]}> 244 + <Text style={[a.leading_snug]}> 245 + <Trans> 246 + Select which languages you want your subscribed feeds to 247 + include. If none are selected, all languages will be shown. 248 + </Trans> 249 + </Text> 250 + 251 + <Button 252 + label={_(msg`Select content languages`)} 253 + size="small" 254 + color="secondary" 255 + variant="solid" 256 + onPress={onPressContentLanguages} 257 + style={[a.justify_start, web({maxWidth: 400})]}> 258 + <ButtonIcon 259 + icon={myLanguages.length > 0 ? CheckIcon : PlusIcon} 260 + /> 261 + <ButtonText 262 + style={[t.atoms.text, a.text_md, a.flex_1, a.text_left]} 263 + numberOfLines={1}> 264 + {myLanguages.length > 0 265 + ? myLanguages 266 + : _(msg`Select languages`)} 267 + </ButtonText> 268 + </Button> 269 + </View> 270 + </SettingsList.Group> 271 + </SettingsList.Container> 272 + </Layout.Content> 273 + </Layout.Screen> 274 + ) 275 + }
+85
src/screens/Settings/NotificationSettings.tsx
··· 1 + import React from 'react' 2 + import {Text} from 'react-native' 3 + import {msg, Trans} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + 6 + import {AllNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' 7 + import {useNotificationFeedQuery} from '#/state/queries/notifications/feed' 8 + import {useNotificationSettingsMutation} from '#/state/queries/notifications/settings' 9 + import {atoms as a} from '#/alf' 10 + import {Admonition} from '#/components/Admonition' 11 + import {Error} from '#/components/Error' 12 + import * as Toggle from '#/components/forms/Toggle' 13 + import {Beaker_Stroke2_Corner2_Rounded as BeakerIcon} from '#/components/icons/Beaker' 14 + import * as Layout from '#/components/Layout' 15 + import {Loader} from '#/components/Loader' 16 + import * as SettingsList from './components/SettingsList' 17 + 18 + type Props = NativeStackScreenProps<AllNavigatorParams, 'NotificationSettings'> 19 + export function NotificationSettingsScreen({}: Props) { 20 + const {_} = useLingui() 21 + 22 + const {data, isError: isQueryError, refetch} = useNotificationFeedQuery() 23 + const serverPriority = data?.pages.at(0)?.priority 24 + 25 + const { 26 + mutate: onChangePriority, 27 + isPending: isMutationPending, 28 + variables, 29 + } = useNotificationSettingsMutation() 30 + 31 + const priority = isMutationPending 32 + ? variables[0] === 'enabled' 33 + : serverPriority 34 + 35 + return ( 36 + <Layout.Screen> 37 + <Layout.Header title={_(msg`Notification Settings`)} /> 38 + <Layout.Content> 39 + {isQueryError ? ( 40 + <Error 41 + title={_(msg`Oops!`)} 42 + message={_(msg`Something went wrong!`)} 43 + onRetry={refetch} 44 + sideBorders={false} 45 + /> 46 + ) : ( 47 + <SettingsList.Container> 48 + <SettingsList.Group> 49 + <SettingsList.ItemIcon icon={BeakerIcon} /> 50 + <SettingsList.ItemText> 51 + <Trans>Notification filters</Trans> 52 + </SettingsList.ItemText> 53 + <Toggle.Group 54 + label={_(msg`Priority notifications`)} 55 + type="checkbox" 56 + values={priority ? ['enabled'] : []} 57 + onChange={onChangePriority} 58 + disabled={typeof priority !== 'boolean' || isMutationPending}> 59 + <Toggle.Item 60 + name="enabled" 61 + label={_(msg`Enable priority notifications`)} 62 + style={[a.flex_1, a.justify_between]}> 63 + <Toggle.LabelText> 64 + <Trans>Enable priority notifications</Trans> 65 + </Toggle.LabelText> 66 + {!data ? <Loader size="md" /> : <Toggle.Platform />} 67 + </Toggle.Item> 68 + </Toggle.Group> 69 + </SettingsList.Group> 70 + <SettingsList.Item> 71 + <Admonition type="warning" style={[a.flex_1]}> 72 + <Trans> 73 + <Text style={[a.font_bold]}>Experimental:</Text> When this 74 + preference is enabled, you'll only receive reply and quote 75 + notifications from users you follow. We'll continue to add 76 + more controls here over time. 77 + </Trans> 78 + </Admonition> 79 + </SettingsList.Item> 80 + </SettingsList.Container> 81 + )} 82 + </Layout.Content> 83 + </Layout.Screen> 84 + ) 85 + }
+1
src/screens/Settings/Settings.tsx
··· 60 60 <View 61 61 style={[ 62 62 a.px_xl, 63 + a.pt_md, 63 64 a.pb_md, 64 65 a.w_full, 65 66 a.gap_2xs,
+145
src/screens/Settings/ThreadPreferences.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + import {msg, Trans} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + 6 + import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' 7 + import { 8 + usePreferencesQuery, 9 + useSetThreadViewPreferencesMutation, 10 + } from '#/state/queries/preferences' 11 + import {atoms as a, useTheme} from '#/alf' 12 + import * as Toggle from '#/components/forms/Toggle' 13 + import {Beaker_Stroke2_Corner2_Rounded as BeakerIcon} from '#/components/icons/Beaker' 14 + import {Bubbles_Stroke2_Corner2_Rounded as BubblesIcon} from '#/components/icons/Bubble' 15 + import {PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon} from '#/components/icons/Person' 16 + import * as Layout from '#/components/Layout' 17 + import {Text} from '#/components/Typography' 18 + import * as SettingsList from './components/SettingsList' 19 + 20 + type Props = NativeStackScreenProps<CommonNavigatorParams, 'PreferencesThreads'> 21 + export function ThreadPreferencesScreen({}: Props) { 22 + const {_} = useLingui() 23 + const t = useTheme() 24 + 25 + const {data: preferences} = usePreferencesQuery() 26 + const {mutate: setThreadViewPrefs, variables} = 27 + useSetThreadViewPreferencesMutation() 28 + 29 + const sortReplies = variables?.sort ?? preferences?.threadViewPrefs?.sort 30 + 31 + const prioritizeFollowedUsers = Boolean( 32 + variables?.prioritizeFollowedUsers ?? 33 + preferences?.threadViewPrefs?.prioritizeFollowedUsers, 34 + ) 35 + const treeViewEnabled = Boolean( 36 + variables?.lab_treeViewEnabled ?? 37 + preferences?.threadViewPrefs?.lab_treeViewEnabled, 38 + ) 39 + 40 + return ( 41 + <Layout.Screen testID="threadPreferencesScreen"> 42 + <Layout.Header title={_(msg`Thread Preferences`)} /> 43 + <Layout.Content> 44 + <SettingsList.Container> 45 + <SettingsList.Group> 46 + <SettingsList.ItemIcon icon={BubblesIcon} /> 47 + <SettingsList.ItemText>Sort replies</SettingsList.ItemText> 48 + <View style={[a.w_full, a.gap_md]}> 49 + <Text style={[a.flex_1, t.atoms.text_contrast_medium]}> 50 + <Trans>Sort replies to the same post by:</Trans> 51 + </Text> 52 + <Toggle.Group 53 + label={_(msg`Sort replies by`)} 54 + type="radio" 55 + values={sortReplies ? [sortReplies] : []} 56 + onChange={values => setThreadViewPrefs({sort: values[0]})}> 57 + <View style={[a.gap_sm, a.flex_1]}> 58 + <Toggle.Item 59 + name="oldest" 60 + label={_(msg`Oldest replies first`)}> 61 + <Toggle.Radio /> 62 + <Toggle.LabelText> 63 + <Trans>Oldest replies first</Trans> 64 + </Toggle.LabelText> 65 + </Toggle.Item> 66 + <Toggle.Item 67 + name="newest" 68 + label={_(msg`Newest replies first`)}> 69 + <Toggle.Radio /> 70 + <Toggle.LabelText> 71 + <Trans>Newest replies first</Trans> 72 + </Toggle.LabelText> 73 + </Toggle.Item> 74 + <Toggle.Item 75 + name="most-likes" 76 + label={_(msg`Most-liked replies first`)}> 77 + <Toggle.Radio /> 78 + <Toggle.LabelText> 79 + <Trans>Most-liked first</Trans> 80 + </Toggle.LabelText> 81 + </Toggle.Item> 82 + <Toggle.Item 83 + name="random" 84 + label={_(msg`Random (aka "Poster's Roulette")`)}> 85 + <Toggle.Radio /> 86 + <Toggle.LabelText> 87 + <Trans>Random (aka "Poster's Roulette")</Trans> 88 + </Toggle.LabelText> 89 + </Toggle.Item> 90 + </View> 91 + </Toggle.Group> 92 + </View> 93 + </SettingsList.Group> 94 + <SettingsList.Group> 95 + <SettingsList.ItemIcon icon={PersonGroupIcon} /> 96 + <SettingsList.ItemText> 97 + <Trans>Prioritize your Follows</Trans> 98 + </SettingsList.ItemText> 99 + <Toggle.Item 100 + type="checkbox" 101 + name="prioritize-follows" 102 + label={_(msg`Prioritize your Follows`)} 103 + value={prioritizeFollowedUsers} 104 + onChange={value => 105 + setThreadViewPrefs({ 106 + prioritizeFollowedUsers: value, 107 + }) 108 + } 109 + style={[a.w_full, a.gap_md]}> 110 + <Toggle.LabelText style={[a.flex_1]}> 111 + <Trans> 112 + Show replies by people you follow before all other replies. 113 + </Trans> 114 + </Toggle.LabelText> 115 + <Toggle.Platform /> 116 + </Toggle.Item> 117 + </SettingsList.Group> 118 + <SettingsList.Divider /> 119 + <SettingsList.Group> 120 + <SettingsList.ItemIcon icon={BeakerIcon} /> 121 + <SettingsList.ItemText> 122 + <Trans>Experimental</Trans> 123 + </SettingsList.ItemText> 124 + <Toggle.Item 125 + type="checkbox" 126 + name="threaded-mode" 127 + label={_(msg`Threaded mode`)} 128 + value={treeViewEnabled} 129 + onChange={value => 130 + setThreadViewPrefs({ 131 + lab_treeViewEnabled: value, 132 + }) 133 + } 134 + style={[a.w_full, a.gap_md]}> 135 + <Toggle.LabelText style={[a.flex_1]}> 136 + <Trans>Show replies in a threaded view</Trans> 137 + </Toggle.LabelText> 138 + <Toggle.Platform /> 139 + </Toggle.Item> 140 + </SettingsList.Group> 141 + </SettingsList.Container> 142 + </Layout.Content> 143 + </Layout.Screen> 144 + ) 145 + }
+280
src/screens/Settings/components/AddAppPasswordDialog.tsx
··· 1 + import React, {useEffect, useMemo, useState} from 'react' 2 + import {useWindowDimensions, View} from 'react-native' 3 + import Animated, { 4 + FadeIn, 5 + FadeOut, 6 + LayoutAnimationConfig, 7 + LinearTransition, 8 + SlideInRight, 9 + SlideOutLeft, 10 + } from 'react-native-reanimated' 11 + import {ComAtprotoServerCreateAppPassword} from '@atproto/api' 12 + import {msg, Trans} from '@lingui/macro' 13 + import {useLingui} from '@lingui/react' 14 + import {useMutation} from '@tanstack/react-query' 15 + 16 + import {isWeb} from '#/platform/detection' 17 + import {useAppPasswordCreateMutation} from '#/state/queries/app-passwords' 18 + import {atoms as a, native, useTheme} from '#/alf' 19 + import {Admonition} from '#/components/Admonition' 20 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 21 + import * as Dialog from '#/components/Dialog' 22 + import * as TextInput from '#/components/forms/TextField' 23 + import * as Toggle from '#/components/forms/Toggle' 24 + import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' 25 + import {SquareBehindSquare4_Stroke2_Corner0_Rounded as CopyIcon} from '#/components/icons/SquareBehindSquare4' 26 + import {Text} from '#/components/Typography' 27 + import {CopyButton} from './CopyButton' 28 + 29 + export function AddAppPasswordDialog({ 30 + control, 31 + passwords, 32 + }: { 33 + control: Dialog.DialogControlProps 34 + passwords: string[] 35 + }) { 36 + const {height} = useWindowDimensions() 37 + return ( 38 + <Dialog.Outer control={control} nativeOptions={{minHeight: height}}> 39 + <Dialog.Handle /> 40 + <CreateDialogInner passwords={passwords} /> 41 + </Dialog.Outer> 42 + ) 43 + } 44 + 45 + function CreateDialogInner({passwords}: {passwords: string[]}) { 46 + const control = Dialog.useDialogContext() 47 + const t = useTheme() 48 + const {_} = useLingui() 49 + const autogeneratedName = useRandomName() 50 + const [name, setName] = useState('') 51 + const [privileged, setPrivileged] = useState(false) 52 + const { 53 + mutateAsync: actuallyCreateAppPassword, 54 + error: apiError, 55 + data, 56 + } = useAppPasswordCreateMutation() 57 + 58 + const regexFailError = useMemo( 59 + () => 60 + new DisplayableError( 61 + _( 62 + msg`App password names can only contain letters, numbers, spaces, dashes, and underscores`, 63 + ), 64 + ), 65 + [_], 66 + ) 67 + 68 + const { 69 + mutate: createAppPassword, 70 + error: validationError, 71 + isPending, 72 + } = useMutation< 73 + ComAtprotoServerCreateAppPassword.AppPassword, 74 + Error | DisplayableError 75 + >({ 76 + mutationFn: async () => { 77 + const chosenName = name.trim() || autogeneratedName 78 + if (chosenName.length < 4) { 79 + throw new DisplayableError( 80 + _(msg`App password names must be at least 4 characters long`), 81 + ) 82 + } 83 + if (passwords.find(p => p === chosenName)) { 84 + throw new DisplayableError(_(msg`App password name must be unique`)) 85 + } 86 + return await actuallyCreateAppPassword({name: chosenName, privileged}) 87 + }, 88 + }) 89 + 90 + const [hasBeenCopied, setHasBeenCopied] = useState(false) 91 + useEffect(() => { 92 + if (hasBeenCopied) { 93 + const timeout = setTimeout(() => setHasBeenCopied(false), 100) 94 + return () => clearTimeout(timeout) 95 + } 96 + }, [hasBeenCopied]) 97 + 98 + const error = 99 + validationError || (!name.match(/^[a-zA-Z0-9-_ ]*$/) && regexFailError) 100 + 101 + return ( 102 + <Dialog.ScrollableInner label={_(msg`Add app password`)}> 103 + <View style={[native(a.pt_md)]}> 104 + <LayoutAnimationConfig skipEntering skipExiting> 105 + {!data ? ( 106 + <Animated.View 107 + style={[a.gap_lg]} 108 + exiting={native(SlideOutLeft)} 109 + key={0}> 110 + <Text style={[a.text_2xl, a.font_bold]}> 111 + <Trans>Add App Password</Trans> 112 + </Text> 113 + <Text style={[a.text_md, a.leading_snug]}> 114 + <Trans> 115 + Please enter a unique name for this app password or use our 116 + randomly generated one. 117 + </Trans> 118 + </Text> 119 + <View> 120 + <TextInput.Root isInvalid={!!error}> 121 + <Dialog.Input 122 + label={_(msg`App Password`)} 123 + placeholder={autogeneratedName} 124 + onChangeText={setName} 125 + returnKeyType="done" 126 + onSubmitEditing={() => createAppPassword()} 127 + blurOnSubmit 128 + autoCorrect={false} 129 + autoComplete="off" 130 + autoCapitalize="none" 131 + autoFocus 132 + /> 133 + </TextInput.Root> 134 + </View> 135 + {error instanceof DisplayableError && ( 136 + <Animated.View entering={FadeIn} exiting={FadeOut}> 137 + <Admonition type="error">{error.message}</Admonition> 138 + </Animated.View> 139 + )} 140 + <Animated.View 141 + style={[a.gap_lg]} 142 + layout={native(LinearTransition)}> 143 + <Toggle.Item 144 + name="privileged" 145 + type="checkbox" 146 + label={_(msg`Allow access to your direct messages`)} 147 + value={privileged} 148 + onChange={setPrivileged} 149 + style={[a.flex_1]}> 150 + <Toggle.Checkbox /> 151 + <Toggle.LabelText 152 + style={[a.font_normal, a.text_md, a.leading_snug]}> 153 + <Trans>Allow access to your direct messages</Trans> 154 + </Toggle.LabelText> 155 + </Toggle.Item> 156 + <Button 157 + label={_(msg`Next`)} 158 + size="large" 159 + variant="solid" 160 + color="primary" 161 + style={[a.flex_1]} 162 + onPress={() => createAppPassword()} 163 + disabled={isPending}> 164 + <ButtonText> 165 + <Trans>Next</Trans> 166 + </ButtonText> 167 + <ButtonIcon icon={ChevronRight} /> 168 + </Button> 169 + {!!apiError || 170 + (error && !(error instanceof DisplayableError) && ( 171 + <Animated.View entering={FadeIn} exiting={FadeOut}> 172 + <Admonition type="error"> 173 + <Trans> 174 + Failed to create app password. Please try again. 175 + </Trans> 176 + </Admonition> 177 + </Animated.View> 178 + ))} 179 + </Animated.View> 180 + </Animated.View> 181 + ) : ( 182 + <Animated.View 183 + style={[a.gap_lg]} 184 + entering={isWeb ? FadeIn.delay(200) : SlideInRight} 185 + key={1}> 186 + <Text style={[a.text_2xl, a.font_bold]}> 187 + <Trans>Here is your app password!</Trans> 188 + </Text> 189 + <Text style={[a.text_md, a.leading_snug]}> 190 + <Trans> 191 + Use this to sign into the other app along with your handle. 192 + </Trans> 193 + </Text> 194 + <CopyButton 195 + value={data.password} 196 + label={_(msg`Copy App Password`)} 197 + size="large" 198 + variant="solid" 199 + color="secondary"> 200 + <ButtonText>{data.password}</ButtonText> 201 + <ButtonIcon icon={CopyIcon} /> 202 + </CopyButton> 203 + <Text 204 + style={[ 205 + a.text_md, 206 + a.leading_snug, 207 + t.atoms.text_contrast_medium, 208 + ]}> 209 + <Trans> 210 + For security reasons, you won't be able to view this again. If 211 + you lose this app password, you'll need to generate a new one. 212 + </Trans> 213 + </Text> 214 + <Button 215 + label={_(msg`Done`)} 216 + size="large" 217 + variant="outline" 218 + color="primary" 219 + style={[a.flex_1]} 220 + onPress={() => control.close()}> 221 + <ButtonText> 222 + <Trans>Done</Trans> 223 + </ButtonText> 224 + </Button> 225 + </Animated.View> 226 + )} 227 + </LayoutAnimationConfig> 228 + </View> 229 + <Dialog.Close /> 230 + </Dialog.ScrollableInner> 231 + ) 232 + } 233 + 234 + class DisplayableError extends Error { 235 + constructor(message: string) { 236 + super(message) 237 + this.name = 'DisplayableError' 238 + } 239 + } 240 + 241 + function useRandomName() { 242 + return useState( 243 + () => shadesOfBlue[Math.floor(Math.random() * shadesOfBlue.length)], 244 + )[0] 245 + } 246 + 247 + const shadesOfBlue: string[] = [ 248 + 'AliceBlue', 249 + 'Aqua', 250 + 'Aquamarine', 251 + 'Azure', 252 + 'BabyBlue', 253 + 'Blue', 254 + 'BlueViolet', 255 + 'CadetBlue', 256 + 'CornflowerBlue', 257 + 'Cyan', 258 + 'DarkBlue', 259 + 'DarkCyan', 260 + 'DarkSlateBlue', 261 + 'DeepSkyBlue', 262 + 'DodgerBlue', 263 + 'ElectricBlue', 264 + 'LightBlue', 265 + 'LightCyan', 266 + 'LightSkyBlue', 267 + 'LightSteelBlue', 268 + 'MediumAquaMarine', 269 + 'MediumBlue', 270 + 'MediumSlateBlue', 271 + 'MidnightBlue', 272 + 'Navy', 273 + 'PowderBlue', 274 + 'RoyalBlue', 275 + 'SkyBlue', 276 + 'SlateBlue', 277 + 'SteelBlue', 278 + 'Teal', 279 + 'Turquoise', 280 + ]
+602
src/screens/Settings/components/ChangeHandleDialog.tsx
··· 1 + import React, {useCallback, useMemo, useState} from 'react' 2 + import {useWindowDimensions, View} from 'react-native' 3 + import Animated, { 4 + FadeIn, 5 + FadeOut, 6 + LayoutAnimationConfig, 7 + LinearTransition, 8 + SlideInLeft, 9 + SlideInRight, 10 + SlideOutLeft, 11 + SlideOutRight, 12 + } from 'react-native-reanimated' 13 + import {ComAtprotoServerDescribeServer} from '@atproto/api' 14 + import {msg, Trans} from '@lingui/macro' 15 + import {useLingui} from '@lingui/react' 16 + import {useMutation, useQueryClient} from '@tanstack/react-query' 17 + 18 + import {HITSLOP_10} from '#/lib/constants' 19 + import {cleanError} from '#/lib/strings/errors' 20 + import {createFullHandle, validateHandle} from '#/lib/strings/handles' 21 + import {useFetchDid, useUpdateHandleMutation} from '#/state/queries/handle' 22 + import {RQKEY as RQKEY_PROFILE} from '#/state/queries/profile' 23 + import {useServiceQuery} from '#/state/queries/service' 24 + import {useAgent, useSession} from '#/state/session' 25 + import {ErrorScreen} from '#/view/com/util/error/ErrorScreen' 26 + import {atoms as a, native, useBreakpoints, useTheme} from '#/alf' 27 + import {Admonition} from '#/components/Admonition' 28 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 29 + import * as Dialog from '#/components/Dialog' 30 + import * as TextField from '#/components/forms/TextField' 31 + import * as ToggleButton from '#/components/forms/ToggleButton' 32 + import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon} from '#/components/icons/Arrow' 33 + import {At_Stroke2_Corner0_Rounded as AtIcon} from '#/components/icons/At' 34 + import {CheckThick_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check' 35 + import {SquareBehindSquare4_Stroke2_Corner0_Rounded as CopyIcon} from '#/components/icons/SquareBehindSquare4' 36 + import {InlineLinkText} from '#/components/Link' 37 + import {Loader} from '#/components/Loader' 38 + import {Text} from '#/components/Typography' 39 + import {CopyButton} from './CopyButton' 40 + 41 + export function ChangeHandleDialog({ 42 + control, 43 + }: { 44 + control: Dialog.DialogControlProps 45 + }) { 46 + const {height} = useWindowDimensions() 47 + 48 + return ( 49 + <Dialog.Outer control={control} nativeOptions={{minHeight: height}}> 50 + <ChangeHandleDialogInner /> 51 + </Dialog.Outer> 52 + ) 53 + } 54 + 55 + function ChangeHandleDialogInner() { 56 + const control = Dialog.useDialogContext() 57 + const {_} = useLingui() 58 + const agent = useAgent() 59 + const { 60 + data: serviceInfo, 61 + error: serviceInfoError, 62 + refetch, 63 + } = useServiceQuery(agent.serviceUrl.toString()) 64 + 65 + const [page, setPage] = useState<'provided-handle' | 'own-handle'>( 66 + 'provided-handle', 67 + ) 68 + 69 + const cancelButton = useCallback( 70 + () => ( 71 + <Button 72 + label={_(msg`Cancel`)} 73 + onPress={() => control.close()} 74 + size="small" 75 + color="primary" 76 + variant="ghost" 77 + style={[a.rounded_full]}> 78 + <ButtonText style={[a.text_md]}> 79 + <Trans>Cancel</Trans> 80 + </ButtonText> 81 + </Button> 82 + ), 83 + [control, _], 84 + ) 85 + 86 + return ( 87 + <Dialog.ScrollableInner 88 + label={_(msg`Change Handle`)} 89 + style={[a.overflow_hidden]} 90 + header={ 91 + <Dialog.Header renderLeft={cancelButton}> 92 + <Dialog.HeaderText> 93 + <Trans>Change Handle</Trans> 94 + </Dialog.HeaderText> 95 + </Dialog.Header> 96 + } 97 + contentContainerStyle={[a.pt_0, a.px_0]}> 98 + <View style={[a.flex_1, a.pt_lg, a.px_xl]}> 99 + {serviceInfoError ? ( 100 + <ErrorScreen 101 + title={_(msg`Oops!`)} 102 + message={_(msg`There was an issue fetching your service info`)} 103 + details={cleanError(serviceInfoError)} 104 + onPressTryAgain={refetch} 105 + /> 106 + ) : serviceInfo ? ( 107 + <LayoutAnimationConfig skipEntering skipExiting> 108 + {page === 'provided-handle' ? ( 109 + <Animated.View 110 + key={page} 111 + entering={native(SlideInLeft)} 112 + exiting={native(SlideOutLeft)}> 113 + <ProvidedHandlePage 114 + serviceInfo={serviceInfo} 115 + goToOwnHandle={() => setPage('own-handle')} 116 + /> 117 + </Animated.View> 118 + ) : ( 119 + <Animated.View 120 + key={page} 121 + entering={native(SlideInRight)} 122 + exiting={native(SlideOutRight)}> 123 + <OwnHandlePage 124 + goToServiceHandle={() => setPage('provided-handle')} 125 + /> 126 + </Animated.View> 127 + )} 128 + </LayoutAnimationConfig> 129 + ) : ( 130 + <View style={[a.flex_1, a.justify_center, a.align_center, a.py_4xl]}> 131 + <Loader size="xl" /> 132 + </View> 133 + )} 134 + </View> 135 + </Dialog.ScrollableInner> 136 + ) 137 + } 138 + 139 + function ProvidedHandlePage({ 140 + serviceInfo, 141 + goToOwnHandle, 142 + }: { 143 + serviceInfo: ComAtprotoServerDescribeServer.OutputSchema 144 + goToOwnHandle: () => void 145 + }) { 146 + const {_} = useLingui() 147 + const [subdomain, setSubdomain] = useState('') 148 + const agent = useAgent() 149 + const control = Dialog.useDialogContext() 150 + const {currentAccount} = useSession() 151 + const queryClient = useQueryClient() 152 + 153 + const { 154 + mutate: changeHandle, 155 + isPending, 156 + error, 157 + isSuccess, 158 + } = useUpdateHandleMutation({ 159 + onSuccess: () => { 160 + if (currentAccount) { 161 + queryClient.invalidateQueries({ 162 + queryKey: RQKEY_PROFILE(currentAccount.did), 163 + }) 164 + } 165 + agent.resumeSession(agent.session!).then(() => control.close()) 166 + }, 167 + }) 168 + 169 + const host = serviceInfo.availableUserDomains[0] 170 + 171 + const validation = useMemo( 172 + () => validateHandle(subdomain, host), 173 + [subdomain, host], 174 + ) 175 + 176 + const isTooLong = subdomain.length > 18 177 + const isInvalid = 178 + isTooLong || 179 + !validation.handleChars || 180 + !validation.hyphenStartOrEnd || 181 + !validation.totalLength 182 + 183 + return ( 184 + <LayoutAnimationConfig skipEntering> 185 + <View style={[a.flex_1, a.gap_md]}> 186 + {isSuccess && ( 187 + <Animated.View entering={FadeIn} exiting={FadeOut}> 188 + <SuccessMessage text={_(msg`Handle changed!`)} /> 189 + </Animated.View> 190 + )} 191 + {error && ( 192 + <Animated.View entering={FadeIn} exiting={FadeOut}> 193 + <ChangeHandleError error={error} /> 194 + </Animated.View> 195 + )} 196 + <Animated.View 197 + layout={native(LinearTransition)} 198 + style={[a.flex_1, a.gap_md]}> 199 + <View> 200 + <TextField.LabelText> 201 + <Trans>New handle</Trans> 202 + </TextField.LabelText> 203 + <TextField.Root isInvalid={isInvalid}> 204 + <TextField.Icon icon={AtIcon} /> 205 + <Dialog.Input 206 + editable={!isPending} 207 + defaultValue={subdomain} 208 + onChangeText={text => setSubdomain(text)} 209 + label={_(msg`New handle`)} 210 + placeholder={_(msg`e.g. alice`)} 211 + autoCapitalize="none" 212 + autoCorrect={false} 213 + /> 214 + <TextField.SuffixText label={host} style={[{maxWidth: '40%'}]}> 215 + {host} 216 + </TextField.SuffixText> 217 + </TextField.Root> 218 + </View> 219 + <Text> 220 + <Trans> 221 + Your full handle will be{' '} 222 + <Text style={[a.font_bold]}> 223 + @{createFullHandle(subdomain, host)} 224 + </Text> 225 + </Trans> 226 + </Text> 227 + <Button 228 + label={_(msg`Save new handle`)} 229 + variant="solid" 230 + size="large" 231 + color={validation.overall && !isTooLong ? 'primary' : 'secondary'} 232 + disabled={!validation.overall && !isTooLong} 233 + onPress={() => { 234 + if (validation.overall && !isTooLong) { 235 + changeHandle({handle: createFullHandle(subdomain, host)}) 236 + } 237 + }}> 238 + {isPending ? ( 239 + <ButtonIcon icon={Loader} /> 240 + ) : ( 241 + <ButtonText> 242 + <Trans>Save</Trans> 243 + </ButtonText> 244 + )} 245 + </Button> 246 + <Text style={[a.leading_snug]}> 247 + <Trans> 248 + If you have your own domain, you can use that as your handle. This 249 + lets you self-verify your identity –{' '} 250 + <InlineLinkText 251 + label={_(msg`learn more`)} 252 + to="https://bsky.social/about/blog/4-28-2023-domain-handle-tutorial" 253 + style={[a.font_bold]} 254 + disableMismatchWarning> 255 + learn more 256 + </InlineLinkText> 257 + . 258 + </Trans> 259 + </Text> 260 + <Button 261 + label={_(msg`I have my own domain`)} 262 + variant="outline" 263 + color="primary" 264 + size="large" 265 + onPress={goToOwnHandle}> 266 + <ButtonText> 267 + <Trans>I have my own domain</Trans> 268 + </ButtonText> 269 + <ButtonIcon icon={ArrowRightIcon} /> 270 + </Button> 271 + </Animated.View> 272 + </View> 273 + </LayoutAnimationConfig> 274 + ) 275 + } 276 + 277 + function OwnHandlePage({goToServiceHandle}: {goToServiceHandle: () => void}) { 278 + const {_} = useLingui() 279 + const t = useTheme() 280 + const {currentAccount} = useSession() 281 + const [dnsPanel, setDNSPanel] = useState(true) 282 + const [domain, setDomain] = useState('') 283 + const agent = useAgent() 284 + const control = Dialog.useDialogContext() 285 + const fetchDid = useFetchDid() 286 + const queryClient = useQueryClient() 287 + 288 + const { 289 + mutate: changeHandle, 290 + isPending, 291 + error, 292 + isSuccess, 293 + } = useUpdateHandleMutation({ 294 + onSuccess: () => { 295 + if (currentAccount) { 296 + queryClient.invalidateQueries({ 297 + queryKey: RQKEY_PROFILE(currentAccount.did), 298 + }) 299 + } 300 + agent.resumeSession(agent.session!).then(() => control.close()) 301 + }, 302 + }) 303 + 304 + const { 305 + mutate: verify, 306 + isPending: isVerifyPending, 307 + isSuccess: isVerified, 308 + error: verifyError, 309 + reset: resetVerification, 310 + } = useMutation<true, Error | DidMismatchError>({ 311 + mutationKey: ['verify-handle', domain], 312 + mutationFn: async () => { 313 + const did = await fetchDid(domain) 314 + if (did !== currentAccount?.did) { 315 + throw new DidMismatchError(did) 316 + } 317 + return true 318 + }, 319 + }) 320 + 321 + return ( 322 + <View style={[a.flex_1, a.gap_lg]}> 323 + {isSuccess && ( 324 + <Animated.View entering={FadeIn} exiting={FadeOut}> 325 + <SuccessMessage text={_(msg`Handle changed!`)} /> 326 + </Animated.View> 327 + )} 328 + {error && ( 329 + <Animated.View entering={FadeIn} exiting={FadeOut}> 330 + <ChangeHandleError error={error} /> 331 + </Animated.View> 332 + )} 333 + {verifyError && ( 334 + <Animated.View entering={FadeIn} exiting={FadeOut}> 335 + <Admonition type="error"> 336 + {verifyError instanceof DidMismatchError ? ( 337 + <Trans> 338 + Wrong DID returned from server. Received: {verifyError.did} 339 + </Trans> 340 + ) : ( 341 + <Trans>Failed to verify handle. Please try again.</Trans> 342 + )} 343 + </Admonition> 344 + </Animated.View> 345 + )} 346 + <Animated.View 347 + layout={native(LinearTransition)} 348 + style={[a.flex_1, a.gap_md, a.overflow_hidden]}> 349 + <View> 350 + <TextField.LabelText> 351 + <Trans>Enter the domain you want to use</Trans> 352 + </TextField.LabelText> 353 + <TextField.Root> 354 + <TextField.Icon icon={AtIcon} /> 355 + <Dialog.Input 356 + label={_(msg`New handle`)} 357 + placeholder={_(msg`e.g. alice.com`)} 358 + editable={!isPending} 359 + defaultValue={domain} 360 + onChangeText={text => { 361 + setDomain(text) 362 + resetVerification() 363 + }} 364 + autoCapitalize="none" 365 + autoCorrect={false} 366 + /> 367 + </TextField.Root> 368 + </View> 369 + <ToggleButton.Group 370 + label={_(msg`Choose domain verification method`)} 371 + values={[dnsPanel ? 'dns' : 'file']} 372 + onChange={values => setDNSPanel(values[0] === 'dns')}> 373 + <ToggleButton.Button name="dns" label={_(msg`DNS Panel`)}> 374 + <ToggleButton.ButtonText> 375 + <Trans>DNS Panel</Trans> 376 + </ToggleButton.ButtonText> 377 + </ToggleButton.Button> 378 + <ToggleButton.Button name="file" label={_(msg`No DNS Panel`)}> 379 + <ToggleButton.ButtonText> 380 + <Trans>No DNS Panel</Trans> 381 + </ToggleButton.ButtonText> 382 + </ToggleButton.Button> 383 + </ToggleButton.Group> 384 + {dnsPanel ? ( 385 + <> 386 + <Text> 387 + <Trans>Add the following DNS record to your domain:</Trans> 388 + </Text> 389 + <View 390 + style={[ 391 + t.atoms.bg_contrast_25, 392 + a.rounded_sm, 393 + a.p_md, 394 + a.border, 395 + t.atoms.border_contrast_low, 396 + ]}> 397 + <Text style={[t.atoms.text_contrast_medium]}> 398 + <Trans>Host:</Trans> 399 + </Text> 400 + <View style={[a.py_xs]}> 401 + <CopyButton 402 + variant="solid" 403 + color="secondary" 404 + value="_atproto" 405 + label={_(msg`Copy host`)} 406 + hoverStyle={[a.bg_transparent]} 407 + hitSlop={HITSLOP_10}> 408 + <Text style={[a.text_md, a.flex_1]}>_atproto</Text> 409 + <ButtonIcon icon={CopyIcon} /> 410 + </CopyButton> 411 + </View> 412 + <Text style={[a.mt_xs, t.atoms.text_contrast_medium]}> 413 + <Trans>Type:</Trans> 414 + </Text> 415 + <View style={[a.py_xs]}> 416 + <Text style={[a.text_md]}>TXT</Text> 417 + </View> 418 + <Text style={[a.mt_xs, t.atoms.text_contrast_medium]}> 419 + <Trans>Value:</Trans> 420 + </Text> 421 + <View style={[a.py_xs]}> 422 + <CopyButton 423 + variant="solid" 424 + color="secondary" 425 + value={'did=' + currentAccount?.did} 426 + label={_(msg`Copy TXT record value`)} 427 + hoverStyle={[a.bg_transparent]} 428 + hitSlop={HITSLOP_10}> 429 + <Text style={[a.text_md, a.flex_1]}> 430 + did={currentAccount?.did} 431 + </Text> 432 + <ButtonIcon icon={CopyIcon} /> 433 + </CopyButton> 434 + </View> 435 + </View> 436 + <Text> 437 + <Trans>This should create a domain record at:</Trans> 438 + </Text> 439 + <View 440 + style={[ 441 + t.atoms.bg_contrast_25, 442 + a.rounded_sm, 443 + a.p_md, 444 + a.border, 445 + t.atoms.border_contrast_low, 446 + ]}> 447 + <Text style={[a.text_md]}>_atproto.{domain}</Text> 448 + </View> 449 + </> 450 + ) : ( 451 + <> 452 + <Text> 453 + <Trans>Upload a text file to:</Trans> 454 + </Text> 455 + <View 456 + style={[ 457 + t.atoms.bg_contrast_25, 458 + a.rounded_sm, 459 + a.p_md, 460 + a.border, 461 + t.atoms.border_contrast_low, 462 + ]}> 463 + <Text style={[a.text_md]}> 464 + https://{domain}/.well-known/atproto-did 465 + </Text> 466 + </View> 467 + <Text> 468 + <Trans>That contains the following:</Trans> 469 + </Text> 470 + <CopyButton 471 + value={currentAccount?.did ?? ''} 472 + label={_(msg`Copy DID`)} 473 + size="large" 474 + variant="solid" 475 + color="secondary" 476 + style={[a.px_md, a.border, t.atoms.border_contrast_low]}> 477 + <Text style={[a.text_md, a.flex_1]}>{currentAccount?.did}</Text> 478 + <ButtonIcon icon={CopyIcon} /> 479 + </CopyButton> 480 + </> 481 + )} 482 + </Animated.View> 483 + {isVerified && ( 484 + <Animated.View 485 + entering={FadeIn} 486 + exiting={FadeOut} 487 + layout={native(LinearTransition)}> 488 + <SuccessMessage text={_(msg`Domain verified!`)} /> 489 + </Animated.View> 490 + )} 491 + <Animated.View layout={native(LinearTransition)}> 492 + <Button 493 + label={ 494 + isVerified 495 + ? _(msg`Update to ${domain}`) 496 + : dnsPanel 497 + ? _(msg`Verify DNS Record`) 498 + : _(msg`Verify Text File`) 499 + } 500 + variant="solid" 501 + size="large" 502 + color={domain.trim().length > 0 ? 'primary' : 'secondary'} 503 + disabled={domain.trim().length === 0} 504 + onPress={() => { 505 + if (isVerified) { 506 + changeHandle({handle: domain}) 507 + } else { 508 + verify() 509 + } 510 + }}> 511 + {isPending || isVerifyPending ? ( 512 + <ButtonIcon icon={Loader} /> 513 + ) : ( 514 + <ButtonText> 515 + {isVerified ? ( 516 + <Trans>Update to {domain}</Trans> 517 + ) : dnsPanel ? ( 518 + <Trans>Verify DNS Record</Trans> 519 + ) : ( 520 + <Trans>Verify Text File</Trans> 521 + )} 522 + </ButtonText> 523 + )} 524 + </Button> 525 + </Animated.View> 526 + <Animated.View layout={native(LinearTransition)}> 527 + <Button 528 + label={_(msg`Use default provider`)} 529 + accessibilityHint={_(msg`Go back to previous page`)} 530 + onPress={goToServiceHandle} 531 + style={[a.p_0, a.justify_start]}> 532 + <ButtonText style={[{color: t.palette.primary_500}, a.text_left]}> 533 + <Trans>Nevermind, create a handle for me</Trans> 534 + </ButtonText> 535 + </Button> 536 + </Animated.View> 537 + </View> 538 + ) 539 + } 540 + 541 + class DidMismatchError extends Error { 542 + did: string 543 + constructor(did: string) { 544 + super('DID mismatch') 545 + this.name = 'DidMismatchError' 546 + this.did = did 547 + } 548 + } 549 + 550 + function ChangeHandleError({error}: {error: unknown}) { 551 + const {_} = useLingui() 552 + 553 + let message = _(msg`Failed to change handle. Please try again.`) 554 + 555 + if (error instanceof Error) { 556 + if (error.message.startsWith('Handle already taken')) { 557 + message = _(msg`Handle already taken. Please try a different one.`) 558 + } else if (error.message === 'Reserved handle') { 559 + message = _(msg`This handle is reserved. Please try a different one.`) 560 + } else if (error.message === 'Handle too long') { 561 + message = _(msg`Handle too long. Please try a shorter one.`) 562 + } else if (error.message === 'Input/handle must be a valid handle') { 563 + message = _(msg`Invalid handle. Please try a different one.`) 564 + } else if (error.message === 'Rate Limit Exceeded') { 565 + message = _( 566 + msg`Rate limit exceeded – you've tried to change your handle too many times in a short period. Please wait a minute before trying again.`, 567 + ) 568 + } 569 + } 570 + 571 + return <Admonition type="error">{message}</Admonition> 572 + } 573 + 574 + function SuccessMessage({text}: {text: string}) { 575 + const {gtMobile} = useBreakpoints() 576 + const t = useTheme() 577 + return ( 578 + <View 579 + style={[ 580 + a.flex_1, 581 + a.gap_md, 582 + a.flex_row, 583 + a.justify_center, 584 + a.align_center, 585 + gtMobile ? a.px_md : a.px_sm, 586 + a.py_xs, 587 + t.atoms.border_contrast_low, 588 + ]}> 589 + <View 590 + style={[ 591 + {height: 20, width: 20}, 592 + a.rounded_full, 593 + a.align_center, 594 + a.justify_center, 595 + {backgroundColor: t.palette.positive_600}, 596 + ]}> 597 + <CheckIcon fill={t.palette.white} size="xs" /> 598 + </View> 599 + <Text style={[a.text_md]}>{text}</Text> 600 + </View> 601 + ) 602 + }
+69
src/screens/Settings/components/CopyButton.tsx
··· 1 + import React, {useCallback, useEffect, useState} from 'react' 2 + import {GestureResponderEvent, View} from 'react-native' 3 + import Animated, {FadeOutUp, ZoomIn} from 'react-native-reanimated' 4 + import * as Clipboard from 'expo-clipboard' 5 + import {Trans} from '@lingui/macro' 6 + 7 + import {atoms as a, useTheme} from '#/alf' 8 + import {Button, ButtonProps} from '#/components/Button' 9 + import {Text} from '#/components/Typography' 10 + 11 + export function CopyButton({ 12 + style, 13 + value, 14 + onPress: onPressProp, 15 + ...props 16 + }: ButtonProps & {value: string}) { 17 + const [hasBeenCopied, setHasBeenCopied] = useState(false) 18 + const t = useTheme() 19 + 20 + useEffect(() => { 21 + if (hasBeenCopied) { 22 + const timeout = setTimeout(() => setHasBeenCopied(false), 100) 23 + return () => clearTimeout(timeout) 24 + } 25 + }, [hasBeenCopied]) 26 + 27 + const onPress = useCallback( 28 + (evt: GestureResponderEvent) => { 29 + Clipboard.setStringAsync(value) 30 + setHasBeenCopied(true) 31 + onPressProp?.(evt) 32 + }, 33 + [value, onPressProp], 34 + ) 35 + 36 + return ( 37 + <View style={[a.relative]}> 38 + {hasBeenCopied && ( 39 + <Animated.View 40 + entering={ZoomIn.duration(100)} 41 + exiting={FadeOutUp.duration(2000)} 42 + style={[ 43 + a.absolute, 44 + {bottom: '100%', right: 0}, 45 + a.justify_center, 46 + a.gap_sm, 47 + a.z_10, 48 + a.pb_sm, 49 + ]} 50 + pointerEvents="none"> 51 + <Text 52 + style={[ 53 + a.font_bold, 54 + a.text_right, 55 + a.text_md, 56 + t.atoms.text_contrast_high, 57 + ]}> 58 + <Trans>Copied!</Trans> 59 + </Text> 60 + </Animated.View> 61 + )} 62 + <Button 63 + style={[a.flex_1, a.justify_between, style]} 64 + onPress={onPress} 65 + {...props} 66 + /> 67 + </View> 68 + ) 69 + }
+10 -4
src/screens/Settings/components/SettingsList.tsx
··· 2 2 import {GestureResponderEvent, StyleProp, View, ViewStyle} from 'react-native' 3 3 4 4 import {HITSLOP_10} from '#/lib/constants' 5 - import {atoms as a, useTheme} from '#/alf' 5 + import {atoms as a, useTheme, ViewStyleProp} from '#/alf' 6 6 import * as Button from '#/components/Button' 7 7 import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon} from '#/components/icons/Chevron' 8 8 import {Link, LinkProps} from '#/components/Link' ··· 17 17 const Portal = createPortalGroup() 18 18 19 19 export function Container({children}: {children: React.ReactNode}) { 20 - return <View style={[a.flex_1, a.py_lg]}>{children}</View> 20 + return <View style={[a.flex_1, a.py_md]}>{children}</View> 21 21 } 22 22 23 23 /** ··· 241 241 } 242 242 } 243 243 244 - export function Divider() { 244 + export function Divider({style}: ViewStyleProp) { 245 245 const t = useTheme() 246 246 return ( 247 247 <View 248 - style={[a.border_t, t.atoms.border_contrast_medium, a.w_full, a.my_sm]} 248 + style={[ 249 + a.border_t, 250 + t.atoms.border_contrast_medium, 251 + a.w_full, 252 + a.my_sm, 253 + style, 254 + ]} 249 255 /> 250 256 ) 251 257 }
+4 -1
src/state/queries/handle.ts
··· 32 32 ) 33 33 } 34 34 35 - export function useUpdateHandleMutation() { 35 + export function useUpdateHandleMutation(opts?: { 36 + onSuccess?: (handle: string) => void 37 + }) { 36 38 const queryClient = useQueryClient() 37 39 const agent = useAgent() 38 40 ··· 41 43 await agent.updateHandle({handle}) 42 44 }, 43 45 onSuccess(_data, variables) { 46 + opts?.onSuccess?.(variables.handle) 44 47 queryClient.invalidateQueries({ 45 48 queryKey: fetchHandleQueryKey(variables.handle), 46 49 })
+1 -1
src/state/queries/notifications/settings.ts
··· 9 9 import {useAgent} from '#/state/session' 10 10 import * as Toast from '#/view/com/util/Toast' 11 11 12 - export function useNotificationsSettingsMutation() { 12 + export function useNotificationSettingsMutation() { 13 13 const {_} = useLingui() 14 14 const agent = useAgent() 15 15 const queryClient = useQueryClient()
+6 -2
src/view/screens/AppPasswords.tsx
··· 12 12 import {useFocusEffect} from '@react-navigation/native' 13 13 import {NativeStackScreenProps} from '@react-navigation/native-stack' 14 14 15 + import {IS_INTERNAL} from '#/lib/app-info' 15 16 import {usePalette} from '#/lib/hooks/usePalette' 16 17 import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' 17 18 import {CommonNavigatorParams} from '#/lib/routes/types' ··· 28 29 import * as Toast from '#/view/com/util/Toast' 29 30 import {ViewHeader} from '#/view/com/util/ViewHeader' 30 31 import {CenteredView} from '#/view/com/util/Views' 32 + import {AppPasswordsScreen as NewAppPasswordsScreen} from '#/screens/Settings/AppPasswords' 31 33 import {atoms as a} from '#/alf' 32 34 import {useDialogControl} from '#/components/Dialog' 33 35 import * as Layout from '#/components/Layout' 34 36 import * as Prompt from '#/components/Prompt' 35 37 36 38 type Props = NativeStackScreenProps<CommonNavigatorParams, 'AppPasswords'> 37 - export function AppPasswords({}: Props) { 38 - return ( 39 + export function AppPasswords(props: Props) { 40 + return IS_INTERNAL ? ( 41 + <NewAppPasswordsScreen {...props} /> 42 + ) : ( 39 43 <Layout.Screen testID="AppPasswordsScreen"> 40 44 <AppPasswordsInner /> 41 45 </Layout.Screen>
+11 -1
src/view/screens/LanguageSettings.tsx
··· 10 10 import {useFocusEffect} from '@react-navigation/native' 11 11 12 12 import {APP_LANGUAGES, LANGUAGES} from '#/lib/../locale/languages' 13 + import {IS_INTERNAL} from '#/lib/app-info' 13 14 import {usePalette} from '#/lib/hooks/usePalette' 14 15 import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' 15 16 import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' ··· 22 23 import {Text} from '#/view/com/util/text/Text' 23 24 import {ViewHeader} from '#/view/com/util/ViewHeader' 24 25 import {CenteredView} from '#/view/com/util/Views' 26 + import {LanguageSettingsScreen as NewLanguageSettingsScreen} from '#/screens/Settings/LanguageSettings' 25 27 import * as Layout from '#/components/Layout' 26 28 27 29 type Props = NativeStackScreenProps<CommonNavigatorParams, 'LanguageSettings'> 28 30 29 - export function LanguageSettingsScreen(_props: Props) { 31 + export function LanguageSettingsScreen(props: Props) { 32 + return IS_INTERNAL ? ( 33 + <NewLanguageSettingsScreen {...props} /> 34 + ) : ( 35 + <LegacyLanguageSettingsScreen {...props} /> 36 + ) 37 + } 38 + 39 + function LegacyLanguageSettingsScreen(_props: Props) { 30 40 const pal = usePalette('default') 31 41 const {_} = useLingui() 32 42 const langPrefs = useLanguagePrefs()
-89
src/view/screens/NotificationsSettings.tsx
··· 1 - import React from 'react' 2 - import {View} from 'react-native' 3 - import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 4 - import {msg, Trans} from '@lingui/macro' 5 - import {useLingui} from '@lingui/react' 6 - 7 - import {AllNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' 8 - import {useNotificationFeedQuery} from '#/state/queries/notifications/feed' 9 - import {useNotificationsSettingsMutation} from '#/state/queries/notifications/settings' 10 - import {ViewHeader} from '#/view/com/util/ViewHeader' 11 - import {ScrollView} from '#/view/com/util/Views' 12 - import {atoms as a, useTheme} from '#/alf' 13 - import {Admonition} from '#/components/Admonition' 14 - import {Error} from '#/components/Error' 15 - import * as Toggle from '#/components/forms/Toggle' 16 - import * as Layout from '#/components/Layout' 17 - import {Loader} from '#/components/Loader' 18 - import {Text} from '#/components/Typography' 19 - 20 - type Props = NativeStackScreenProps<AllNavigatorParams, 'NotificationsSettings'> 21 - export function NotificationsSettingsScreen({}: Props) { 22 - const {_} = useLingui() 23 - const t = useTheme() 24 - 25 - const {data, isError: isQueryError, refetch} = useNotificationFeedQuery() 26 - const serverPriority = data?.pages.at(0)?.priority 27 - 28 - const { 29 - mutate: onChangePriority, 30 - isPending: isMutationPending, 31 - variables, 32 - } = useNotificationsSettingsMutation() 33 - 34 - const priority = isMutationPending 35 - ? variables[0] === 'enabled' 36 - : serverPriority 37 - 38 - return ( 39 - <Layout.Screen> 40 - <ScrollView stickyHeaderIndices={[0]}> 41 - <ViewHeader 42 - title={_(msg`Notification Settings`)} 43 - showOnDesktop 44 - showBorder 45 - /> 46 - {isQueryError ? ( 47 - <Error 48 - title={_(msg`Oops!`)} 49 - message={_(msg`Something went wrong!`)} 50 - onRetry={refetch} 51 - sideBorders={false} 52 - /> 53 - ) : ( 54 - <View style={[a.p_lg, a.gap_md]}> 55 - <Text style={[a.text_lg, a.font_bold]}> 56 - <FontAwesomeIcon icon="flask" style={t.atoms.text} />{' '} 57 - <Trans>Notification filters</Trans> 58 - </Text> 59 - <Toggle.Group 60 - label={_(msg`Priority notifications`)} 61 - type="checkbox" 62 - values={priority ? ['enabled'] : []} 63 - onChange={onChangePriority} 64 - disabled={typeof priority !== 'boolean' || isMutationPending}> 65 - <View> 66 - <Toggle.Item 67 - name="enabled" 68 - label={_(msg`Enable priority notifications`)} 69 - style={[a.justify_between, a.py_sm]}> 70 - <Toggle.LabelText> 71 - <Trans>Enable priority notifications</Trans> 72 - </Toggle.LabelText> 73 - {!data ? <Loader size="md" /> : <Toggle.Platform />} 74 - </Toggle.Item> 75 - </View> 76 - </Toggle.Group> 77 - <Admonition type="warning" style={[a.mt_sm]}> 78 - <Trans> 79 - Experimental: When this preference is enabled, you'll only 80 - receive reply and quote notifications from users you follow. 81 - We'll continue to add more controls here over time. 82 - </Trans> 83 - </Admonition> 84 - </View> 85 - )} 86 - </ScrollView> 87 - </Layout.Screen> 88 - ) 89 - }
+11 -1
src/view/screens/PreferencesExternalEmbeds.tsx
··· 3 3 import {Trans} from '@lingui/macro' 4 4 import {useFocusEffect} from '@react-navigation/native' 5 5 6 + import {IS_INTERNAL} from '#/lib/app-info' 6 7 import {usePalette} from '#/lib/hooks/usePalette' 7 8 import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' 8 9 import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' ··· 19 20 import {SimpleViewHeader} from '#/view/com/util/SimpleViewHeader' 20 21 import {Text} from '#/view/com/util/text/Text' 21 22 import {ScrollView} from '#/view/com/util/Views' 23 + import {ExternalMediaPreferencesScreen} from '#/screens/Settings/ExternalMediaPreferences' 22 24 import {atoms as a} from '#/alf' 23 25 import * as Layout from '#/components/Layout' 24 26 ··· 26 28 CommonNavigatorParams, 27 29 'PreferencesExternalEmbeds' 28 30 > 29 - export function PreferencesExternalEmbeds({}: Props) { 31 + export function PreferencesExternalEmbeds(props: Props) { 32 + return IS_INTERNAL ? ( 33 + <ExternalMediaPreferencesScreen {...props} /> 34 + ) : ( 35 + <LegacyPreferencesExternalEmbeds {...props} /> 36 + ) 37 + } 38 + 39 + function LegacyPreferencesExternalEmbeds({}: Props) { 30 40 const pal = usePalette('default') 31 41 const setMinimalShellMode = useSetMinimalShellMode() 32 42 const {isTabletOrMobile} = useWebMediaQueries()
+11 -1
src/view/screens/PreferencesFollowingFeed.tsx
··· 4 4 import {msg, Trans} from '@lingui/macro' 5 5 import {useLingui} from '@lingui/react' 6 6 7 + import {IS_INTERNAL} from '#/lib/app-info' 7 8 import {usePalette} from '#/lib/hooks/usePalette' 8 9 import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' 9 10 import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' ··· 16 17 import {SimpleViewHeader} from '#/view/com/util/SimpleViewHeader' 17 18 import {Text} from '#/view/com/util/text/Text' 18 19 import {ScrollView} from '#/view/com/util/Views' 20 + import {FollowingFeedPreferencesScreen} from '#/screens/Settings/FollowingFeedPreferences' 19 21 import {atoms as a} from '#/alf' 20 22 import * as Layout from '#/components/Layout' 21 23 ··· 23 25 CommonNavigatorParams, 24 26 'PreferencesFollowingFeed' 25 27 > 26 - export function PreferencesFollowingFeed({}: Props) { 28 + export function PreferencesFollowingFeed(props: Props) { 29 + return IS_INTERNAL ? ( 30 + <FollowingFeedPreferencesScreen {...props} /> 31 + ) : ( 32 + <LegacyPreferencesFollowingFeed {...props} /> 33 + ) 34 + } 35 + 36 + function LegacyPreferencesFollowingFeed({}: Props) { 27 37 const pal = usePalette('default') 28 38 const {_} = useLingui() 29 39 const {isTabletOrMobile} = useWebMediaQueries()
+11 -1
src/view/screens/PreferencesThreads.tsx
··· 4 4 import {msg, Trans} from '@lingui/macro' 5 5 import {useLingui} from '@lingui/react' 6 6 7 + import {IS_INTERNAL} from '#/lib/app-info' 7 8 import {usePalette} from '#/lib/hooks/usePalette' 8 9 import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' 9 10 import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' ··· 17 18 import {SimpleViewHeader} from '#/view/com/util/SimpleViewHeader' 18 19 import {Text} from '#/view/com/util/text/Text' 19 20 import {ScrollView} from '#/view/com/util/Views' 21 + import {ThreadPreferencesScreen} from '#/screens/Settings/ThreadPreferences' 20 22 import {atoms as a} from '#/alf' 21 23 import * as Layout from '#/components/Layout' 22 24 23 25 type Props = NativeStackScreenProps<CommonNavigatorParams, 'PreferencesThreads'> 24 - export function PreferencesThreads({}: Props) { 26 + export function PreferencesThreads(props: Props) { 27 + return IS_INTERNAL ? ( 28 + <ThreadPreferencesScreen {...props} /> 29 + ) : ( 30 + <LegacyPreferencesThreads {...props} /> 31 + ) 32 + } 33 + 34 + function LegacyPreferencesThreads({}: Props) { 25 35 const pal = usePalette('default') 26 36 const {_} = useLingui() 27 37 const {isTabletOrMobile} = useWebMediaQueries()