[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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -326,6 +326,7 @@ export function SuffixText({
|
||||
<Text
|
||||
accessibilityLabel={label}
|
||||
accessibilityHint={accessibilityHint}
|
||||
numberOfLines={1}
|
||||
style={[
|
||||
a.z_20,
|
||||
a.pr_sm,
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user