[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>
This commit is contained in:
Samuel Newman
2024-10-31 20:45:34 +00:00
committed by GitHub
parent d85dcc3dd0
commit aa6aad652e
27 changed files with 1991 additions and 128 deletions
@@ -0,0 +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>

After

Width:  |  Height:  |  Size: 1.1 KiB

+3 -3
View File
@@ -55,7 +55,6 @@ import {ModerationModlistsScreen} from '#/view/screens/ModerationModlists'
import {ModerationMutedAccounts} from '#/view/screens/ModerationMutedAccounts'
import {NotFoundScreen} from '#/view/screens/NotFound'
import {NotificationsScreen} from '#/view/screens/Notifications'
import {NotificationsSettingsScreen} from '#/view/screens/NotificationsSettings'
import {PostThreadScreen} from '#/view/screens/PostThread'
import {PreferencesExternalEmbeds} from '#/view/screens/PreferencesExternalEmbeds'
import {PreferencesFollowingFeed} from '#/view/screens/PreferencesFollowingFeed'
@@ -87,6 +86,7 @@ import {PostRepostedByScreen} from '#/screens/Post/PostRepostedBy'
import {ProfileKnownFollowersScreen} from '#/screens/Profile/KnownFollowers'
import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy'
import {AppearanceSettingsScreen} from '#/screens/Settings/AppearanceSettings'
import {NotificationSettingsScreen} from '#/screens/Settings/NotificationSettings'
import {
StarterPackScreen,
StarterPackScreenShort,
@@ -378,8 +378,8 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
options={{title: title(msg`Chat settings`), requireAuth: true}}
/>
<Stack.Screen
name="NotificationsSettings"
getComponent={() => NotificationsSettingsScreen}
name="NotificationSettings"
getComponent={() => NotificationSettingsScreen}
options={{title: title(msg`Notification settings`), requireAuth: true}}
/>
<Stack.Screen
+1 -1
View File
@@ -8,7 +8,7 @@ import {Leaf_Stroke2_Corner0_Rounded as TipIcon} from '#/components/icons/Leaf'
import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
import {Text as BaseText, TextProps} from '#/components/Typography'
const colors = {
export const colors = {
warning: {
light: '#DFBC00',
dark: '#BFAF1F',
+1
View File
@@ -326,6 +326,7 @@ export function SuffixText({
<Text
accessibilityLabel={label}
accessibilityHint={accessibilityHint}
numberOfLines={1}
style={[
a.z_20,
a.pr_sm,
+5
View File
@@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const Beaker_Stroke2_Corner2_Rounded = createSinglePathSVG({
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',
})
+1 -1
View File
@@ -48,7 +48,7 @@ export type CommonNavigatorParams = {
Hashtag: {tag: string; author?: string}
MessagesConversation: {conversation: string; embed?: string}
MessagesSettings: undefined
NotificationsSettings: undefined
NotificationSettings: undefined
Feeds: undefined
Start: {name: string; rkey: string}
StarterPack: {name: string; rkey: string; new?: boolean}
+1 -1
View File
@@ -5,7 +5,7 @@ export const router = new Router({
Search: '/search',
Feeds: '/feeds',
Notifications: '/notifications',
NotificationsSettings: '/notifications/settings',
NotificationSettings: '/notifications/settings',
Settings: '/settings',
Lists: '/lists',
// moderation
+5 -21
View File
@@ -2,12 +2,9 @@ import React from 'react'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {NativeStackScreenProps} from '@react-navigation/native-stack'
import {useQueryClient} from '@tanstack/react-query'
import {CommonNavigatorParams} from '#/lib/routes/types'
import {useModalControls} from '#/state/modals'
import {RQKEY as RQKEY_PROFILE} from '#/state/queries/profile'
import {useProfileQuery} from '#/state/queries/profile'
import {useSession} from '#/state/session'
import {ExportCarDialog} from '#/view/screens/Settings/ExportCarDialog'
import * as SettingsList from '#/screens/Settings/components/SettingsList'
@@ -25,6 +22,7 @@ import {PencilLine_Stroke2_Corner2_Rounded as PencilIcon} from '#/components/ico
import {Trash_Stroke2_Corner2_Rounded} from '#/components/icons/Trash'
import {Verified_Stroke2_Corner2_Rounded as VerifiedIcon} from '#/components/icons/Verified'
import * as Layout from '#/components/Layout'
import {ChangeHandleDialog} from './components/ChangeHandleDialog'
import {DeactivateAccountDialog} from './components/DeactivateAccountDialog'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'AccountSettings'>
@@ -32,10 +30,9 @@ export function AccountSettingsScreen({}: Props) {
const t = useTheme()
const {_} = useLingui()
const {currentAccount} = useSession()
const queryClient = useQueryClient()
const {data: profile} = useProfileQuery({did: currentAccount?.did})
const {openModal} = useModalControls()
const birthdayControl = useDialogControl()
const changeHandleControl = useDialogControl()
const exportCarControl = useDialogControl()
const deactivateAccountControl = useDialogControl()
@@ -117,26 +114,12 @@ export function AccountSettingsScreen({}: Props) {
</SettingsList.PressableItem>
<SettingsList.PressableItem
label={_(msg`Handle`)}
onPress={() =>
openModal({
name: 'change-handle',
onChanged() {
if (currentAccount) {
// refresh my profile
queryClient.invalidateQueries({
queryKey: RQKEY_PROFILE(currentAccount.did),
})
}
},
})
}>
accessibilityHint={_(msg`Open change handle dialog`)}
onPress={() => changeHandleControl.open()}>
<SettingsList.ItemIcon icon={AtIcon} />
<SettingsList.ItemText>
<Trans>Handle</Trans>
</SettingsList.ItemText>
{profile && (
<SettingsList.BadgeText>@{profile.handle}</SettingsList.BadgeText>
)}
<SettingsList.Chevron />
</SettingsList.PressableItem>
<SettingsList.Divider />
@@ -173,6 +156,7 @@ export function AccountSettingsScreen({}: Props) {
</Layout.Content>
<BirthDateSettingsDialog control={birthdayControl} />
<ChangeHandleDialog control={changeHandleControl} />
<ExportCarDialog control={exportCarControl} />
<DeactivateAccountDialog control={deactivateAccountControl} />
</Layout.Screen>
+209
View File
@@ -0,0 +1,209 @@
import React, {useCallback} from 'react'
import {View} from 'react-native'
import Animated, {
FadeIn,
FadeOut,
LayoutAnimationConfig,
LinearTransition,
StretchOutY,
} from 'react-native-reanimated'
import {ComAtprotoServerListAppPasswords} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {NativeStackScreenProps} from '@react-navigation/native-stack'
import {CommonNavigatorParams} from '#/lib/routes/types'
import {cleanError} from '#/lib/strings/errors'
import {isWeb} from '#/platform/detection'
import {
useAppPasswordDeleteMutation,
useAppPasswordsQuery,
} from '#/state/queries/app-passwords'
import {EmptyState} from '#/view/com/util/EmptyState'
import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useTheme} from '#/alf'
import {Admonition, colors} from '#/components/Admonition'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {useDialogControl} from '#/components/Dialog'
import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus'
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
import * as Layout from '#/components/Layout'
import {Loader} from '#/components/Loader'
import * as Prompt from '#/components/Prompt'
import {Text} from '#/components/Typography'
import {AddAppPasswordDialog} from './components/AddAppPasswordDialog'
import * as SettingsList from './components/SettingsList'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'AppPasswords'>
export function AppPasswordsScreen({}: Props) {
const {_} = useLingui()
const {data: appPasswords, error} = useAppPasswordsQuery()
const createAppPasswordControl = useDialogControl()
return (
<Layout.Screen testID="AppPasswordsScreen">
<Layout.Header title={_(msg`App Passwords`)} />
<Layout.Content>
{error ? (
<ErrorScreen
title={_(msg`Oops!`)}
message={_(msg`There was an issue fetching your app passwords`)}
details={cleanError(error)}
/>
) : (
<SettingsList.Container>
<SettingsList.Item>
<Admonition type="tip" style={[a.flex_1]}>
<Trans>
Use app passwords to sign in to other Bluesky clients without
giving full access to your account or password.
</Trans>
</Admonition>
</SettingsList.Item>
<SettingsList.Item>
<Button
label={_(msg`Add App Password`)}
size="large"
color="primary"
variant="solid"
onPress={() => createAppPasswordControl.open()}
style={[a.flex_1]}>
<ButtonIcon icon={PlusIcon} />
<ButtonText>
<Trans>Add App Password</Trans>
</ButtonText>
</Button>
</SettingsList.Item>
<SettingsList.Divider />
<LayoutAnimationConfig skipEntering skipExiting>
{appPasswords ? (
appPasswords.length > 0 ? (
<View style={[a.overflow_hidden]}>
{appPasswords.map(appPassword => (
<Animated.View
key={appPassword.name}
style={a.w_full}
entering={FadeIn}
exiting={isWeb ? FadeOut : StretchOutY}
layout={LinearTransition.delay(150)}>
<SettingsList.Item>
<AppPasswordCard appPassword={appPassword} />
</SettingsList.Item>
</Animated.View>
))}
</View>
) : (
<EmptyState
icon="growth"
message={_(msg`No app passwords yet`)}
/>
)
) : (
<View
style={[
a.flex_1,
a.justify_center,
a.align_center,
a.py_4xl,
]}>
<Loader size="xl" />
</View>
)}
</LayoutAnimationConfig>
</SettingsList.Container>
)}
</Layout.Content>
<AddAppPasswordDialog
control={createAppPasswordControl}
passwords={appPasswords?.map(p => p.name) || []}
/>
</Layout.Screen>
)
}
function AppPasswordCard({
appPassword,
}: {
appPassword: ComAtprotoServerListAppPasswords.AppPassword
}) {
const t = useTheme()
const {i18n, _} = useLingui()
const deleteControl = Prompt.usePromptControl()
const {mutateAsync: deleteMutation} = useAppPasswordDeleteMutation()
const onDelete = useCallback(async () => {
await deleteMutation({name: appPassword.name})
Toast.show(_(msg`App password deleted`))
}, [deleteMutation, appPassword.name, _])
return (
<View
style={[
a.w_full,
a.border,
a.rounded_sm,
a.px_md,
a.py_sm,
t.atoms.bg_contrast_25,
t.atoms.border_contrast_low,
]}>
<View
style={[
a.flex_row,
a.justify_between,
a.align_start,
a.w_full,
a.gap_sm,
]}>
<View style={[a.gap_xs]}>
<Text style={[t.atoms.text, a.text_md, a.font_bold]}>
{appPassword.name}
</Text>
<Text style={[t.atoms.text_contrast_medium]}>
<Trans>
Created{' '}
{i18n.date(appPassword.createdAt, {
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</Trans>
</Text>
</View>
<Button
label={_(msg`Delete app password`)}
variant="ghost"
color="negative"
size="small"
style={[a.bg_transparent]}
onPress={() => deleteControl.open()}>
<ButtonIcon icon={TrashIcon} />
</Button>
</View>
{appPassword.privileged && (
<View style={[a.flex_row, a.gap_sm, a.align_center, a.mt_md]}>
<WarningIcon style={[{color: colors.warning[t.scheme]}]} />
<Text style={t.atoms.text_contrast_high}>
<Trans>Allows access to direct messages</Trans>
</Text>
</View>
)}
<Prompt.Basic
control={deleteControl}
title={_(msg`Delete app password?`)}
description={_(
msg`Are you sure you want to delete the app password "${appPassword.name}"?`,
)}
onConfirm={onDelete}
confirmButtonCta={_(msg`Delete`)}
confirmButtonColor="negative"
/>
</View>
)
}
@@ -0,0 +1,99 @@
import React, {Fragment} from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
import {
EmbedPlayerSource,
externalEmbedLabels,
} from '#/lib/strings/embed-player'
import {
useExternalEmbedsPrefs,
useSetExternalEmbedPref,
} from '#/state/preferences'
import {atoms as a, native} from '#/alf'
import {Admonition} from '#/components/Admonition'
import * as Toggle from '#/components/forms/Toggle'
import * as Layout from '#/components/Layout'
import * as SettingsList from './components/SettingsList'
type Props = NativeStackScreenProps<
CommonNavigatorParams,
'PreferencesExternalEmbeds'
>
export function ExternalMediaPreferencesScreen({}: Props) {
const {_} = useLingui()
return (
<Layout.Screen testID="externalMediaPreferencesScreen">
<Layout.Header title={_(msg`External Media Preferences`)} />
<Layout.Content>
<SettingsList.Container>
<SettingsList.Item>
<Admonition type="info" style={[a.flex_1]}>
<Trans>
External media may allow websites to collect information about
you and your device. No information is sent or requested until
you press the "play" button.
</Trans>
</Admonition>
</SettingsList.Item>
<SettingsList.Group iconInset={false}>
<SettingsList.ItemText>
<Trans>Enable media players for</Trans>
</SettingsList.ItemText>
<View style={[a.mt_sm, a.w_full]}>
{native(<SettingsList.Divider style={[a.my_0]} />)}
{Object.entries(externalEmbedLabels)
// TODO: Remove special case when we disable the old integration.
.filter(([key]) => key !== 'tenor')
.map(([key, label]) => (
<Fragment key={key}>
<PrefSelector
source={key as EmbedPlayerSource}
label={label}
key={key}
/>
{native(<SettingsList.Divider style={[a.my_0]} />)}
</Fragment>
))}
</View>
</SettingsList.Group>
</SettingsList.Container>
</Layout.Content>
</Layout.Screen>
)
}
function PrefSelector({
source,
label,
}: {
source: EmbedPlayerSource
label: string
}) {
const setExternalEmbedPref = useSetExternalEmbedPref()
const sources = useExternalEmbedsPrefs()
return (
<Toggle.Item
name={label}
label={label}
type="checkbox"
value={sources?.[source] === 'show'}
onChange={() =>
setExternalEmbedPref(
source,
sources?.[source] === 'show' ? 'hide' : 'show',
)
}
style={[
a.flex_1,
a.py_md,
native([a.justify_between, a.flex_row_reverse]),
]}>
<Toggle.Platform />
<Toggle.LabelText style={[a.text_md]}>{label}</Toggle.LabelText>
</Toggle.Item>
)
}
@@ -0,0 +1,143 @@
import React from 'react'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
import {
usePreferencesQuery,
useSetFeedViewPreferencesMutation,
} from '#/state/queries/preferences'
import {atoms as a} from '#/alf'
import {Admonition} from '#/components/Admonition'
import * as Toggle from '#/components/forms/Toggle'
import {Beaker_Stroke2_Corner2_Rounded as BeakerIcon} from '#/components/icons/Beaker'
import {Bubbles_Stroke2_Corner2_Rounded as BubblesIcon} from '#/components/icons/Bubble'
import {CloseQuote_Stroke2_Corner1_Rounded as QuoteIcon} from '#/components/icons/Quote'
import {Repost_Stroke2_Corner2_Rounded as RepostIcon} from '#/components/icons/Repost'
import * as Layout from '#/components/Layout'
import * as SettingsList from './components/SettingsList'
type Props = NativeStackScreenProps<
CommonNavigatorParams,
'PreferencesFollowingFeed'
>
export function FollowingFeedPreferencesScreen({}: Props) {
const {_} = useLingui()
const {data: preferences} = usePreferencesQuery()
const {mutate: setFeedViewPref, variables} =
useSetFeedViewPreferencesMutation()
const showReplies = !(
variables?.hideReplies ?? preferences?.feedViewPrefs?.hideReplies
)
const showReposts = !(
variables?.hideReposts ?? preferences?.feedViewPrefs?.hideReposts
)
const showQuotePosts = !(
variables?.hideQuotePosts ?? preferences?.feedViewPrefs?.hideQuotePosts
)
const mergeFeedEnabled = Boolean(
variables?.lab_mergeFeedEnabled ??
preferences?.feedViewPrefs?.lab_mergeFeedEnabled,
)
return (
<Layout.Screen testID="followingFeedPreferencesScreen">
<Layout.Header title={_(msg`Following Feed Preferences`)} />
<Layout.Content>
<SettingsList.Container>
<SettingsList.Item>
<Admonition type="tip" style={[a.flex_1]}>
<Trans>These settings only apply to the Following feed.</Trans>
</Admonition>
</SettingsList.Item>
<Toggle.Item
type="checkbox"
name="show-replies"
label={_(msg`Show replies`)}
value={showReplies}
onChange={value =>
setFeedViewPref({
hideReplies: !value,
})
}>
<SettingsList.Item>
<SettingsList.ItemIcon icon={BubblesIcon} />
<SettingsList.ItemText>
<Trans>Show replies</Trans>
</SettingsList.ItemText>
<Toggle.Platform />
</SettingsList.Item>
</Toggle.Item>
<Toggle.Item
type="checkbox"
name="show-reposts"
label={_(msg`Show reposts`)}
value={showReposts}
onChange={value =>
setFeedViewPref({
hideReposts: !value,
})
}>
<SettingsList.Item>
<SettingsList.ItemIcon icon={RepostIcon} />
<SettingsList.ItemText>
<Trans>Show reposts</Trans>
</SettingsList.ItemText>
<Toggle.Platform />
</SettingsList.Item>
</Toggle.Item>
<Toggle.Item
type="checkbox"
name="show-quotes"
label={_(msg`Show quote posts`)}
value={showQuotePosts}
onChange={value =>
setFeedViewPref({
hideQuotePosts: !value,
})
}>
<SettingsList.Item>
<SettingsList.ItemIcon icon={QuoteIcon} />
<SettingsList.ItemText>
<Trans>Show quote posts</Trans>
</SettingsList.ItemText>
<Toggle.Platform />
</SettingsList.Item>
</Toggle.Item>
<SettingsList.Divider />
<SettingsList.Group>
<SettingsList.ItemIcon icon={BeakerIcon} />
<SettingsList.ItemText>
<Trans>Experimental</Trans>
</SettingsList.ItemText>
<Toggle.Item
type="checkbox"
name="merge-feed"
label={_(
msg`Show samples of your saved feeds in your Following feed`,
)}
value={mergeFeedEnabled}
onChange={value =>
setFeedViewPref({
lab_mergeFeedEnabled: value,
})
}
style={[a.w_full, a.gap_md]}>
<Toggle.LabelText style={[a.flex_1]}>
<Trans>
Show samples of your saved feeds in your Following feed
</Trans>
</Toggle.LabelText>
<Toggle.Platform />
</Toggle.Item>
</SettingsList.Group>
</SettingsList.Container>
</Layout.Content>
</Layout.Screen>
)
}
+275
View File
@@ -0,0 +1,275 @@
import React, {useCallback, useMemo} from 'react'
import {View} from 'react-native'
import RNPickerSelect, {PickerSelectProps} from 'react-native-picker-select'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {APP_LANGUAGES, LANGUAGES} from '#/lib/../locale/languages'
import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
import {sanitizeAppLanguageSetting} from '#/locale/helpers'
import {useModalControls} from '#/state/modals'
import {useLanguagePrefs, useLanguagePrefsApi} from '#/state/preferences'
import {atoms as a, useTheme, web} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
import {ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon} from '#/components/icons/Chevron'
import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus'
import * as Layout from '#/components/Layout'
import {Text} from '#/components/Typography'
import * as SettingsList from './components/SettingsList'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'LanguageSettings'>
export function LanguageSettingsScreen({}: Props) {
const {_} = useLingui()
const langPrefs = useLanguagePrefs()
const setLangPrefs = useLanguagePrefsApi()
const t = useTheme()
const {openModal} = useModalControls()
const onPressContentLanguages = useCallback(() => {
openModal({name: 'content-languages-settings'})
}, [openModal])
const onChangePrimaryLanguage = useCallback(
(value: Parameters<PickerSelectProps['onValueChange']>[0]) => {
if (!value) return
if (langPrefs.primaryLanguage !== value) {
setLangPrefs.setPrimaryLanguage(value)
}
},
[langPrefs, setLangPrefs],
)
const onChangeAppLanguage = useCallback(
(value: Parameters<PickerSelectProps['onValueChange']>[0]) => {
if (!value) return
if (langPrefs.appLanguage !== value) {
setLangPrefs.setAppLanguage(sanitizeAppLanguageSetting(value))
}
},
[langPrefs, setLangPrefs],
)
const myLanguages = useMemo(() => {
return (
langPrefs.contentLanguages
.map(lang => LANGUAGES.find(l => l.code2 === lang))
.filter(Boolean)
// @ts-ignore
.map(l => l.name)
.join(', ')
)
}, [langPrefs.contentLanguages])
return (
<Layout.Screen testID="PreferencesLanguagesScreen">
<Layout.Header title={_(msg`Languages`)} />
<Layout.Content>
<SettingsList.Container>
<SettingsList.Group iconInset={false}>
<SettingsList.ItemText>
<Trans>App Language</Trans>
</SettingsList.ItemText>
<View style={[a.gap_md, a.w_full]}>
<Text style={[a.leading_snug]}>
<Trans>
Select your app language for the default text to display in
the app.
</Trans>
</Text>
<View style={[a.relative, web([a.w_full, {maxWidth: 400}])]}>
<RNPickerSelect
placeholder={{}}
value={sanitizeAppLanguageSetting(langPrefs.appLanguage)}
onValueChange={onChangeAppLanguage}
items={APP_LANGUAGES.filter(l => Boolean(l.code2)).map(l => ({
label: l.name,
value: l.code2,
key: l.code2,
}))}
style={{
inputAndroid: {
backgroundColor: t.atoms.bg_contrast_25.backgroundColor,
color: t.atoms.text.color,
fontSize: 14,
letterSpacing: 0.5,
fontWeight: a.font_bold.fontWeight,
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: a.rounded_xs.borderRadius,
},
inputIOS: {
backgroundColor: t.atoms.bg_contrast_25.backgroundColor,
color: t.atoms.text.color,
fontSize: 14,
letterSpacing: 0.5,
fontWeight: a.font_bold.fontWeight,
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: a.rounded_xs.borderRadius,
},
inputWeb: {
flex: 1,
width: '100%',
cursor: 'pointer',
// @ts-ignore web only
'-moz-appearance': 'none',
'-webkit-appearance': 'none',
appearance: 'none',
outline: 0,
borderWidth: 0,
backgroundColor: t.atoms.bg_contrast_25.backgroundColor,
color: t.atoms.text.color,
fontSize: 14,
fontFamily: 'inherit',
letterSpacing: 0.5,
fontWeight: a.font_bold.fontWeight,
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: a.rounded_xs.borderRadius,
},
}}
/>
<View
style={[
a.absolute,
t.atoms.bg_contrast_25,
a.rounded_xs,
a.pointer_events_none,
a.align_center,
a.justify_center,
{
top: 1,
right: 1,
bottom: 1,
width: 40,
},
]}>
<ChevronDownIcon style={[t.atoms.text]} />
</View>
</View>
</View>
</SettingsList.Group>
<SettingsList.Divider />
<SettingsList.Group iconInset={false}>
<SettingsList.ItemText>
<Trans>Primary Language</Trans>
</SettingsList.ItemText>
<View style={[a.gap_md, a.w_full]}>
<Text style={[a.leading_snug]}>
<Trans>
Select your preferred language for translations in your feed.
</Trans>
</Text>
<View style={[a.relative, web([a.w_full, {maxWidth: 400}])]}>
<RNPickerSelect
placeholder={{}}
value={langPrefs.primaryLanguage}
onValueChange={onChangePrimaryLanguage}
items={LANGUAGES.filter(l => Boolean(l.code2)).map(l => ({
label: l.name,
value: l.code2,
key: l.code2 + l.code3,
}))}
style={{
inputAndroid: {
backgroundColor: t.atoms.bg_contrast_25.backgroundColor,
color: t.atoms.text.color,
fontSize: 14,
letterSpacing: 0.5,
fontWeight: a.font_bold.fontWeight,
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: a.rounded_xs.borderRadius,
},
inputIOS: {
backgroundColor: t.atoms.bg_contrast_25.backgroundColor,
color: t.atoms.text.color,
fontSize: 14,
letterSpacing: 0.5,
fontWeight: a.font_bold.fontWeight,
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: a.rounded_xs.borderRadius,
},
inputWeb: {
flex: 1,
width: '100%',
cursor: 'pointer',
// @ts-ignore web only
'-moz-appearance': 'none',
'-webkit-appearance': 'none',
appearance: 'none',
outline: 0,
borderWidth: 0,
backgroundColor: t.atoms.bg_contrast_25.backgroundColor,
color: t.atoms.text.color,
fontSize: 14,
fontFamily: 'inherit',
letterSpacing: 0.5,
fontWeight: a.font_bold.fontWeight,
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: a.rounded_xs.borderRadius,
},
}}
/>
<View
style={{
position: 'absolute',
top: 1,
right: 1,
bottom: 1,
width: 40,
backgroundColor: t.atoms.bg_contrast_25.backgroundColor,
borderRadius: a.rounded_xs.borderRadius,
pointerEvents: 'none',
alignItems: 'center',
justifyContent: 'center',
}}>
<ChevronDownIcon style={t.atoms.text} />
</View>
</View>
</View>
</SettingsList.Group>
<SettingsList.Divider />
<SettingsList.Group iconInset={false}>
<SettingsList.ItemText>
<Trans>Content Languages</Trans>
</SettingsList.ItemText>
<View style={[a.gap_md]}>
<Text style={[a.leading_snug]}>
<Trans>
Select which languages you want your subscribed feeds to
include. If none are selected, all languages will be shown.
</Trans>
</Text>
<Button
label={_(msg`Select content languages`)}
size="small"
color="secondary"
variant="solid"
onPress={onPressContentLanguages}
style={[a.justify_start, web({maxWidth: 400})]}>
<ButtonIcon
icon={myLanguages.length > 0 ? CheckIcon : PlusIcon}
/>
<ButtonText
style={[t.atoms.text, a.text_md, a.flex_1, a.text_left]}
numberOfLines={1}>
{myLanguages.length > 0
? myLanguages
: _(msg`Select languages`)}
</ButtonText>
</Button>
</View>
</SettingsList.Group>
</SettingsList.Container>
</Layout.Content>
</Layout.Screen>
)
}
@@ -0,0 +1,85 @@
import React from 'react'
import {Text} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {AllNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
import {useNotificationFeedQuery} from '#/state/queries/notifications/feed'
import {useNotificationSettingsMutation} from '#/state/queries/notifications/settings'
import {atoms as a} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {Error} from '#/components/Error'
import * as Toggle from '#/components/forms/Toggle'
import {Beaker_Stroke2_Corner2_Rounded as BeakerIcon} from '#/components/icons/Beaker'
import * as Layout from '#/components/Layout'
import {Loader} from '#/components/Loader'
import * as SettingsList from './components/SettingsList'
type Props = NativeStackScreenProps<AllNavigatorParams, 'NotificationSettings'>
export function NotificationSettingsScreen({}: Props) {
const {_} = useLingui()
const {data, isError: isQueryError, refetch} = useNotificationFeedQuery()
const serverPriority = data?.pages.at(0)?.priority
const {
mutate: onChangePriority,
isPending: isMutationPending,
variables,
} = useNotificationSettingsMutation()
const priority = isMutationPending
? variables[0] === 'enabled'
: serverPriority
return (
<Layout.Screen>
<Layout.Header title={_(msg`Notification Settings`)} />
<Layout.Content>
{isQueryError ? (
<Error
title={_(msg`Oops!`)}
message={_(msg`Something went wrong!`)}
onRetry={refetch}
sideBorders={false}
/>
) : (
<SettingsList.Container>
<SettingsList.Group>
<SettingsList.ItemIcon icon={BeakerIcon} />
<SettingsList.ItemText>
<Trans>Notification filters</Trans>
</SettingsList.ItemText>
<Toggle.Group
label={_(msg`Priority notifications`)}
type="checkbox"
values={priority ? ['enabled'] : []}
onChange={onChangePriority}
disabled={typeof priority !== 'boolean' || isMutationPending}>
<Toggle.Item
name="enabled"
label={_(msg`Enable priority notifications`)}
style={[a.flex_1, a.justify_between]}>
<Toggle.LabelText>
<Trans>Enable priority notifications</Trans>
</Toggle.LabelText>
{!data ? <Loader size="md" /> : <Toggle.Platform />}
</Toggle.Item>
</Toggle.Group>
</SettingsList.Group>
<SettingsList.Item>
<Admonition type="warning" style={[a.flex_1]}>
<Trans>
<Text style={[a.font_bold]}>Experimental:</Text> When this
preference is enabled, you'll only receive reply and quote
notifications from users you follow. We'll continue to add
more controls here over time.
</Trans>
</Admonition>
</SettingsList.Item>
</SettingsList.Container>
)}
</Layout.Content>
</Layout.Screen>
)
}
+1
View File
@@ -60,6 +60,7 @@ export function SettingsScreen({}: Props) {
<View
style={[
a.px_xl,
a.pt_md,
a.pb_md,
a.w_full,
a.gap_2xs,
+145
View File
@@ -0,0 +1,145 @@
import React from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
import {
usePreferencesQuery,
useSetThreadViewPreferencesMutation,
} from '#/state/queries/preferences'
import {atoms as a, useTheme} from '#/alf'
import * as Toggle from '#/components/forms/Toggle'
import {Beaker_Stroke2_Corner2_Rounded as BeakerIcon} from '#/components/icons/Beaker'
import {Bubbles_Stroke2_Corner2_Rounded as BubblesIcon} from '#/components/icons/Bubble'
import {PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon} from '#/components/icons/Person'
import * as Layout from '#/components/Layout'
import {Text} from '#/components/Typography'
import * as SettingsList from './components/SettingsList'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'PreferencesThreads'>
export function ThreadPreferencesScreen({}: Props) {
const {_} = useLingui()
const t = useTheme()
const {data: preferences} = usePreferencesQuery()
const {mutate: setThreadViewPrefs, variables} =
useSetThreadViewPreferencesMutation()
const sortReplies = variables?.sort ?? preferences?.threadViewPrefs?.sort
const prioritizeFollowedUsers = Boolean(
variables?.prioritizeFollowedUsers ??
preferences?.threadViewPrefs?.prioritizeFollowedUsers,
)
const treeViewEnabled = Boolean(
variables?.lab_treeViewEnabled ??
preferences?.threadViewPrefs?.lab_treeViewEnabled,
)
return (
<Layout.Screen testID="threadPreferencesScreen">
<Layout.Header title={_(msg`Thread Preferences`)} />
<Layout.Content>
<SettingsList.Container>
<SettingsList.Group>
<SettingsList.ItemIcon icon={BubblesIcon} />
<SettingsList.ItemText>Sort replies</SettingsList.ItemText>
<View style={[a.w_full, a.gap_md]}>
<Text style={[a.flex_1, t.atoms.text_contrast_medium]}>
<Trans>Sort replies to the same post by:</Trans>
</Text>
<Toggle.Group
label={_(msg`Sort replies by`)}
type="radio"
values={sortReplies ? [sortReplies] : []}
onChange={values => setThreadViewPrefs({sort: values[0]})}>
<View style={[a.gap_sm, a.flex_1]}>
<Toggle.Item
name="oldest"
label={_(msg`Oldest replies first`)}>
<Toggle.Radio />
<Toggle.LabelText>
<Trans>Oldest replies first</Trans>
</Toggle.LabelText>
</Toggle.Item>
<Toggle.Item
name="newest"
label={_(msg`Newest replies first`)}>
<Toggle.Radio />
<Toggle.LabelText>
<Trans>Newest replies first</Trans>
</Toggle.LabelText>
</Toggle.Item>
<Toggle.Item
name="most-likes"
label={_(msg`Most-liked replies first`)}>
<Toggle.Radio />
<Toggle.LabelText>
<Trans>Most-liked first</Trans>
</Toggle.LabelText>
</Toggle.Item>
<Toggle.Item
name="random"
label={_(msg`Random (aka "Poster's Roulette")`)}>
<Toggle.Radio />
<Toggle.LabelText>
<Trans>Random (aka "Poster's Roulette")</Trans>
</Toggle.LabelText>
</Toggle.Item>
</View>
</Toggle.Group>
</View>
</SettingsList.Group>
<SettingsList.Group>
<SettingsList.ItemIcon icon={PersonGroupIcon} />
<SettingsList.ItemText>
<Trans>Prioritize your Follows</Trans>
</SettingsList.ItemText>
<Toggle.Item
type="checkbox"
name="prioritize-follows"
label={_(msg`Prioritize your Follows`)}
value={prioritizeFollowedUsers}
onChange={value =>
setThreadViewPrefs({
prioritizeFollowedUsers: value,
})
}
style={[a.w_full, a.gap_md]}>
<Toggle.LabelText style={[a.flex_1]}>
<Trans>
Show replies by people you follow before all other replies.
</Trans>
</Toggle.LabelText>
<Toggle.Platform />
</Toggle.Item>
</SettingsList.Group>
<SettingsList.Divider />
<SettingsList.Group>
<SettingsList.ItemIcon icon={BeakerIcon} />
<SettingsList.ItemText>
<Trans>Experimental</Trans>
</SettingsList.ItemText>
<Toggle.Item
type="checkbox"
name="threaded-mode"
label={_(msg`Threaded mode`)}
value={treeViewEnabled}
onChange={value =>
setThreadViewPrefs({
lab_treeViewEnabled: value,
})
}
style={[a.w_full, a.gap_md]}>
<Toggle.LabelText style={[a.flex_1]}>
<Trans>Show replies in a threaded view</Trans>
</Toggle.LabelText>
<Toggle.Platform />
</Toggle.Item>
</SettingsList.Group>
</SettingsList.Container>
</Layout.Content>
</Layout.Screen>
)
}
@@ -0,0 +1,280 @@
import React, {useEffect, useMemo, useState} from 'react'
import {useWindowDimensions, View} from 'react-native'
import Animated, {
FadeIn,
FadeOut,
LayoutAnimationConfig,
LinearTransition,
SlideInRight,
SlideOutLeft,
} from 'react-native-reanimated'
import {ComAtprotoServerCreateAppPassword} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useMutation} from '@tanstack/react-query'
import {isWeb} from '#/platform/detection'
import {useAppPasswordCreateMutation} from '#/state/queries/app-passwords'
import {atoms as a, native, useTheme} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import * as TextInput from '#/components/forms/TextField'
import * as Toggle from '#/components/forms/Toggle'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
import {SquareBehindSquare4_Stroke2_Corner0_Rounded as CopyIcon} from '#/components/icons/SquareBehindSquare4'
import {Text} from '#/components/Typography'
import {CopyButton} from './CopyButton'
export function AddAppPasswordDialog({
control,
passwords,
}: {
control: Dialog.DialogControlProps
passwords: string[]
}) {
const {height} = useWindowDimensions()
return (
<Dialog.Outer control={control} nativeOptions={{minHeight: height}}>
<Dialog.Handle />
<CreateDialogInner passwords={passwords} />
</Dialog.Outer>
)
}
function CreateDialogInner({passwords}: {passwords: string[]}) {
const control = Dialog.useDialogContext()
const t = useTheme()
const {_} = useLingui()
const autogeneratedName = useRandomName()
const [name, setName] = useState('')
const [privileged, setPrivileged] = useState(false)
const {
mutateAsync: actuallyCreateAppPassword,
error: apiError,
data,
} = useAppPasswordCreateMutation()
const regexFailError = useMemo(
() =>
new DisplayableError(
_(
msg`App password names can only contain letters, numbers, spaces, dashes, and underscores`,
),
),
[_],
)
const {
mutate: createAppPassword,
error: validationError,
isPending,
} = useMutation<
ComAtprotoServerCreateAppPassword.AppPassword,
Error | DisplayableError
>({
mutationFn: async () => {
const chosenName = name.trim() || autogeneratedName
if (chosenName.length < 4) {
throw new DisplayableError(
_(msg`App password names must be at least 4 characters long`),
)
}
if (passwords.find(p => p === chosenName)) {
throw new DisplayableError(_(msg`App password name must be unique`))
}
return await actuallyCreateAppPassword({name: chosenName, privileged})
},
})
const [hasBeenCopied, setHasBeenCopied] = useState(false)
useEffect(() => {
if (hasBeenCopied) {
const timeout = setTimeout(() => setHasBeenCopied(false), 100)
return () => clearTimeout(timeout)
}
}, [hasBeenCopied])
const error =
validationError || (!name.match(/^[a-zA-Z0-9-_ ]*$/) && regexFailError)
return (
<Dialog.ScrollableInner label={_(msg`Add app password`)}>
<View style={[native(a.pt_md)]}>
<LayoutAnimationConfig skipEntering skipExiting>
{!data ? (
<Animated.View
style={[a.gap_lg]}
exiting={native(SlideOutLeft)}
key={0}>
<Text style={[a.text_2xl, a.font_bold]}>
<Trans>Add App Password</Trans>
</Text>
<Text style={[a.text_md, a.leading_snug]}>
<Trans>
Please enter a unique name for this app password or use our
randomly generated one.
</Trans>
</Text>
<View>
<TextInput.Root isInvalid={!!error}>
<Dialog.Input
label={_(msg`App Password`)}
placeholder={autogeneratedName}
onChangeText={setName}
returnKeyType="done"
onSubmitEditing={() => createAppPassword()}
blurOnSubmit
autoCorrect={false}
autoComplete="off"
autoCapitalize="none"
autoFocus
/>
</TextInput.Root>
</View>
{error instanceof DisplayableError && (
<Animated.View entering={FadeIn} exiting={FadeOut}>
<Admonition type="error">{error.message}</Admonition>
</Animated.View>
)}
<Animated.View
style={[a.gap_lg]}
layout={native(LinearTransition)}>
<Toggle.Item
name="privileged"
type="checkbox"
label={_(msg`Allow access to your direct messages`)}
value={privileged}
onChange={setPrivileged}
style={[a.flex_1]}>
<Toggle.Checkbox />
<Toggle.LabelText
style={[a.font_normal, a.text_md, a.leading_snug]}>
<Trans>Allow access to your direct messages</Trans>
</Toggle.LabelText>
</Toggle.Item>
<Button
label={_(msg`Next`)}
size="large"
variant="solid"
color="primary"
style={[a.flex_1]}
onPress={() => createAppPassword()}
disabled={isPending}>
<ButtonText>
<Trans>Next</Trans>
</ButtonText>
<ButtonIcon icon={ChevronRight} />
</Button>
{!!apiError ||
(error && !(error instanceof DisplayableError) && (
<Animated.View entering={FadeIn} exiting={FadeOut}>
<Admonition type="error">
<Trans>
Failed to create app password. Please try again.
</Trans>
</Admonition>
</Animated.View>
))}
</Animated.View>
</Animated.View>
) : (
<Animated.View
style={[a.gap_lg]}
entering={isWeb ? FadeIn.delay(200) : SlideInRight}
key={1}>
<Text style={[a.text_2xl, a.font_bold]}>
<Trans>Here is your app password!</Trans>
</Text>
<Text style={[a.text_md, a.leading_snug]}>
<Trans>
Use this to sign into the other app along with your handle.
</Trans>
</Text>
<CopyButton
value={data.password}
label={_(msg`Copy App Password`)}
size="large"
variant="solid"
color="secondary">
<ButtonText>{data.password}</ButtonText>
<ButtonIcon icon={CopyIcon} />
</CopyButton>
<Text
style={[
a.text_md,
a.leading_snug,
t.atoms.text_contrast_medium,
]}>
<Trans>
For security reasons, you won't be able to view this again. If
you lose this app password, you'll need to generate a new one.
</Trans>
</Text>
<Button
label={_(msg`Done`)}
size="large"
variant="outline"
color="primary"
style={[a.flex_1]}
onPress={() => control.close()}>
<ButtonText>
<Trans>Done</Trans>
</ButtonText>
</Button>
</Animated.View>
)}
</LayoutAnimationConfig>
</View>
<Dialog.Close />
</Dialog.ScrollableInner>
)
}
class DisplayableError extends Error {
constructor(message: string) {
super(message)
this.name = 'DisplayableError'
}
}
function useRandomName() {
return useState(
() => shadesOfBlue[Math.floor(Math.random() * shadesOfBlue.length)],
)[0]
}
const shadesOfBlue: string[] = [
'AliceBlue',
'Aqua',
'Aquamarine',
'Azure',
'BabyBlue',
'Blue',
'BlueViolet',
'CadetBlue',
'CornflowerBlue',
'Cyan',
'DarkBlue',
'DarkCyan',
'DarkSlateBlue',
'DeepSkyBlue',
'DodgerBlue',
'ElectricBlue',
'LightBlue',
'LightCyan',
'LightSkyBlue',
'LightSteelBlue',
'MediumAquaMarine',
'MediumBlue',
'MediumSlateBlue',
'MidnightBlue',
'Navy',
'PowderBlue',
'RoyalBlue',
'SkyBlue',
'SlateBlue',
'SteelBlue',
'Teal',
'Turquoise',
]
@@ -0,0 +1,602 @@
import React, {useCallback, useMemo, useState} from 'react'
import {useWindowDimensions, View} from 'react-native'
import Animated, {
FadeIn,
FadeOut,
LayoutAnimationConfig,
LinearTransition,
SlideInLeft,
SlideInRight,
SlideOutLeft,
SlideOutRight,
} from 'react-native-reanimated'
import {ComAtprotoServerDescribeServer} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useMutation, useQueryClient} from '@tanstack/react-query'
import {HITSLOP_10} from '#/lib/constants'
import {cleanError} from '#/lib/strings/errors'
import {createFullHandle, validateHandle} from '#/lib/strings/handles'
import {useFetchDid, useUpdateHandleMutation} from '#/state/queries/handle'
import {RQKEY as RQKEY_PROFILE} from '#/state/queries/profile'
import {useServiceQuery} from '#/state/queries/service'
import {useAgent, useSession} from '#/state/session'
import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
import {atoms as a, native, useBreakpoints, useTheme} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import * as TextField from '#/components/forms/TextField'
import * as ToggleButton from '#/components/forms/ToggleButton'
import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon} from '#/components/icons/Arrow'
import {At_Stroke2_Corner0_Rounded as AtIcon} from '#/components/icons/At'
import {CheckThick_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
import {SquareBehindSquare4_Stroke2_Corner0_Rounded as CopyIcon} from '#/components/icons/SquareBehindSquare4'
import {InlineLinkText} from '#/components/Link'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
import {CopyButton} from './CopyButton'
export function ChangeHandleDialog({
control,
}: {
control: Dialog.DialogControlProps
}) {
const {height} = useWindowDimensions()
return (
<Dialog.Outer control={control} nativeOptions={{minHeight: height}}>
<ChangeHandleDialogInner />
</Dialog.Outer>
)
}
function ChangeHandleDialogInner() {
const control = Dialog.useDialogContext()
const {_} = useLingui()
const agent = useAgent()
const {
data: serviceInfo,
error: serviceInfoError,
refetch,
} = useServiceQuery(agent.serviceUrl.toString())
const [page, setPage] = useState<'provided-handle' | 'own-handle'>(
'provided-handle',
)
const cancelButton = useCallback(
() => (
<Button
label={_(msg`Cancel`)}
onPress={() => control.close()}
size="small"
color="primary"
variant="ghost"
style={[a.rounded_full]}>
<ButtonText style={[a.text_md]}>
<Trans>Cancel</Trans>
</ButtonText>
</Button>
),
[control, _],
)
return (
<Dialog.ScrollableInner
label={_(msg`Change Handle`)}
style={[a.overflow_hidden]}
header={
<Dialog.Header renderLeft={cancelButton}>
<Dialog.HeaderText>
<Trans>Change Handle</Trans>
</Dialog.HeaderText>
</Dialog.Header>
}
contentContainerStyle={[a.pt_0, a.px_0]}>
<View style={[a.flex_1, a.pt_lg, a.px_xl]}>
{serviceInfoError ? (
<ErrorScreen
title={_(msg`Oops!`)}
message={_(msg`There was an issue fetching your service info`)}
details={cleanError(serviceInfoError)}
onPressTryAgain={refetch}
/>
) : serviceInfo ? (
<LayoutAnimationConfig skipEntering skipExiting>
{page === 'provided-handle' ? (
<Animated.View
key={page}
entering={native(SlideInLeft)}
exiting={native(SlideOutLeft)}>
<ProvidedHandlePage
serviceInfo={serviceInfo}
goToOwnHandle={() => setPage('own-handle')}
/>
</Animated.View>
) : (
<Animated.View
key={page}
entering={native(SlideInRight)}
exiting={native(SlideOutRight)}>
<OwnHandlePage
goToServiceHandle={() => setPage('provided-handle')}
/>
</Animated.View>
)}
</LayoutAnimationConfig>
) : (
<View style={[a.flex_1, a.justify_center, a.align_center, a.py_4xl]}>
<Loader size="xl" />
</View>
)}
</View>
</Dialog.ScrollableInner>
)
}
function ProvidedHandlePage({
serviceInfo,
goToOwnHandle,
}: {
serviceInfo: ComAtprotoServerDescribeServer.OutputSchema
goToOwnHandle: () => void
}) {
const {_} = useLingui()
const [subdomain, setSubdomain] = useState('')
const agent = useAgent()
const control = Dialog.useDialogContext()
const {currentAccount} = useSession()
const queryClient = useQueryClient()
const {
mutate: changeHandle,
isPending,
error,
isSuccess,
} = useUpdateHandleMutation({
onSuccess: () => {
if (currentAccount) {
queryClient.invalidateQueries({
queryKey: RQKEY_PROFILE(currentAccount.did),
})
}
agent.resumeSession(agent.session!).then(() => control.close())
},
})
const host = serviceInfo.availableUserDomains[0]
const validation = useMemo(
() => validateHandle(subdomain, host),
[subdomain, host],
)
const isTooLong = subdomain.length > 18
const isInvalid =
isTooLong ||
!validation.handleChars ||
!validation.hyphenStartOrEnd ||
!validation.totalLength
return (
<LayoutAnimationConfig skipEntering>
<View style={[a.flex_1, a.gap_md]}>
{isSuccess && (
<Animated.View entering={FadeIn} exiting={FadeOut}>
<SuccessMessage text={_(msg`Handle changed!`)} />
</Animated.View>
)}
{error && (
<Animated.View entering={FadeIn} exiting={FadeOut}>
<ChangeHandleError error={error} />
</Animated.View>
)}
<Animated.View
layout={native(LinearTransition)}
style={[a.flex_1, a.gap_md]}>
<View>
<TextField.LabelText>
<Trans>New handle</Trans>
</TextField.LabelText>
<TextField.Root isInvalid={isInvalid}>
<TextField.Icon icon={AtIcon} />
<Dialog.Input
editable={!isPending}
defaultValue={subdomain}
onChangeText={text => setSubdomain(text)}
label={_(msg`New handle`)}
placeholder={_(msg`e.g. alice`)}
autoCapitalize="none"
autoCorrect={false}
/>
<TextField.SuffixText label={host} style={[{maxWidth: '40%'}]}>
{host}
</TextField.SuffixText>
</TextField.Root>
</View>
<Text>
<Trans>
Your full handle will be{' '}
<Text style={[a.font_bold]}>
@{createFullHandle(subdomain, host)}
</Text>
</Trans>
</Text>
<Button
label={_(msg`Save new handle`)}
variant="solid"
size="large"
color={validation.overall && !isTooLong ? 'primary' : 'secondary'}
disabled={!validation.overall && !isTooLong}
onPress={() => {
if (validation.overall && !isTooLong) {
changeHandle({handle: createFullHandle(subdomain, host)})
}
}}>
{isPending ? (
<ButtonIcon icon={Loader} />
) : (
<ButtonText>
<Trans>Save</Trans>
</ButtonText>
)}
</Button>
<Text style={[a.leading_snug]}>
<Trans>
If you have your own domain, you can use that as your handle. This
lets you self-verify your identity {' '}
<InlineLinkText
label={_(msg`learn more`)}
to="https://bsky.social/about/blog/4-28-2023-domain-handle-tutorial"
style={[a.font_bold]}
disableMismatchWarning>
learn more
</InlineLinkText>
.
</Trans>
</Text>
<Button
label={_(msg`I have my own domain`)}
variant="outline"
color="primary"
size="large"
onPress={goToOwnHandle}>
<ButtonText>
<Trans>I have my own domain</Trans>
</ButtonText>
<ButtonIcon icon={ArrowRightIcon} />
</Button>
</Animated.View>
</View>
</LayoutAnimationConfig>
)
}
function OwnHandlePage({goToServiceHandle}: {goToServiceHandle: () => void}) {
const {_} = useLingui()
const t = useTheme()
const {currentAccount} = useSession()
const [dnsPanel, setDNSPanel] = useState(true)
const [domain, setDomain] = useState('')
const agent = useAgent()
const control = Dialog.useDialogContext()
const fetchDid = useFetchDid()
const queryClient = useQueryClient()
const {
mutate: changeHandle,
isPending,
error,
isSuccess,
} = useUpdateHandleMutation({
onSuccess: () => {
if (currentAccount) {
queryClient.invalidateQueries({
queryKey: RQKEY_PROFILE(currentAccount.did),
})
}
agent.resumeSession(agent.session!).then(() => control.close())
},
})
const {
mutate: verify,
isPending: isVerifyPending,
isSuccess: isVerified,
error: verifyError,
reset: resetVerification,
} = useMutation<true, Error | DidMismatchError>({
mutationKey: ['verify-handle', domain],
mutationFn: async () => {
const did = await fetchDid(domain)
if (did !== currentAccount?.did) {
throw new DidMismatchError(did)
}
return true
},
})
return (
<View style={[a.flex_1, a.gap_lg]}>
{isSuccess && (
<Animated.View entering={FadeIn} exiting={FadeOut}>
<SuccessMessage text={_(msg`Handle changed!`)} />
</Animated.View>
)}
{error && (
<Animated.View entering={FadeIn} exiting={FadeOut}>
<ChangeHandleError error={error} />
</Animated.View>
)}
{verifyError && (
<Animated.View entering={FadeIn} exiting={FadeOut}>
<Admonition type="error">
{verifyError instanceof DidMismatchError ? (
<Trans>
Wrong DID returned from server. Received: {verifyError.did}
</Trans>
) : (
<Trans>Failed to verify handle. Please try again.</Trans>
)}
</Admonition>
</Animated.View>
)}
<Animated.View
layout={native(LinearTransition)}
style={[a.flex_1, a.gap_md, a.overflow_hidden]}>
<View>
<TextField.LabelText>
<Trans>Enter the domain you want to use</Trans>
</TextField.LabelText>
<TextField.Root>
<TextField.Icon icon={AtIcon} />
<Dialog.Input
label={_(msg`New handle`)}
placeholder={_(msg`e.g. alice.com`)}
editable={!isPending}
defaultValue={domain}
onChangeText={text => {
setDomain(text)
resetVerification()
}}
autoCapitalize="none"
autoCorrect={false}
/>
</TextField.Root>
</View>
<ToggleButton.Group
label={_(msg`Choose domain verification method`)}
values={[dnsPanel ? 'dns' : 'file']}
onChange={values => setDNSPanel(values[0] === 'dns')}>
<ToggleButton.Button name="dns" label={_(msg`DNS Panel`)}>
<ToggleButton.ButtonText>
<Trans>DNS Panel</Trans>
</ToggleButton.ButtonText>
</ToggleButton.Button>
<ToggleButton.Button name="file" label={_(msg`No DNS Panel`)}>
<ToggleButton.ButtonText>
<Trans>No DNS Panel</Trans>
</ToggleButton.ButtonText>
</ToggleButton.Button>
</ToggleButton.Group>
{dnsPanel ? (
<>
<Text>
<Trans>Add the following DNS record to your domain:</Trans>
</Text>
<View
style={[
t.atoms.bg_contrast_25,
a.rounded_sm,
a.p_md,
a.border,
t.atoms.border_contrast_low,
]}>
<Text style={[t.atoms.text_contrast_medium]}>
<Trans>Host:</Trans>
</Text>
<View style={[a.py_xs]}>
<CopyButton
variant="solid"
color="secondary"
value="_atproto"
label={_(msg`Copy host`)}
hoverStyle={[a.bg_transparent]}
hitSlop={HITSLOP_10}>
<Text style={[a.text_md, a.flex_1]}>_atproto</Text>
<ButtonIcon icon={CopyIcon} />
</CopyButton>
</View>
<Text style={[a.mt_xs, t.atoms.text_contrast_medium]}>
<Trans>Type:</Trans>
</Text>
<View style={[a.py_xs]}>
<Text style={[a.text_md]}>TXT</Text>
</View>
<Text style={[a.mt_xs, t.atoms.text_contrast_medium]}>
<Trans>Value:</Trans>
</Text>
<View style={[a.py_xs]}>
<CopyButton
variant="solid"
color="secondary"
value={'did=' + currentAccount?.did}
label={_(msg`Copy TXT record value`)}
hoverStyle={[a.bg_transparent]}
hitSlop={HITSLOP_10}>
<Text style={[a.text_md, a.flex_1]}>
did={currentAccount?.did}
</Text>
<ButtonIcon icon={CopyIcon} />
</CopyButton>
</View>
</View>
<Text>
<Trans>This should create a domain record at:</Trans>
</Text>
<View
style={[
t.atoms.bg_contrast_25,
a.rounded_sm,
a.p_md,
a.border,
t.atoms.border_contrast_low,
]}>
<Text style={[a.text_md]}>_atproto.{domain}</Text>
</View>
</>
) : (
<>
<Text>
<Trans>Upload a text file to:</Trans>
</Text>
<View
style={[
t.atoms.bg_contrast_25,
a.rounded_sm,
a.p_md,
a.border,
t.atoms.border_contrast_low,
]}>
<Text style={[a.text_md]}>
https://{domain}/.well-known/atproto-did
</Text>
</View>
<Text>
<Trans>That contains the following:</Trans>
</Text>
<CopyButton
value={currentAccount?.did ?? ''}
label={_(msg`Copy DID`)}
size="large"
variant="solid"
color="secondary"
style={[a.px_md, a.border, t.atoms.border_contrast_low]}>
<Text style={[a.text_md, a.flex_1]}>{currentAccount?.did}</Text>
<ButtonIcon icon={CopyIcon} />
</CopyButton>
</>
)}
</Animated.View>
{isVerified && (
<Animated.View
entering={FadeIn}
exiting={FadeOut}
layout={native(LinearTransition)}>
<SuccessMessage text={_(msg`Domain verified!`)} />
</Animated.View>
)}
<Animated.View layout={native(LinearTransition)}>
<Button
label={
isVerified
? _(msg`Update to ${domain}`)
: dnsPanel
? _(msg`Verify DNS Record`)
: _(msg`Verify Text File`)
}
variant="solid"
size="large"
color={domain.trim().length > 0 ? 'primary' : 'secondary'}
disabled={domain.trim().length === 0}
onPress={() => {
if (isVerified) {
changeHandle({handle: domain})
} else {
verify()
}
}}>
{isPending || isVerifyPending ? (
<ButtonIcon icon={Loader} />
) : (
<ButtonText>
{isVerified ? (
<Trans>Update to {domain}</Trans>
) : dnsPanel ? (
<Trans>Verify DNS Record</Trans>
) : (
<Trans>Verify Text File</Trans>
)}
</ButtonText>
)}
</Button>
</Animated.View>
<Animated.View layout={native(LinearTransition)}>
<Button
label={_(msg`Use default provider`)}
accessibilityHint={_(msg`Go back to previous page`)}
onPress={goToServiceHandle}
style={[a.p_0, a.justify_start]}>
<ButtonText style={[{color: t.palette.primary_500}, a.text_left]}>
<Trans>Nevermind, create a handle for me</Trans>
</ButtonText>
</Button>
</Animated.View>
</View>
)
}
class DidMismatchError extends Error {
did: string
constructor(did: string) {
super('DID mismatch')
this.name = 'DidMismatchError'
this.did = did
}
}
function ChangeHandleError({error}: {error: unknown}) {
const {_} = useLingui()
let message = _(msg`Failed to change handle. Please try again.`)
if (error instanceof Error) {
if (error.message.startsWith('Handle already taken')) {
message = _(msg`Handle already taken. Please try a different one.`)
} else if (error.message === 'Reserved handle') {
message = _(msg`This handle is reserved. Please try a different one.`)
} else if (error.message === 'Handle too long') {
message = _(msg`Handle too long. Please try a shorter one.`)
} else if (error.message === 'Input/handle must be a valid handle') {
message = _(msg`Invalid handle. Please try a different one.`)
} else if (error.message === 'Rate Limit Exceeded') {
message = _(
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.`,
)
}
}
return <Admonition type="error">{message}</Admonition>
}
function SuccessMessage({text}: {text: string}) {
const {gtMobile} = useBreakpoints()
const t = useTheme()
return (
<View
style={[
a.flex_1,
a.gap_md,
a.flex_row,
a.justify_center,
a.align_center,
gtMobile ? a.px_md : a.px_sm,
a.py_xs,
t.atoms.border_contrast_low,
]}>
<View
style={[
{height: 20, width: 20},
a.rounded_full,
a.align_center,
a.justify_center,
{backgroundColor: t.palette.positive_600},
]}>
<CheckIcon fill={t.palette.white} size="xs" />
</View>
<Text style={[a.text_md]}>{text}</Text>
</View>
)
}
@@ -0,0 +1,69 @@
import React, {useCallback, useEffect, useState} from 'react'
import {GestureResponderEvent, View} from 'react-native'
import Animated, {FadeOutUp, ZoomIn} from 'react-native-reanimated'
import * as Clipboard from 'expo-clipboard'
import {Trans} from '@lingui/macro'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonProps} from '#/components/Button'
import {Text} from '#/components/Typography'
export function CopyButton({
style,
value,
onPress: onPressProp,
...props
}: ButtonProps & {value: string}) {
const [hasBeenCopied, setHasBeenCopied] = useState(false)
const t = useTheme()
useEffect(() => {
if (hasBeenCopied) {
const timeout = setTimeout(() => setHasBeenCopied(false), 100)
return () => clearTimeout(timeout)
}
}, [hasBeenCopied])
const onPress = useCallback(
(evt: GestureResponderEvent) => {
Clipboard.setStringAsync(value)
setHasBeenCopied(true)
onPressProp?.(evt)
},
[value, onPressProp],
)
return (
<View style={[a.relative]}>
{hasBeenCopied && (
<Animated.View
entering={ZoomIn.duration(100)}
exiting={FadeOutUp.duration(2000)}
style={[
a.absolute,
{bottom: '100%', right: 0},
a.justify_center,
a.gap_sm,
a.z_10,
a.pb_sm,
]}
pointerEvents="none">
<Text
style={[
a.font_bold,
a.text_right,
a.text_md,
t.atoms.text_contrast_high,
]}>
<Trans>Copied!</Trans>
</Text>
</Animated.View>
)}
<Button
style={[a.flex_1, a.justify_between, style]}
onPress={onPress}
{...props}
/>
</View>
)
}
@@ -2,7 +2,7 @@ import React, {useContext, useMemo} from 'react'
import {GestureResponderEvent, StyleProp, View, ViewStyle} from 'react-native'
import {HITSLOP_10} from '#/lib/constants'
import {atoms as a, useTheme} from '#/alf'
import {atoms as a, useTheme, ViewStyleProp} from '#/alf'
import * as Button from '#/components/Button'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon} from '#/components/icons/Chevron'
import {Link, LinkProps} from '#/components/Link'
@@ -17,7 +17,7 @@ const ItemContext = React.createContext({
const Portal = createPortalGroup()
export function Container({children}: {children: React.ReactNode}) {
return <View style={[a.flex_1, a.py_lg]}>{children}</View>
return <View style={[a.flex_1, a.py_md]}>{children}</View>
}
/**
@@ -241,11 +241,17 @@ export function ItemText({
}
}
export function Divider() {
export function Divider({style}: ViewStyleProp) {
const t = useTheme()
return (
<View
style={[a.border_t, t.atoms.border_contrast_medium, a.w_full, a.my_sm]}
style={[
a.border_t,
t.atoms.border_contrast_medium,
a.w_full,
a.my_sm,
style,
]}
/>
)
}
+4 -1
View File
@@ -32,7 +32,9 @@ export function useFetchHandle() {
)
}
export function useUpdateHandleMutation() {
export function useUpdateHandleMutation(opts?: {
onSuccess?: (handle: string) => void
}) {
const queryClient = useQueryClient()
const agent = useAgent()
@@ -41,6 +43,7 @@ export function useUpdateHandleMutation() {
await agent.updateHandle({handle})
},
onSuccess(_data, variables) {
opts?.onSuccess?.(variables.handle)
queryClient.invalidateQueries({
queryKey: fetchHandleQueryKey(variables.handle),
})
+1 -1
View File
@@ -9,7 +9,7 @@ import {invalidateCachedUnreadPage} from '#/state/queries/notifications/unread'
import {useAgent} from '#/state/session'
import * as Toast from '#/view/com/util/Toast'
export function useNotificationsSettingsMutation() {
export function useNotificationSettingsMutation() {
const {_} = useLingui()
const agent = useAgent()
const queryClient = useQueryClient()
+6 -2
View File
@@ -12,6 +12,7 @@ import {useLingui} from '@lingui/react'
import {useFocusEffect} from '@react-navigation/native'
import {NativeStackScreenProps} from '@react-navigation/native-stack'
import {IS_INTERNAL} from '#/lib/app-info'
import {usePalette} from '#/lib/hooks/usePalette'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {CommonNavigatorParams} from '#/lib/routes/types'
@@ -28,14 +29,17 @@ import {Text} from '#/view/com/util/text/Text'
import * as Toast from '#/view/com/util/Toast'
import {ViewHeader} from '#/view/com/util/ViewHeader'
import {CenteredView} from '#/view/com/util/Views'
import {AppPasswordsScreen as NewAppPasswordsScreen} from '#/screens/Settings/AppPasswords'
import {atoms as a} from '#/alf'
import {useDialogControl} from '#/components/Dialog'
import * as Layout from '#/components/Layout'
import * as Prompt from '#/components/Prompt'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'AppPasswords'>
export function AppPasswords({}: Props) {
return (
export function AppPasswords(props: Props) {
return IS_INTERNAL ? (
<NewAppPasswordsScreen {...props} />
) : (
<Layout.Screen testID="AppPasswordsScreen">
<AppPasswordsInner />
</Layout.Screen>
+11 -1
View File
@@ -10,6 +10,7 @@ import {useLingui} from '@lingui/react'
import {useFocusEffect} from '@react-navigation/native'
import {APP_LANGUAGES, LANGUAGES} from '#/lib/../locale/languages'
import {IS_INTERNAL} from '#/lib/app-info'
import {usePalette} from '#/lib/hooks/usePalette'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
@@ -22,11 +23,20 @@ import {Button} from '#/view/com/util/forms/Button'
import {Text} from '#/view/com/util/text/Text'
import {ViewHeader} from '#/view/com/util/ViewHeader'
import {CenteredView} from '#/view/com/util/Views'
import {LanguageSettingsScreen as NewLanguageSettingsScreen} from '#/screens/Settings/LanguageSettings'
import * as Layout from '#/components/Layout'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'LanguageSettings'>
export function LanguageSettingsScreen(_props: Props) {
export function LanguageSettingsScreen(props: Props) {
return IS_INTERNAL ? (
<NewLanguageSettingsScreen {...props} />
) : (
<LegacyLanguageSettingsScreen {...props} />
)
}
function LegacyLanguageSettingsScreen(_props: Props) {
const pal = usePalette('default')
const {_} = useLingui()
const langPrefs = useLanguagePrefs()
@@ -1,89 +0,0 @@
import React from 'react'
import {View} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {AllNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
import {useNotificationFeedQuery} from '#/state/queries/notifications/feed'
import {useNotificationsSettingsMutation} from '#/state/queries/notifications/settings'
import {ViewHeader} from '#/view/com/util/ViewHeader'
import {ScrollView} from '#/view/com/util/Views'
import {atoms as a, useTheme} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {Error} from '#/components/Error'
import * as Toggle from '#/components/forms/Toggle'
import * as Layout from '#/components/Layout'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
type Props = NativeStackScreenProps<AllNavigatorParams, 'NotificationsSettings'>
export function NotificationsSettingsScreen({}: Props) {
const {_} = useLingui()
const t = useTheme()
const {data, isError: isQueryError, refetch} = useNotificationFeedQuery()
const serverPriority = data?.pages.at(0)?.priority
const {
mutate: onChangePriority,
isPending: isMutationPending,
variables,
} = useNotificationsSettingsMutation()
const priority = isMutationPending
? variables[0] === 'enabled'
: serverPriority
return (
<Layout.Screen>
<ScrollView stickyHeaderIndices={[0]}>
<ViewHeader
title={_(msg`Notification Settings`)}
showOnDesktop
showBorder
/>
{isQueryError ? (
<Error
title={_(msg`Oops!`)}
message={_(msg`Something went wrong!`)}
onRetry={refetch}
sideBorders={false}
/>
) : (
<View style={[a.p_lg, a.gap_md]}>
<Text style={[a.text_lg, a.font_bold]}>
<FontAwesomeIcon icon="flask" style={t.atoms.text} />{' '}
<Trans>Notification filters</Trans>
</Text>
<Toggle.Group
label={_(msg`Priority notifications`)}
type="checkbox"
values={priority ? ['enabled'] : []}
onChange={onChangePriority}
disabled={typeof priority !== 'boolean' || isMutationPending}>
<View>
<Toggle.Item
name="enabled"
label={_(msg`Enable priority notifications`)}
style={[a.justify_between, a.py_sm]}>
<Toggle.LabelText>
<Trans>Enable priority notifications</Trans>
</Toggle.LabelText>
{!data ? <Loader size="md" /> : <Toggle.Platform />}
</Toggle.Item>
</View>
</Toggle.Group>
<Admonition type="warning" style={[a.mt_sm]}>
<Trans>
Experimental: When this preference is enabled, you'll only
receive reply and quote notifications from users you follow.
We'll continue to add more controls here over time.
</Trans>
</Admonition>
</View>
)}
</ScrollView>
</Layout.Screen>
)
}
+11 -1
View File
@@ -3,6 +3,7 @@ import {StyleSheet, View} from 'react-native'
import {Trans} from '@lingui/macro'
import {useFocusEffect} from '@react-navigation/native'
import {IS_INTERNAL} from '#/lib/app-info'
import {usePalette} from '#/lib/hooks/usePalette'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
@@ -19,6 +20,7 @@ import {ToggleButton} from '#/view/com/util/forms/ToggleButton'
import {SimpleViewHeader} from '#/view/com/util/SimpleViewHeader'
import {Text} from '#/view/com/util/text/Text'
import {ScrollView} from '#/view/com/util/Views'
import {ExternalMediaPreferencesScreen} from '#/screens/Settings/ExternalMediaPreferences'
import {atoms as a} from '#/alf'
import * as Layout from '#/components/Layout'
@@ -26,7 +28,15 @@ type Props = NativeStackScreenProps<
CommonNavigatorParams,
'PreferencesExternalEmbeds'
>
export function PreferencesExternalEmbeds({}: Props) {
export function PreferencesExternalEmbeds(props: Props) {
return IS_INTERNAL ? (
<ExternalMediaPreferencesScreen {...props} />
) : (
<LegacyPreferencesExternalEmbeds {...props} />
)
}
function LegacyPreferencesExternalEmbeds({}: Props) {
const pal = usePalette('default')
const setMinimalShellMode = useSetMinimalShellMode()
const {isTabletOrMobile} = useWebMediaQueries()
+11 -1
View File
@@ -4,6 +4,7 @@ import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {IS_INTERNAL} from '#/lib/app-info'
import {usePalette} from '#/lib/hooks/usePalette'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
@@ -16,6 +17,7 @@ import {ToggleButton} from '#/view/com/util/forms/ToggleButton'
import {SimpleViewHeader} from '#/view/com/util/SimpleViewHeader'
import {Text} from '#/view/com/util/text/Text'
import {ScrollView} from '#/view/com/util/Views'
import {FollowingFeedPreferencesScreen} from '#/screens/Settings/FollowingFeedPreferences'
import {atoms as a} from '#/alf'
import * as Layout from '#/components/Layout'
@@ -23,7 +25,15 @@ type Props = NativeStackScreenProps<
CommonNavigatorParams,
'PreferencesFollowingFeed'
>
export function PreferencesFollowingFeed({}: Props) {
export function PreferencesFollowingFeed(props: Props) {
return IS_INTERNAL ? (
<FollowingFeedPreferencesScreen {...props} />
) : (
<LegacyPreferencesFollowingFeed {...props} />
)
}
function LegacyPreferencesFollowingFeed({}: Props) {
const pal = usePalette('default')
const {_} = useLingui()
const {isTabletOrMobile} = useWebMediaQueries()
+11 -1
View File
@@ -4,6 +4,7 @@ import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {IS_INTERNAL} from '#/lib/app-info'
import {usePalette} from '#/lib/hooks/usePalette'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
@@ -17,11 +18,20 @@ import {ToggleButton} from '#/view/com/util/forms/ToggleButton'
import {SimpleViewHeader} from '#/view/com/util/SimpleViewHeader'
import {Text} from '#/view/com/util/text/Text'
import {ScrollView} from '#/view/com/util/Views'
import {ThreadPreferencesScreen} from '#/screens/Settings/ThreadPreferences'
import {atoms as a} from '#/alf'
import * as Layout from '#/components/Layout'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'PreferencesThreads'>
export function PreferencesThreads({}: Props) {
export function PreferencesThreads(props: Props) {
return IS_INTERNAL ? (
<ThreadPreferencesScreen {...props} />
) : (
<LegacyPreferencesThreads {...props} />
)
}
function LegacyPreferencesThreads({}: Props) {
const pal = usePalette('default')
const {_} = useLingui()
const {isTabletOrMobile} = useWebMediaQueries()