Merge branch 'main' into ja-translation-14
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="M12.097 2.53a1 1 0 0 1-.041 1.07 6 6 0 0 0 8.345 8.344 1 1 0 0 1 1.563.908c-.434 5.122-4.728 9.144-9.962 9.144-5.522 0-9.998-4.476-9.998-9.998 0-5.234 4.021-9.528 9.144-9.962a1 1 0 0 1 .949.494ZM9.424 4.424a7.998 7.998 0 1 0 10.152 10.152A8 8 0 0 1 9.424 4.424Z" clip-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 403 B |
@@ -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="M5 4a3 3 0 0 1 3-3h8a3 3 0 0 1 3 3v16a3 3 0 0 1-3 3H8a3 3 0 0 1-3-3V4Zm3-1a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1H8Zm2 2a1 1 0 0 1 1-1h2a1 1 0 1 1 0 2h-2a1 1 0 0 1-1-1Z" clip-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 334 B |
@@ -211,6 +211,7 @@ func serve(cctx *cli.Context) error {
|
|||||||
e.GET("/settings/threads", server.WebGeneric)
|
e.GET("/settings/threads", server.WebGeneric)
|
||||||
e.GET("/settings/external-embeds", server.WebGeneric)
|
e.GET("/settings/external-embeds", server.WebGeneric)
|
||||||
e.GET("/settings/accessibility", server.WebGeneric)
|
e.GET("/settings/accessibility", server.WebGeneric)
|
||||||
|
e.GET("/settings/appearance", server.WebGeneric)
|
||||||
e.GET("/sys/debug", server.WebGeneric)
|
e.GET("/sys/debug", server.WebGeneric)
|
||||||
e.GET("/sys/debug-mod", server.WebGeneric)
|
e.GET("/sys/debug-mod", server.WebGeneric)
|
||||||
e.GET("/sys/log", server.WebGeneric)
|
e.GET("/sys/log", server.WebGeneric)
|
||||||
|
|||||||
+1
-1
@@ -52,7 +52,7 @@
|
|||||||
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
|
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@atproto/api": "0.12.25",
|
"@atproto/api": "^0.12.26",
|
||||||
"@bam.tech/react-native-image-resizer": "^3.0.4",
|
"@bam.tech/react-native-image-resizer": "^3.0.4",
|
||||||
"@braintree/sanitize-url": "^6.0.2",
|
"@braintree/sanitize-url": "^6.0.2",
|
||||||
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import HashtagScreen from '#/screens/Hashtag'
|
|||||||
import {ModerationScreen} from '#/screens/Moderation'
|
import {ModerationScreen} from '#/screens/Moderation'
|
||||||
import {ProfileKnownFollowersScreen} from '#/screens/Profile/KnownFollowers'
|
import {ProfileKnownFollowersScreen} from '#/screens/Profile/KnownFollowers'
|
||||||
import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy'
|
import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy'
|
||||||
|
import {AppearanceSettingsScreen} from '#/screens/Settings/AppearanceSettings'
|
||||||
import {
|
import {
|
||||||
StarterPackScreen,
|
StarterPackScreen,
|
||||||
StarterPackScreenShort,
|
StarterPackScreenShort,
|
||||||
@@ -310,6 +311,14 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
|
|||||||
requireAuth: true,
|
requireAuth: true,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="AppearanceSettings"
|
||||||
|
getComponent={() => AppearanceSettingsScreen}
|
||||||
|
options={{
|
||||||
|
title: title(msg`Appearance Settings`),
|
||||||
|
requireAuth: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="Hashtag"
|
name="Hashtag"
|
||||||
getComponent={() => HashtagScreen}
|
getComponent={() => HashtagScreen}
|
||||||
|
|||||||
@@ -1,27 +1,27 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import {useNavigation} from '@react-navigation/native'
|
|
||||||
import {useLingui} from '@lingui/react'
|
|
||||||
import {msg, Trans} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
import {useNavigation} from '@react-navigation/native'
|
||||||
|
|
||||||
import {atoms as a, native, useTheme} from '#/alf'
|
|
||||||
import * as Dialog from '#/components/Dialog'
|
|
||||||
import {Text} from '#/components/Typography'
|
|
||||||
import {Button, ButtonText} from '#/components/Button'
|
|
||||||
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
|
|
||||||
import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person'
|
|
||||||
import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
|
|
||||||
import {Divider} from '#/components/Divider'
|
|
||||||
import {Link} from '#/components/Link'
|
|
||||||
import {makeSearchLink} from '#/lib/routes/links'
|
import {makeSearchLink} from '#/lib/routes/links'
|
||||||
import {NavigationProp} from '#/lib/routes/types'
|
import {NavigationProp} from '#/lib/routes/types'
|
||||||
|
import {isInvalidHandle} from '#/lib/strings/handles'
|
||||||
import {
|
import {
|
||||||
usePreferencesQuery,
|
usePreferencesQuery,
|
||||||
|
useRemoveMutedWordsMutation,
|
||||||
useUpsertMutedWordsMutation,
|
useUpsertMutedWordsMutation,
|
||||||
useRemoveMutedWordMutation,
|
|
||||||
} from '#/state/queries/preferences'
|
} from '#/state/queries/preferences'
|
||||||
|
import {atoms as a, native, useTheme} from '#/alf'
|
||||||
|
import {Button, ButtonText} from '#/components/Button'
|
||||||
|
import * as Dialog from '#/components/Dialog'
|
||||||
|
import {Divider} from '#/components/Divider'
|
||||||
|
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
|
||||||
|
import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
|
||||||
|
import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person'
|
||||||
|
import {Link} from '#/components/Link'
|
||||||
import {Loader} from '#/components/Loader'
|
import {Loader} from '#/components/Loader'
|
||||||
import {isInvalidHandle} from '#/lib/strings/handles'
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
export function useTagMenuControl() {
|
export function useTagMenuControl() {
|
||||||
return Dialog.useDialogControl()
|
return Dialog.useDialogControl()
|
||||||
@@ -52,10 +52,10 @@ export function TagMenu({
|
|||||||
reset: resetUpsert,
|
reset: resetUpsert,
|
||||||
} = useUpsertMutedWordsMutation()
|
} = useUpsertMutedWordsMutation()
|
||||||
const {
|
const {
|
||||||
mutateAsync: removeMutedWord,
|
mutateAsync: removeMutedWords,
|
||||||
variables: optimisticRemove,
|
variables: optimisticRemove,
|
||||||
reset: resetRemove,
|
reset: resetRemove,
|
||||||
} = useRemoveMutedWordMutation()
|
} = useRemoveMutedWordsMutation()
|
||||||
const displayTag = '#' + tag
|
const displayTag = '#' + tag
|
||||||
|
|
||||||
const isMuted = Boolean(
|
const isMuted = Boolean(
|
||||||
@@ -65,9 +65,20 @@ export function TagMenu({
|
|||||||
optimisticUpsert?.find(
|
optimisticUpsert?.find(
|
||||||
m => m.value === tag && m.targets.includes('tag'),
|
m => m.value === tag && m.targets.includes('tag'),
|
||||||
)) &&
|
)) &&
|
||||||
!(optimisticRemove?.value === tag),
|
!optimisticRemove?.find(m => m?.value === tag),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Mute word records that exactly match the tag in question.
|
||||||
|
*/
|
||||||
|
const removeableMuteWords = React.useMemo(() => {
|
||||||
|
return (
|
||||||
|
preferences?.moderationPrefs.mutedWords?.filter(word => {
|
||||||
|
return word.value === tag
|
||||||
|
}) || []
|
||||||
|
)
|
||||||
|
}, [tag, preferences?.moderationPrefs?.mutedWords])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{children}
|
{children}
|
||||||
@@ -212,13 +223,16 @@ export function TagMenu({
|
|||||||
control.close(() => {
|
control.close(() => {
|
||||||
if (isMuted) {
|
if (isMuted) {
|
||||||
resetUpsert()
|
resetUpsert()
|
||||||
removeMutedWord({
|
removeMutedWords(removeableMuteWords)
|
||||||
value: tag,
|
|
||||||
targets: ['tag'],
|
|
||||||
})
|
|
||||||
} else {
|
} else {
|
||||||
resetRemove()
|
resetRemove()
|
||||||
upsertMutedWord([{value: tag, targets: ['tag']}])
|
upsertMutedWord([
|
||||||
|
{
|
||||||
|
value: tag,
|
||||||
|
targets: ['tag'],
|
||||||
|
actorTarget: 'all',
|
||||||
|
},
|
||||||
|
])
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}}>
|
}}>
|
||||||
|
|||||||
@@ -3,16 +3,16 @@ import {msg} from '@lingui/macro'
|
|||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {useNavigation} from '@react-navigation/native'
|
import {useNavigation} from '@react-navigation/native'
|
||||||
|
|
||||||
import {isInvalidHandle} from '#/lib/strings/handles'
|
|
||||||
import {EventStopper} from '#/view/com/util/EventStopper'
|
|
||||||
import {NativeDropdown} from '#/view/com/util/forms/NativeDropdown'
|
|
||||||
import {NavigationProp} from '#/lib/routes/types'
|
import {NavigationProp} from '#/lib/routes/types'
|
||||||
|
import {isInvalidHandle} from '#/lib/strings/handles'
|
||||||
|
import {enforceLen} from '#/lib/strings/helpers'
|
||||||
import {
|
import {
|
||||||
usePreferencesQuery,
|
usePreferencesQuery,
|
||||||
|
useRemoveMutedWordsMutation,
|
||||||
useUpsertMutedWordsMutation,
|
useUpsertMutedWordsMutation,
|
||||||
useRemoveMutedWordMutation,
|
|
||||||
} from '#/state/queries/preferences'
|
} from '#/state/queries/preferences'
|
||||||
import {enforceLen} from '#/lib/strings/helpers'
|
import {EventStopper} from '#/view/com/util/EventStopper'
|
||||||
|
import {NativeDropdown} from '#/view/com/util/forms/NativeDropdown'
|
||||||
import {web} from '#/alf'
|
import {web} from '#/alf'
|
||||||
import * as Dialog from '#/components/Dialog'
|
import * as Dialog from '#/components/Dialog'
|
||||||
|
|
||||||
@@ -47,8 +47,8 @@ export function TagMenu({
|
|||||||
const {data: preferences} = usePreferencesQuery()
|
const {data: preferences} = usePreferencesQuery()
|
||||||
const {mutateAsync: upsertMutedWord, variables: optimisticUpsert} =
|
const {mutateAsync: upsertMutedWord, variables: optimisticUpsert} =
|
||||||
useUpsertMutedWordsMutation()
|
useUpsertMutedWordsMutation()
|
||||||
const {mutateAsync: removeMutedWord, variables: optimisticRemove} =
|
const {mutateAsync: removeMutedWords, variables: optimisticRemove} =
|
||||||
useRemoveMutedWordMutation()
|
useRemoveMutedWordsMutation()
|
||||||
const isMuted = Boolean(
|
const isMuted = Boolean(
|
||||||
(preferences?.moderationPrefs.mutedWords?.find(
|
(preferences?.moderationPrefs.mutedWords?.find(
|
||||||
m => m.value === tag && m.targets.includes('tag'),
|
m => m.value === tag && m.targets.includes('tag'),
|
||||||
@@ -56,10 +56,21 @@ export function TagMenu({
|
|||||||
optimisticUpsert?.find(
|
optimisticUpsert?.find(
|
||||||
m => m.value === tag && m.targets.includes('tag'),
|
m => m.value === tag && m.targets.includes('tag'),
|
||||||
)) &&
|
)) &&
|
||||||
!(optimisticRemove?.value === tag),
|
!optimisticRemove?.find(m => m?.value === tag),
|
||||||
)
|
)
|
||||||
const truncatedTag = '#' + enforceLen(tag, 15, true, 'middle')
|
const truncatedTag = '#' + enforceLen(tag, 15, true, 'middle')
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Mute word records that exactly match the tag in question.
|
||||||
|
*/
|
||||||
|
const removeableMuteWords = React.useMemo(() => {
|
||||||
|
return (
|
||||||
|
preferences?.moderationPrefs.mutedWords?.filter(word => {
|
||||||
|
return word.value === tag
|
||||||
|
}) || []
|
||||||
|
)
|
||||||
|
}, [tag, preferences?.moderationPrefs?.mutedWords])
|
||||||
|
|
||||||
const dropdownItems = React.useMemo(() => {
|
const dropdownItems = React.useMemo(() => {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -105,9 +116,11 @@ export function TagMenu({
|
|||||||
: _(msg`Mute ${truncatedTag}`),
|
: _(msg`Mute ${truncatedTag}`),
|
||||||
onPress() {
|
onPress() {
|
||||||
if (isMuted) {
|
if (isMuted) {
|
||||||
removeMutedWord({value: tag, targets: ['tag']})
|
removeMutedWords(removeableMuteWords)
|
||||||
} else {
|
} else {
|
||||||
upsertMutedWord([{value: tag, targets: ['tag']}])
|
upsertMutedWord([
|
||||||
|
{value: tag, targets: ['tag'], actorTarget: 'all'},
|
||||||
|
])
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
testID: 'tagMenuMute',
|
testID: 'tagMenuMute',
|
||||||
@@ -129,7 +142,8 @@ export function TagMenu({
|
|||||||
tag,
|
tag,
|
||||||
truncatedTag,
|
truncatedTag,
|
||||||
upsertMutedWord,
|
upsertMutedWord,
|
||||||
removeMutedWord,
|
removeMutedWords,
|
||||||
|
removeableMuteWords,
|
||||||
])
|
])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {Keyboard, View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import {AppBskyActorDefs, sanitizeMutedWordValue} from '@atproto/api'
|
import {AppBskyActorDefs, sanitizeMutedWordValue} from '@atproto/api'
|
||||||
import {msg, Trans} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
@@ -24,6 +24,7 @@ import * as Dialog from '#/components/Dialog'
|
|||||||
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
|
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
|
||||||
import {Divider} from '#/components/Divider'
|
import {Divider} from '#/components/Divider'
|
||||||
import * as Toggle from '#/components/forms/Toggle'
|
import * as Toggle from '#/components/forms/Toggle'
|
||||||
|
import {useFormatDistance} from '#/components/hooks/dates'
|
||||||
import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
|
import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
|
||||||
import {PageText_Stroke2_Corner0_Rounded as PageText} from '#/components/icons/PageText'
|
import {PageText_Stroke2_Corner0_Rounded as PageText} from '#/components/icons/PageText'
|
||||||
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
||||||
@@ -32,6 +33,8 @@ import {Loader} from '#/components/Loader'
|
|||||||
import * as Prompt from '#/components/Prompt'
|
import * as Prompt from '#/components/Prompt'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
const ONE_DAY = 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
export function MutedWordsDialog() {
|
export function MutedWordsDialog() {
|
||||||
const {mutedWordsDialogControl: control} = useGlobalDialogsControlContext()
|
const {mutedWordsDialogControl: control} = useGlobalDialogsControlContext()
|
||||||
return (
|
return (
|
||||||
@@ -53,16 +56,32 @@ function MutedWordsInner() {
|
|||||||
} = usePreferencesQuery()
|
} = usePreferencesQuery()
|
||||||
const {isPending, mutateAsync: addMutedWord} = useUpsertMutedWordsMutation()
|
const {isPending, mutateAsync: addMutedWord} = useUpsertMutedWordsMutation()
|
||||||
const [field, setField] = React.useState('')
|
const [field, setField] = React.useState('')
|
||||||
const [options, setOptions] = React.useState(['content'])
|
const [targets, setTargets] = React.useState(['content'])
|
||||||
const [error, setError] = React.useState('')
|
const [error, setError] = React.useState('')
|
||||||
|
const [durations, setDurations] = React.useState(['forever'])
|
||||||
|
const [excludeFollowing, setExcludeFollowing] = React.useState(false)
|
||||||
|
|
||||||
const submit = React.useCallback(async () => {
|
const submit = React.useCallback(async () => {
|
||||||
const sanitizedValue = sanitizeMutedWordValue(field)
|
const sanitizedValue = sanitizeMutedWordValue(field)
|
||||||
const targets = ['tag', options.includes('content') && 'content'].filter(
|
const surfaces = ['tag', targets.includes('content') && 'content'].filter(
|
||||||
Boolean,
|
Boolean,
|
||||||
) as AppBskyActorDefs.MutedWord['targets']
|
) as AppBskyActorDefs.MutedWord['targets']
|
||||||
|
const actorTarget = excludeFollowing ? 'exclude-following' : 'all'
|
||||||
|
|
||||||
if (!sanitizedValue || !targets.length) {
|
const now = Date.now()
|
||||||
|
const rawDuration = durations.at(0)
|
||||||
|
// undefined evaluates to 'forever'
|
||||||
|
let duration: string | undefined
|
||||||
|
|
||||||
|
if (rawDuration === '24_hours') {
|
||||||
|
duration = new Date(now + ONE_DAY).toISOString()
|
||||||
|
} else if (rawDuration === '7_days') {
|
||||||
|
duration = new Date(now + 7 * ONE_DAY).toISOString()
|
||||||
|
} else if (rawDuration === '30_days') {
|
||||||
|
duration = new Date(now + 30 * ONE_DAY).toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sanitizedValue || !surfaces.length) {
|
||||||
setField('')
|
setField('')
|
||||||
setError(_(msg`Please enter a valid word, tag, or phrase to mute`))
|
setError(_(msg`Please enter a valid word, tag, or phrase to mute`))
|
||||||
return
|
return
|
||||||
@@ -70,28 +89,37 @@ function MutedWordsInner() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// send raw value and rely on SDK as sanitization source of truth
|
// send raw value and rely on SDK as sanitization source of truth
|
||||||
await addMutedWord([{value: field, targets}])
|
await addMutedWord([
|
||||||
|
{
|
||||||
|
value: field,
|
||||||
|
targets: surfaces,
|
||||||
|
actorTarget,
|
||||||
|
expiresAt: duration,
|
||||||
|
},
|
||||||
|
])
|
||||||
setField('')
|
setField('')
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
logger.error(`Failed to save muted word`, {message: e.message})
|
logger.error(`Failed to save muted word`, {message: e.message})
|
||||||
setError(e.message)
|
setError(e.message)
|
||||||
}
|
}
|
||||||
}, [_, field, options, addMutedWord, setField])
|
}, [_, field, targets, addMutedWord, setField, durations, excludeFollowing])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog.ScrollableInner label={_(msg`Manage your muted words and tags`)}>
|
<Dialog.ScrollableInner label={_(msg`Manage your muted words and tags`)}>
|
||||||
<View onTouchStart={Keyboard.dismiss}>
|
<View>
|
||||||
<Text
|
<Text
|
||||||
style={[a.text_md, a.font_bold, a.pb_sm, t.atoms.text_contrast_high]}>
|
style={[a.text_md, a.font_bold, a.pb_sm, t.atoms.text_contrast_high]}>
|
||||||
<Trans>Add muted words and tags</Trans>
|
<Trans>Add muted words and tags</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[a.pb_lg, a.leading_snug, t.atoms.text_contrast_medium]}>
|
<Text style={[a.pb_lg, a.leading_snug, t.atoms.text_contrast_medium]}>
|
||||||
<Trans>
|
<Trans>
|
||||||
Posts can be muted based on their text, their tags, or both.
|
Posts can be muted based on their text, their tags, or both. We
|
||||||
|
recommend avoiding common words that appear in many posts, since it
|
||||||
|
can result in no posts being shown.
|
||||||
</Trans>
|
</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<View style={[a.pb_lg]}>
|
<View style={[a.pb_sm]}>
|
||||||
<Dialog.Input
|
<Dialog.Input
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
@@ -107,30 +135,135 @@ function MutedWordsInner() {
|
|||||||
}}
|
}}
|
||||||
onSubmitEditing={submit}
|
onSubmitEditing={submit}
|
||||||
/>
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={[a.pb_xl, a.gap_sm]}>
|
||||||
<Toggle.Group
|
<Toggle.Group
|
||||||
label={_(msg`Toggle between muted word options.`)}
|
label={_(msg`Select how long to mute this word for.`)}
|
||||||
type="radio"
|
type="radio"
|
||||||
values={options}
|
values={durations}
|
||||||
onChange={setOptions}>
|
onChange={setDurations}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.pb_xs,
|
||||||
|
a.text_sm,
|
||||||
|
a.font_bold,
|
||||||
|
t.atoms.text_contrast_medium,
|
||||||
|
]}>
|
||||||
|
<Trans>Duration:</Trans>
|
||||||
|
</Text>
|
||||||
|
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
a.pt_sm,
|
gtMobile && [a.flex_row, a.align_center, a.justify_start],
|
||||||
a.py_sm,
|
|
||||||
a.flex_row,
|
|
||||||
a.align_center,
|
|
||||||
a.gap_sm,
|
a.gap_sm,
|
||||||
a.flex_wrap,
|
|
||||||
]}>
|
]}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.flex_1,
|
||||||
|
a.flex_row,
|
||||||
|
a.justify_start,
|
||||||
|
a.align_center,
|
||||||
|
a.gap_sm,
|
||||||
|
]}>
|
||||||
|
<Toggle.Item
|
||||||
|
label={_(msg`Mute this word until you unmute it`)}
|
||||||
|
name="forever"
|
||||||
|
style={[a.flex_1]}>
|
||||||
|
<TargetToggle>
|
||||||
|
<View
|
||||||
|
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
|
||||||
|
<Toggle.Radio />
|
||||||
|
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
|
||||||
|
<Trans>Forever</Trans>
|
||||||
|
</Toggle.LabelText>
|
||||||
|
</View>
|
||||||
|
</TargetToggle>
|
||||||
|
</Toggle.Item>
|
||||||
|
|
||||||
|
<Toggle.Item
|
||||||
|
label={_(msg`Mute this word for 24 hours`)}
|
||||||
|
name="24_hours"
|
||||||
|
style={[a.flex_1]}>
|
||||||
|
<TargetToggle>
|
||||||
|
<View
|
||||||
|
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
|
||||||
|
<Toggle.Radio />
|
||||||
|
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
|
||||||
|
<Trans>24 hours</Trans>
|
||||||
|
</Toggle.LabelText>
|
||||||
|
</View>
|
||||||
|
</TargetToggle>
|
||||||
|
</Toggle.Item>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.flex_1,
|
||||||
|
a.flex_row,
|
||||||
|
a.justify_start,
|
||||||
|
a.align_center,
|
||||||
|
a.gap_sm,
|
||||||
|
]}>
|
||||||
|
<Toggle.Item
|
||||||
|
label={_(msg`Mute this word for 7 days`)}
|
||||||
|
name="7_days"
|
||||||
|
style={[a.flex_1]}>
|
||||||
|
<TargetToggle>
|
||||||
|
<View
|
||||||
|
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
|
||||||
|
<Toggle.Radio />
|
||||||
|
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
|
||||||
|
<Trans>7 days</Trans>
|
||||||
|
</Toggle.LabelText>
|
||||||
|
</View>
|
||||||
|
</TargetToggle>
|
||||||
|
</Toggle.Item>
|
||||||
|
|
||||||
|
<Toggle.Item
|
||||||
|
label={_(msg`Mute this word for 30 days`)}
|
||||||
|
name="30_days"
|
||||||
|
style={[a.flex_1]}>
|
||||||
|
<TargetToggle>
|
||||||
|
<View
|
||||||
|
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
|
||||||
|
<Toggle.Radio />
|
||||||
|
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
|
||||||
|
<Trans>30 days</Trans>
|
||||||
|
</Toggle.LabelText>
|
||||||
|
</View>
|
||||||
|
</TargetToggle>
|
||||||
|
</Toggle.Item>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Toggle.Group>
|
||||||
|
|
||||||
|
<Toggle.Group
|
||||||
|
label={_(msg`Select what content this mute word should apply to.`)}
|
||||||
|
type="radio"
|
||||||
|
values={targets}
|
||||||
|
onChange={setTargets}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.pb_xs,
|
||||||
|
a.text_sm,
|
||||||
|
a.font_bold,
|
||||||
|
t.atoms.text_contrast_medium,
|
||||||
|
]}>
|
||||||
|
<Trans>Mute in:</Trans>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={[a.flex_row, a.align_center, a.gap_sm, a.flex_wrap]}>
|
||||||
<Toggle.Item
|
<Toggle.Item
|
||||||
label={_(msg`Mute this word in post text and tags`)}
|
label={_(msg`Mute this word in post text and tags`)}
|
||||||
name="content"
|
name="content"
|
||||||
style={[a.flex_1, !gtMobile && [a.w_full, a.flex_0]]}>
|
style={[a.flex_1]}>
|
||||||
<TargetToggle>
|
<TargetToggle>
|
||||||
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
|
<View
|
||||||
|
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
|
||||||
<Toggle.Radio />
|
<Toggle.Radio />
|
||||||
<Toggle.LabelText>
|
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
|
||||||
<Trans>Mute in text & tags</Trans>
|
<Trans>Text & tags</Trans>
|
||||||
</Toggle.LabelText>
|
</Toggle.LabelText>
|
||||||
</View>
|
</View>
|
||||||
<PageText size="sm" />
|
<PageText size="sm" />
|
||||||
@@ -140,34 +273,64 @@ function MutedWordsInner() {
|
|||||||
<Toggle.Item
|
<Toggle.Item
|
||||||
label={_(msg`Mute this word in tags only`)}
|
label={_(msg`Mute this word in tags only`)}
|
||||||
name="tag"
|
name="tag"
|
||||||
style={[a.flex_1, !gtMobile && [a.w_full, a.flex_0]]}>
|
style={[a.flex_1]}>
|
||||||
<TargetToggle>
|
<TargetToggle>
|
||||||
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
|
<View
|
||||||
|
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
|
||||||
<Toggle.Radio />
|
<Toggle.Radio />
|
||||||
<Toggle.LabelText>
|
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
|
||||||
<Trans>Mute in tags only</Trans>
|
<Trans>Tags only</Trans>
|
||||||
</Toggle.LabelText>
|
</Toggle.LabelText>
|
||||||
</View>
|
</View>
|
||||||
<Hashtag size="sm" />
|
<Hashtag size="sm" />
|
||||||
</TargetToggle>
|
</TargetToggle>
|
||||||
</Toggle.Item>
|
</Toggle.Item>
|
||||||
|
|
||||||
<Button
|
|
||||||
disabled={isPending || !field}
|
|
||||||
label={_(msg`Add mute word for configured settings`)}
|
|
||||||
size="small"
|
|
||||||
color="primary"
|
|
||||||
variant="solid"
|
|
||||||
style={[!gtMobile && [a.w_full, a.flex_0]]}
|
|
||||||
onPress={submit}>
|
|
||||||
<ButtonText>
|
|
||||||
<Trans>Add</Trans>
|
|
||||||
</ButtonText>
|
|
||||||
<ButtonIcon icon={isPending ? Loader : Plus} />
|
|
||||||
</Button>
|
|
||||||
</View>
|
</View>
|
||||||
</Toggle.Group>
|
</Toggle.Group>
|
||||||
|
|
||||||
|
<View>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.pb_xs,
|
||||||
|
a.text_sm,
|
||||||
|
a.font_bold,
|
||||||
|
t.atoms.text_contrast_medium,
|
||||||
|
]}>
|
||||||
|
<Trans>Options:</Trans>
|
||||||
|
</Text>
|
||||||
|
<Toggle.Item
|
||||||
|
label={_(msg`Do not apply this mute word to users you follow`)}
|
||||||
|
name="exclude_following"
|
||||||
|
style={[a.flex_row, a.justify_between]}
|
||||||
|
value={excludeFollowing}
|
||||||
|
onChange={setExcludeFollowing}>
|
||||||
|
<TargetToggle>
|
||||||
|
<View style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
|
||||||
|
<Toggle.Checkbox />
|
||||||
|
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
|
||||||
|
<Trans>Exclude users you follow</Trans>
|
||||||
|
</Toggle.LabelText>
|
||||||
|
</View>
|
||||||
|
</TargetToggle>
|
||||||
|
</Toggle.Item>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={[a.pt_xs]}>
|
||||||
|
<Button
|
||||||
|
disabled={isPending || !field}
|
||||||
|
label={_(msg`Add mute word for configured settings`)}
|
||||||
|
size="medium"
|
||||||
|
color="primary"
|
||||||
|
variant="solid"
|
||||||
|
style={[]}
|
||||||
|
onPress={submit}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Add</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
<ButtonIcon icon={isPending ? Loader : Plus} position="right" />
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
@@ -191,20 +354,6 @@ function MutedWordsInner() {
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
a.pt_xs,
|
|
||||||
a.text_sm,
|
|
||||||
a.italic,
|
|
||||||
a.leading_snug,
|
|
||||||
t.atoms.text_contrast_medium,
|
|
||||||
]}>
|
|
||||||
<Trans>
|
|
||||||
We recommend avoiding common words that appear in many posts,
|
|
||||||
since it can result in no posts being shown.
|
|
||||||
</Trans>
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
@@ -268,6 +417,9 @@ function MutedWordRow({
|
|||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const {isPending, mutateAsync: removeMutedWord} = useRemoveMutedWordMutation()
|
const {isPending, mutateAsync: removeMutedWord} = useRemoveMutedWordMutation()
|
||||||
const control = Prompt.usePromptControl()
|
const control = Prompt.usePromptControl()
|
||||||
|
const expiryDate = word.expiresAt ? new Date(word.expiresAt) : undefined
|
||||||
|
const isExpired = expiryDate && expiryDate < new Date()
|
||||||
|
const formatDistance = useFormatDistance()
|
||||||
|
|
||||||
const remove = React.useCallback(async () => {
|
const remove = React.useCallback(async () => {
|
||||||
control.close()
|
control.close()
|
||||||
@@ -280,7 +432,7 @@ function MutedWordRow({
|
|||||||
control={control}
|
control={control}
|
||||||
title={_(msg`Are you sure?`)}
|
title={_(msg`Are you sure?`)}
|
||||||
description={_(
|
description={_(
|
||||||
msg`This will delete ${word.value} from your muted words. You can always add it back later.`,
|
msg`This will delete "${word.value}" from your muted words. You can always add it back later.`,
|
||||||
)}
|
)}
|
||||||
onConfirm={remove}
|
onConfirm={remove}
|
||||||
confirmButtonCta={_(msg`Remove`)}
|
confirmButtonCta={_(msg`Remove`)}
|
||||||
@@ -289,53 +441,94 @@ function MutedWordRow({
|
|||||||
|
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
|
a.flex_row,
|
||||||
|
a.justify_between,
|
||||||
a.py_md,
|
a.py_md,
|
||||||
a.px_lg,
|
a.px_lg,
|
||||||
a.flex_row,
|
|
||||||
a.align_center,
|
|
||||||
a.justify_between,
|
|
||||||
a.rounded_md,
|
a.rounded_md,
|
||||||
a.gap_md,
|
a.gap_md,
|
||||||
style,
|
style,
|
||||||
]}>
|
]}>
|
||||||
<Text
|
<View style={[a.flex_1, a.gap_xs]}>
|
||||||
style={[
|
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
|
||||||
a.flex_1,
|
<Text
|
||||||
a.leading_snug,
|
style={[
|
||||||
a.w_full,
|
a.flex_1,
|
||||||
a.font_bold,
|
a.leading_snug,
|
||||||
t.atoms.text_contrast_high,
|
a.font_bold,
|
||||||
web({
|
web({
|
||||||
overflowWrap: 'break-word',
|
overflowWrap: 'break-word',
|
||||||
wordBreak: 'break-word',
|
wordBreak: 'break-word',
|
||||||
}),
|
}),
|
||||||
]}>
|
]}>
|
||||||
{word.value}
|
{word.targets.find(t => t === 'content') ? (
|
||||||
</Text>
|
<Trans comment="Pattern: {wordValue} in text, tags">
|
||||||
|
{word.value}{' '}
|
||||||
|
<Text style={[a.font_normal, t.atoms.text_contrast_medium]}>
|
||||||
|
in{' '}
|
||||||
|
<Text style={[a.font_bold, t.atoms.text_contrast_medium]}>
|
||||||
|
text & tags
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans comment="Pattern: {wordValue} in tags">
|
||||||
|
{word.value}{' '}
|
||||||
|
<Text style={[a.font_normal, t.atoms.text_contrast_medium]}>
|
||||||
|
in{' '}
|
||||||
|
<Text style={[a.font_bold, t.atoms.text_contrast_medium]}>
|
||||||
|
tags
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
</Trans>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
<View style={[a.flex_row, a.align_center, a.justify_end, a.gap_sm]}>
|
{(expiryDate || word.actorTarget === 'exclude-following') && (
|
||||||
{word.targets.map(target => (
|
<View style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
|
||||||
<View
|
|
||||||
key={target}
|
|
||||||
style={[a.py_xs, a.px_sm, a.rounded_sm, t.atoms.bg_contrast_100]}>
|
|
||||||
<Text
|
<Text
|
||||||
style={[a.text_xs, a.font_bold, t.atoms.text_contrast_medium]}>
|
style={[
|
||||||
{target === 'content' ? _(msg`text`) : _(msg`tag`)}
|
a.flex_1,
|
||||||
|
a.text_xs,
|
||||||
|
a.leading_snug,
|
||||||
|
t.atoms.text_contrast_medium,
|
||||||
|
]}>
|
||||||
|
{expiryDate && (
|
||||||
|
<>
|
||||||
|
{isExpired ? (
|
||||||
|
<Trans>Expired</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>
|
||||||
|
Expires{' '}
|
||||||
|
{formatDistance(expiryDate, new Date(), {
|
||||||
|
addSuffix: true,
|
||||||
|
})}
|
||||||
|
</Trans>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{word.actorTarget === 'exclude-following' && (
|
||||||
|
<>
|
||||||
|
{' • '}
|
||||||
|
<Trans>Excludes users you follow</Trans>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
))}
|
)}
|
||||||
|
|
||||||
<Button
|
|
||||||
label={_(msg`Remove mute word from your list`)}
|
|
||||||
size="tiny"
|
|
||||||
shape="round"
|
|
||||||
variant="ghost"
|
|
||||||
color="secondary"
|
|
||||||
onPress={() => control.open()}
|
|
||||||
style={[a.ml_sm]}>
|
|
||||||
<ButtonIcon icon={isPending ? Loader : X} />
|
|
||||||
</Button>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
label={_(msg`Remove mute word from your list`)}
|
||||||
|
size="tiny"
|
||||||
|
shape="round"
|
||||||
|
variant="outline"
|
||||||
|
color="secondary"
|
||||||
|
onPress={() => control.open()}
|
||||||
|
style={[a.ml_sm]}>
|
||||||
|
<ButtonIcon icon={isPending ? Loader : X} />
|
||||||
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -23,10 +23,10 @@ export function Group({children, multiple, ...props}: GroupProps) {
|
|||||||
style={[
|
style={[
|
||||||
a.w_full,
|
a.w_full,
|
||||||
a.flex_row,
|
a.flex_row,
|
||||||
a.border,
|
|
||||||
a.rounded_sm,
|
a.rounded_sm,
|
||||||
a.overflow_hidden,
|
a.overflow_hidden,
|
||||||
t.atoms.border_contrast_low,
|
t.atoms.border_contrast_low,
|
||||||
|
{borderWidth: 1},
|
||||||
]}>
|
]}>
|
||||||
{children}
|
{children}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* Hooks for date-fns localized formatters.
|
||||||
|
*
|
||||||
|
* Our app supports some languages that are not included in date-fns by
|
||||||
|
* default, in which case it will fall back to English.
|
||||||
|
*
|
||||||
|
* {@link https://github.com/date-fns/date-fns/blob/main/docs/i18n.md}
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import {formatDistance, Locale} from 'date-fns'
|
||||||
|
import {
|
||||||
|
ca,
|
||||||
|
de,
|
||||||
|
es,
|
||||||
|
fi,
|
||||||
|
fr,
|
||||||
|
hi,
|
||||||
|
id,
|
||||||
|
it,
|
||||||
|
ja,
|
||||||
|
ko,
|
||||||
|
ptBR,
|
||||||
|
tr,
|
||||||
|
uk,
|
||||||
|
zhCN,
|
||||||
|
zhTW,
|
||||||
|
} from 'date-fns/locale'
|
||||||
|
|
||||||
|
import {AppLanguage} from '#/locale/languages'
|
||||||
|
import {useLanguagePrefs} from '#/state/preferences'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link AppLanguage}
|
||||||
|
*/
|
||||||
|
const locales: Record<AppLanguage, Locale | undefined> = {
|
||||||
|
en: undefined,
|
||||||
|
ca,
|
||||||
|
de,
|
||||||
|
es,
|
||||||
|
fi,
|
||||||
|
fr,
|
||||||
|
ga: undefined,
|
||||||
|
hi,
|
||||||
|
id,
|
||||||
|
it,
|
||||||
|
ja,
|
||||||
|
ko,
|
||||||
|
['pt-BR']: ptBR,
|
||||||
|
tr,
|
||||||
|
uk,
|
||||||
|
['zh-CN']: zhCN,
|
||||||
|
['zh-TW']: zhTW,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a localized `formatDistance` function.
|
||||||
|
* {@link formatDistance}
|
||||||
|
*/
|
||||||
|
export function useFormatDistance() {
|
||||||
|
const {appLanguage} = useLanguagePrefs()
|
||||||
|
return React.useCallback<typeof formatDistance>(
|
||||||
|
(date, baseDate, options) => {
|
||||||
|
const locale = locales[appLanguage as AppLanguage]
|
||||||
|
return formatDistance(date, baseDate, {...options, locale: locale})
|
||||||
|
},
|
||||||
|
[appLanguage],
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import {createSinglePathSVG} from './TEMPLATE'
|
||||||
|
|
||||||
|
export const Moon_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||||
|
path: 'M12.097 2.53a1 1 0 0 1-.041 1.07 6 6 0 0 0 8.345 8.344 1 1 0 0 1 1.563.908c-.434 5.122-4.728 9.144-9.962 9.144-5.522 0-9.998-4.476-9.998-9.998 0-5.234 4.021-9.528 9.144-9.962a1 1 0 0 1 .949.494ZM9.424 4.424a7.998 7.998 0 1 0 10.152 10.152A8 8 0 0 1 9.424 4.424Z',
|
||||||
|
})
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import {createSinglePathSVG} from './TEMPLATE'
|
||||||
|
|
||||||
|
export const Phone_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||||
|
path: 'M5 4a3 3 0 0 1 3-3h8a3 3 0 0 1 3 3v16a3 3 0 0 1-3 3H8a3 3 0 0 1-3-3V4Zm3-1a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1H8Zm2 2a1 1 0 0 1 1-1h2a1 1 0 1 1 0 2h-2a1 1 0 0 1-1-1Z',
|
||||||
|
})
|
||||||
@@ -136,16 +136,6 @@ export class FeedViewPostsSlice {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class NoopFeedTuner {
|
|
||||||
reset() {}
|
|
||||||
tune(
|
|
||||||
feed: FeedViewPost[],
|
|
||||||
_opts?: {dryRun: boolean; maintainOrder: boolean},
|
|
||||||
): FeedViewPostsSlice[] {
|
|
||||||
return feed.map(item => new FeedViewPostsSlice(item))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class FeedTuner {
|
export class FeedTuner {
|
||||||
seenKeys: Set<string> = new Set()
|
seenKeys: Set<string> = new Set()
|
||||||
seenUris: Set<string> = new Set()
|
seenUris: Set<string> = new Set()
|
||||||
@@ -309,15 +299,7 @@ export class FeedTuner {
|
|||||||
return slices
|
return slices
|
||||||
}
|
}
|
||||||
|
|
||||||
static thresholdRepliesOnly({
|
static followedRepliesOnly({userDid}: {userDid: string}) {
|
||||||
userDid,
|
|
||||||
minLikes,
|
|
||||||
followedOnly,
|
|
||||||
}: {
|
|
||||||
userDid: string
|
|
||||||
minLikes: number
|
|
||||||
followedOnly: boolean
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
tuner: FeedTuner,
|
tuner: FeedTuner,
|
||||||
slices: FeedViewPostsSlice[],
|
slices: FeedViewPostsSlice[],
|
||||||
@@ -332,9 +314,7 @@ export class FeedTuner {
|
|||||||
if (slice.isRepost) {
|
if (slice.isRepost) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (slice.likeCount < minLikes) {
|
if (!slice.isFollowingAllAuthors(userDid)) {
|
||||||
slices.splice(i, 1)
|
|
||||||
} else if (followedOnly && !slice.isFollowingAllAuthors(userDid)) {
|
|
||||||
slices.splice(i, 1)
|
slices.splice(i, 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export type CommonNavigatorParams = {
|
|||||||
PreferencesThreads: undefined
|
PreferencesThreads: undefined
|
||||||
PreferencesExternalEmbeds: undefined
|
PreferencesExternalEmbeds: undefined
|
||||||
AccessibilitySettings: undefined
|
AccessibilitySettings: undefined
|
||||||
|
AppearanceSettings: undefined
|
||||||
Search: {q?: string}
|
Search: {q?: string}
|
||||||
Hashtag: {tag: string; author?: string}
|
Hashtag: {tag: string; author?: string}
|
||||||
MessagesConversation: {conversation: string; embed?: string}
|
MessagesConversation: {conversation: string; embed?: string}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export const router = new Router({
|
|||||||
PreferencesThreads: '/settings/threads',
|
PreferencesThreads: '/settings/threads',
|
||||||
PreferencesExternalEmbeds: '/settings/external-embeds',
|
PreferencesExternalEmbeds: '/settings/external-embeds',
|
||||||
AccessibilitySettings: '/settings/accessibility',
|
AccessibilitySettings: '/settings/accessibility',
|
||||||
|
AppearanceSettings: '/settings/appearance',
|
||||||
SavedFeeds: '/settings/saved-feeds',
|
SavedFeeds: '/settings/saved-feeds',
|
||||||
Support: '/support',
|
Support: '/support',
|
||||||
PrivacyPolicy: '/support/privacy',
|
PrivacyPolicy: '/support/privacy',
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import React, {useCallback} from 'react'
|
||||||
|
import {View} from 'react-native'
|
||||||
|
import Animated, {
|
||||||
|
FadeInDown,
|
||||||
|
FadeOutDown,
|
||||||
|
LayoutAnimationConfig,
|
||||||
|
} from 'react-native-reanimated'
|
||||||
|
import {msg, Trans} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||||
|
import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
|
||||||
|
import {s} from '#/lib/styles'
|
||||||
|
import {useSetThemePrefs, useThemePrefs} from '#/state/shell'
|
||||||
|
import {SimpleViewHeader} from '#/view/com/util/SimpleViewHeader'
|
||||||
|
import {ScrollView} from '#/view/com/util/Views'
|
||||||
|
import {atoms as a, native, useTheme} from '#/alf'
|
||||||
|
import * as ToggleButton from '#/components/forms/ToggleButton'
|
||||||
|
import {Moon_Stroke2_Corner0_Rounded as MoonIcon} from '#/components/icons/Moon'
|
||||||
|
import {Phone_Stroke2_Corner0_Rounded as PhoneIcon} from '#/components/icons/Phone'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
type Props = NativeStackScreenProps<CommonNavigatorParams, 'AppearanceSettings'>
|
||||||
|
export function AppearanceSettingsScreen({}: Props) {
|
||||||
|
const {_} = useLingui()
|
||||||
|
const t = useTheme()
|
||||||
|
const {isTabletOrMobile} = useWebMediaQueries()
|
||||||
|
|
||||||
|
const {colorMode, darkTheme} = useThemePrefs()
|
||||||
|
const {setColorMode, setDarkTheme} = useSetThemePrefs()
|
||||||
|
|
||||||
|
const onChangeAppearance = useCallback(
|
||||||
|
(keys: string[]) => {
|
||||||
|
const appearance = keys.find(key => key !== colorMode) as
|
||||||
|
| 'system'
|
||||||
|
| 'light'
|
||||||
|
| 'dark'
|
||||||
|
| undefined
|
||||||
|
if (!appearance) return
|
||||||
|
setColorMode(appearance)
|
||||||
|
},
|
||||||
|
[setColorMode, colorMode],
|
||||||
|
)
|
||||||
|
|
||||||
|
const onChangeDarkTheme = useCallback(
|
||||||
|
(keys: string[]) => {
|
||||||
|
const theme = keys.find(key => key !== darkTheme) as
|
||||||
|
| 'dim'
|
||||||
|
| 'dark'
|
||||||
|
| undefined
|
||||||
|
if (!theme) return
|
||||||
|
setDarkTheme(theme)
|
||||||
|
},
|
||||||
|
[setDarkTheme, darkTheme],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LayoutAnimationConfig skipExiting skipEntering>
|
||||||
|
<View testID="preferencesThreadsScreen" style={s.hContentRegion}>
|
||||||
|
<ScrollView
|
||||||
|
// @ts-ignore web only -prf
|
||||||
|
dataSet={{'stable-gutters': 1}}
|
||||||
|
contentContainerStyle={{paddingBottom: 75}}>
|
||||||
|
<SimpleViewHeader
|
||||||
|
showBackButton={isTabletOrMobile}
|
||||||
|
style={[t.atoms.border_contrast_medium, a.border_b]}>
|
||||||
|
<View style={a.flex_1}>
|
||||||
|
<Text style={[a.text_2xl, a.font_bold]}>
|
||||||
|
<Trans>Appearance</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</SimpleViewHeader>
|
||||||
|
|
||||||
|
<View style={[a.p_xl, a.gap_lg]}>
|
||||||
|
<View style={[a.flex_row, a.align_center, a.gap_md]}>
|
||||||
|
<PhoneIcon style={t.atoms.text} />
|
||||||
|
<Text style={a.text_md}>
|
||||||
|
<Trans>Mode</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<ToggleButton.Group
|
||||||
|
label={_(msg`Dark mode`)}
|
||||||
|
values={[colorMode]}
|
||||||
|
onChange={onChangeAppearance}>
|
||||||
|
<ToggleButton.Button label={_(msg`System`)} name="system">
|
||||||
|
<ToggleButton.ButtonText>
|
||||||
|
<Trans>System</Trans>
|
||||||
|
</ToggleButton.ButtonText>
|
||||||
|
</ToggleButton.Button>
|
||||||
|
<ToggleButton.Button label={_(msg`Light`)} name="light">
|
||||||
|
<ToggleButton.ButtonText>
|
||||||
|
<Trans>Light</Trans>
|
||||||
|
</ToggleButton.ButtonText>
|
||||||
|
</ToggleButton.Button>
|
||||||
|
<ToggleButton.Button label={_(msg`Dark`)} name="dark">
|
||||||
|
<ToggleButton.ButtonText>
|
||||||
|
<Trans>Dark</Trans>
|
||||||
|
</ToggleButton.ButtonText>
|
||||||
|
</ToggleButton.Button>
|
||||||
|
</ToggleButton.Group>
|
||||||
|
{colorMode !== 'light' && (
|
||||||
|
<Animated.View
|
||||||
|
entering={native(FadeInDown)}
|
||||||
|
exiting={native(FadeOutDown)}
|
||||||
|
style={[a.mt_md, a.gap_lg]}>
|
||||||
|
<View style={[a.flex_row, a.align_center, a.gap_md]}>
|
||||||
|
<MoonIcon style={t.atoms.text} />
|
||||||
|
<Text style={a.text_md}>
|
||||||
|
<Trans>Dark theme</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ToggleButton.Group
|
||||||
|
label={_(msg`Dark theme`)}
|
||||||
|
values={[darkTheme ?? 'dim']}
|
||||||
|
onChange={onChangeDarkTheme}>
|
||||||
|
<ToggleButton.Button label={_(msg`Dim`)} name="dim">
|
||||||
|
<ToggleButton.ButtonText>
|
||||||
|
<Trans>Dim</Trans>
|
||||||
|
</ToggleButton.ButtonText>
|
||||||
|
</ToggleButton.Button>
|
||||||
|
<ToggleButton.Button label={_(msg`Dark`)} name="dark">
|
||||||
|
<ToggleButton.ButtonText>
|
||||||
|
<Trans>Dark</Trans>
|
||||||
|
</ToggleButton.ButtonText>
|
||||||
|
</ToggleButton.Button>
|
||||||
|
</ToggleButton.Group>
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</LayoutAnimationConfig>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -38,11 +38,8 @@ export function useFeedTuners(feedDesc: FeedDescriptor) {
|
|||||||
feedTuners.push(FeedTuner.removeReplies)
|
feedTuners.push(FeedTuner.removeReplies)
|
||||||
} else {
|
} else {
|
||||||
feedTuners.push(
|
feedTuners.push(
|
||||||
FeedTuner.thresholdRepliesOnly({
|
FeedTuner.followedRepliesOnly({
|
||||||
userDid: currentAccount?.did || '',
|
userDid: currentAccount?.did || '',
|
||||||
minLikes: preferences?.feedViewPrefs.hideRepliesByLikeCount || 0,
|
|
||||||
followedOnly:
|
|
||||||
!!preferences?.feedViewPrefs.hideRepliesByUnfollowed,
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -66,10 +63,8 @@ export function useFeedTuners(feedDesc: FeedDescriptor) {
|
|||||||
feedTuners.push(FeedTuner.removeReplies)
|
feedTuners.push(FeedTuner.removeReplies)
|
||||||
} else {
|
} else {
|
||||||
feedTuners.push(
|
feedTuners.push(
|
||||||
FeedTuner.thresholdRepliesOnly({
|
FeedTuner.followedRepliesOnly({
|
||||||
userDid: currentAccount?.did || '',
|
userDid: currentAccount?.did || '',
|
||||||
minLikes: preferences?.feedViewPrefs.hideRepliesByLikeCount || 0,
|
|
||||||
followedOnly: !!preferences?.feedViewPrefs.hideRepliesByUnfollowed,
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,7 +59,6 @@ export function useNotificationFeedQuery(opts?: {
|
|||||||
const moderationOpts = useModerationOpts()
|
const moderationOpts = useModerationOpts()
|
||||||
const unreads = useUnreadNotificationsApi()
|
const unreads = useUnreadNotificationsApi()
|
||||||
const enabled = opts?.enabled !== false
|
const enabled = opts?.enabled !== false
|
||||||
const lastPageCountRef = useRef(0)
|
|
||||||
const gate = useGate()
|
const gate = useGate()
|
||||||
|
|
||||||
// false: force showing all notifications
|
// false: force showing all notifications
|
||||||
@@ -121,28 +120,52 @@ export function useNotificationFeedQuery(opts?: {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// The server may end up returning an empty page, a page with too few items,
|
||||||
|
// or a page with items that end up getting filtered out. When we fetch pages,
|
||||||
|
// we'll keep track of how many items we actually hope to see. If the server
|
||||||
|
// doesn't return enough items, we're going to continue asking for more items.
|
||||||
|
const lastItemCount = useRef(0)
|
||||||
|
const wantedItemCount = useRef(0)
|
||||||
|
const autoPaginationAttemptCount = useRef(0)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const {isFetching, hasNextPage, data} = query
|
const {data, isLoading, isRefetching, isFetchingNextPage, hasNextPage} =
|
||||||
if (isFetching || !hasNextPage) {
|
query
|
||||||
return
|
// Count the items that we already have.
|
||||||
}
|
let itemCount = 0
|
||||||
|
|
||||||
// avoid double-fires of fetchNextPage()
|
|
||||||
if (
|
|
||||||
lastPageCountRef.current !== 0 &&
|
|
||||||
lastPageCountRef.current === data?.pages?.length
|
|
||||||
) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// fetch next page if we haven't gotten a full page of content
|
|
||||||
let count = 0
|
|
||||||
for (const page of data?.pages || []) {
|
for (const page of data?.pages || []) {
|
||||||
count += page.items.length
|
itemCount += page.items.length
|
||||||
}
|
}
|
||||||
if (count < PAGE_SIZE && (data?.pages.length || 0) < 6) {
|
|
||||||
query.fetchNextPage()
|
// If items got truncated, reset the state we're tracking below.
|
||||||
lastPageCountRef.current = data?.pages?.length || 0
|
if (itemCount !== lastItemCount.current) {
|
||||||
|
if (itemCount < lastItemCount.current) {
|
||||||
|
wantedItemCount.current = itemCount
|
||||||
|
}
|
||||||
|
lastItemCount.current = itemCount
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now track how many items we really want, and fetch more if needed.
|
||||||
|
if (isLoading || isRefetching) {
|
||||||
|
// During the initial fetch, we want to get an entire page's worth of items.
|
||||||
|
wantedItemCount.current = PAGE_SIZE
|
||||||
|
} else if (isFetchingNextPage) {
|
||||||
|
if (itemCount > wantedItemCount.current) {
|
||||||
|
// We have more items than wantedItemCount, so wantedItemCount must be out of date.
|
||||||
|
// Some other code must have called fetchNextPage(), for example, from onEndReached.
|
||||||
|
// Adjust the wantedItemCount to reflect that we want one more full page of items.
|
||||||
|
wantedItemCount.current = itemCount + PAGE_SIZE
|
||||||
|
}
|
||||||
|
} else if (hasNextPage) {
|
||||||
|
// At this point we're not fetching anymore, so it's time to make a decision.
|
||||||
|
// If we didn't receive enough items from the server, paginate again until we do.
|
||||||
|
if (itemCount < wantedItemCount.current) {
|
||||||
|
autoPaginationAttemptCount.current++
|
||||||
|
if (autoPaginationAttemptCount.current < 50 /* failsafe */) {
|
||||||
|
query.fetchNextPage()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
autoPaginationAttemptCount.current = 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [query])
|
}, [query])
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import {LikesFeedAPI} from 'lib/api/feed/likes'
|
|||||||
import {ListFeedAPI} from 'lib/api/feed/list'
|
import {ListFeedAPI} from 'lib/api/feed/list'
|
||||||
import {MergeFeedAPI} from 'lib/api/feed/merge'
|
import {MergeFeedAPI} from 'lib/api/feed/merge'
|
||||||
import {FeedAPI, ReasonFeedSource} from 'lib/api/feed/types'
|
import {FeedAPI, ReasonFeedSource} from 'lib/api/feed/types'
|
||||||
import {FeedTuner, FeedTunerFn, NoopFeedTuner} from 'lib/api/feed-manip'
|
import {FeedTuner, FeedTunerFn} from 'lib/api/feed-manip'
|
||||||
import {BSKY_FEED_OWNER_DIDS} from 'lib/constants'
|
import {BSKY_FEED_OWNER_DIDS} from 'lib/constants'
|
||||||
import {KnownError} from '#/view/com/posts/FeedErrorMessage'
|
import {KnownError} from '#/view/com/posts/FeedErrorMessage'
|
||||||
import {useFeedTuners} from '../preferences/feed-tuners'
|
import {useFeedTuners} from '../preferences/feed-tuners'
|
||||||
@@ -61,7 +61,6 @@ export type FeedDescriptor =
|
|||||||
| `list|${ListUri}`
|
| `list|${ListUri}`
|
||||||
| `list|${ListUri}|${ListFilter}`
|
| `list|${ListUri}|${ListFilter}`
|
||||||
export interface FeedParams {
|
export interface FeedParams {
|
||||||
disableTuner?: boolean
|
|
||||||
mergeFeedEnabled?: boolean
|
mergeFeedEnabled?: boolean
|
||||||
mergeFeedSources?: string[]
|
mergeFeedSources?: string[]
|
||||||
}
|
}
|
||||||
@@ -105,7 +104,7 @@ export interface FeedPageUnselected {
|
|||||||
|
|
||||||
export interface FeedPage {
|
export interface FeedPage {
|
||||||
api: FeedAPI
|
api: FeedAPI
|
||||||
tuner: FeedTuner | NoopFeedTuner
|
tuner: FeedTuner
|
||||||
cursor: string | undefined
|
cursor: string | undefined
|
||||||
slices: FeedPostSlice[]
|
slices: FeedPostSlice[]
|
||||||
fetchedAt: number
|
fetchedAt: number
|
||||||
@@ -135,25 +134,17 @@ export function usePostFeedQuery(
|
|||||||
args: typeof selectArgs
|
args: typeof selectArgs
|
||||||
result: InfiniteData<FeedPage>
|
result: InfiniteData<FeedPage>
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
const lastPageCountRef = useRef(0)
|
|
||||||
const isDiscover = feedDesc.includes(DISCOVER_FEED_URI)
|
const isDiscover = feedDesc.includes(DISCOVER_FEED_URI)
|
||||||
|
|
||||||
// Make sure this doesn't invalidate unless really needed.
|
// Make sure this doesn't invalidate unless really needed.
|
||||||
const selectArgs = React.useMemo(
|
const selectArgs = React.useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
feedTuners,
|
feedTuners,
|
||||||
disableTuner: params?.disableTuner,
|
|
||||||
moderationOpts,
|
moderationOpts,
|
||||||
ignoreFilterFor: opts?.ignoreFilterFor,
|
ignoreFilterFor: opts?.ignoreFilterFor,
|
||||||
isDiscover,
|
isDiscover,
|
||||||
}),
|
}),
|
||||||
[
|
[feedTuners, moderationOpts, opts?.ignoreFilterFor, isDiscover],
|
||||||
feedTuners,
|
|
||||||
params?.disableTuner,
|
|
||||||
moderationOpts,
|
|
||||||
opts?.ignoreFilterFor,
|
|
||||||
isDiscover,
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const query = useInfiniteQuery<
|
const query = useInfiniteQuery<
|
||||||
@@ -232,17 +223,10 @@ export function usePostFeedQuery(
|
|||||||
(data: InfiniteData<FeedPageUnselected, RQPageParam>) => {
|
(data: InfiniteData<FeedPageUnselected, RQPageParam>) => {
|
||||||
// If the selection depends on some data, that data should
|
// If the selection depends on some data, that data should
|
||||||
// be included in the selectArgs object and read here.
|
// be included in the selectArgs object and read here.
|
||||||
const {
|
const {feedTuners, moderationOpts, ignoreFilterFor, isDiscover} =
|
||||||
feedTuners,
|
selectArgs
|
||||||
disableTuner,
|
|
||||||
moderationOpts,
|
|
||||||
ignoreFilterFor,
|
|
||||||
isDiscover,
|
|
||||||
} = selectArgs
|
|
||||||
|
|
||||||
const tuner = disableTuner
|
const tuner = new FeedTuner(feedTuners)
|
||||||
? new NoopFeedTuner()
|
|
||||||
: new FeedTuner(feedTuners)
|
|
||||||
|
|
||||||
// Keep track of the last run and whether we can reuse
|
// Keep track of the last run and whether we can reuse
|
||||||
// some already selected pages from there.
|
// some already selected pages from there.
|
||||||
@@ -391,30 +375,54 @@ export function usePostFeedQuery(
|
|||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// The server may end up returning an empty page, a page with too few items,
|
||||||
|
// or a page with items that end up getting filtered out. When we fetch pages,
|
||||||
|
// we'll keep track of how many items we actually hope to see. If the server
|
||||||
|
// doesn't return enough items, we're going to continue asking for more items.
|
||||||
|
const lastItemCount = useRef(0)
|
||||||
|
const wantedItemCount = useRef(0)
|
||||||
|
const autoPaginationAttemptCount = useRef(0)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const {isFetching, hasNextPage, data} = query
|
const {data, isLoading, isRefetching, isFetchingNextPage, hasNextPage} =
|
||||||
if (isFetching || !hasNextPage) {
|
query
|
||||||
return
|
// Count the items that we already have.
|
||||||
}
|
let itemCount = 0
|
||||||
|
|
||||||
// avoid double-fires of fetchNextPage()
|
|
||||||
if (
|
|
||||||
lastPageCountRef.current !== 0 &&
|
|
||||||
lastPageCountRef.current === data?.pages?.length
|
|
||||||
) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// fetch next page if we haven't gotten a full page of content
|
|
||||||
let count = 0
|
|
||||||
for (const page of data?.pages || []) {
|
for (const page of data?.pages || []) {
|
||||||
for (const slice of page.slices) {
|
for (const slice of page.slices) {
|
||||||
count += slice.items.length
|
itemCount += slice.items.length
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (count < PAGE_SIZE && (data?.pages.length || 0) < 6) {
|
|
||||||
query.fetchNextPage()
|
// If items got truncated, reset the state we're tracking below.
|
||||||
lastPageCountRef.current = data?.pages?.length || 0
|
if (itemCount !== lastItemCount.current) {
|
||||||
|
if (itemCount < lastItemCount.current) {
|
||||||
|
wantedItemCount.current = itemCount
|
||||||
|
}
|
||||||
|
lastItemCount.current = itemCount
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now track how many items we really want, and fetch more if needed.
|
||||||
|
if (isLoading || isRefetching) {
|
||||||
|
// During the initial fetch, we want to get an entire page's worth of items.
|
||||||
|
wantedItemCount.current = PAGE_SIZE
|
||||||
|
} else if (isFetchingNextPage) {
|
||||||
|
if (itemCount > wantedItemCount.current) {
|
||||||
|
// We have more items than wantedItemCount, so wantedItemCount must be out of date.
|
||||||
|
// Some other code must have called fetchNextPage(), for example, from onEndReached.
|
||||||
|
// Adjust the wantedItemCount to reflect that we want one more full page of items.
|
||||||
|
wantedItemCount.current = itemCount + PAGE_SIZE
|
||||||
|
}
|
||||||
|
} else if (hasNextPage) {
|
||||||
|
// At this point we're not fetching anymore, so it's time to make a decision.
|
||||||
|
// If we didn't receive enough items from the server, paginate again until we do.
|
||||||
|
if (itemCount < wantedItemCount.current) {
|
||||||
|
autoPaginationAttemptCount.current++
|
||||||
|
if (autoPaginationAttemptCount.current < 50 /* failsafe */) {
|
||||||
|
query.fetchNextPage()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
autoPaginationAttemptCount.current = 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [query])
|
}, [query])
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import {
|
|||||||
export const DEFAULT_HOME_FEED_PREFS: UsePreferencesQueryResponse['feedViewPrefs'] =
|
export const DEFAULT_HOME_FEED_PREFS: UsePreferencesQueryResponse['feedViewPrefs'] =
|
||||||
{
|
{
|
||||||
hideReplies: false,
|
hideReplies: false,
|
||||||
hideRepliesByUnfollowed: true,
|
hideRepliesByUnfollowed: true, // Legacy, ignored
|
||||||
hideRepliesByLikeCount: 0,
|
hideRepliesByLikeCount: 0, // Legacy, ignored
|
||||||
hideReposts: false,
|
hideReposts: false,
|
||||||
hideQuotePosts: false,
|
hideQuotePosts: false,
|
||||||
lab_mergeFeedEnabled: false, // experimental
|
lab_mergeFeedEnabled: false, // experimental
|
||||||
|
|||||||
@@ -343,6 +343,21 @@ export function useRemoveMutedWordMutation() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useRemoveMutedWordsMutation() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const agent = useAgent()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (mutedWords: AppBskyActorDefs.MutedWord[]) => {
|
||||||
|
await agent.removeMutedWords(mutedWords)
|
||||||
|
// triggers a refetch
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: preferencesQueryKey,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function useQueueNudgesMutation() {
|
export function useQueueNudgesMutation() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
|
|||||||
@@ -13,11 +13,13 @@ import {
|
|||||||
AppBskyEmbedRecordWithMedia,
|
AppBskyEmbedRecordWithMedia,
|
||||||
AppBskyFeedDefs,
|
AppBskyFeedDefs,
|
||||||
AppBskyFeedPost,
|
AppBskyFeedPost,
|
||||||
|
AppBskyGraphFollow,
|
||||||
moderateProfile,
|
moderateProfile,
|
||||||
ModerationDecision,
|
ModerationDecision,
|
||||||
ModerationOpts,
|
ModerationOpts,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
import {AtUri} from '@atproto/api'
|
import {AtUri} from '@atproto/api'
|
||||||
|
import {TID} from '@atproto/common-web'
|
||||||
import {msg, plural, Trans} from '@lingui/macro'
|
import {msg, plural, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {useNavigation} from '@react-navigation/native'
|
import {useNavigation} from '@react-navigation/native'
|
||||||
@@ -184,10 +186,28 @@ let FeedItem = ({
|
|||||||
action = _(msg`reposted your post`)
|
action = _(msg`reposted your post`)
|
||||||
icon = <RepostIcon size="xl" style={{color: t.palette.positive_600}} />
|
icon = <RepostIcon size="xl" style={{color: t.palette.positive_600}} />
|
||||||
} else if (item.type === 'follow') {
|
} else if (item.type === 'follow') {
|
||||||
|
let isFollowBack = false
|
||||||
|
|
||||||
if (
|
if (
|
||||||
item.notification.author.viewer?.following &&
|
item.notification.author.viewer?.following &&
|
||||||
gate('ungroup_follow_backs')
|
AppBskyGraphFollow.isRecord(item.notification.record)
|
||||||
) {
|
) {
|
||||||
|
let followingTimestamp
|
||||||
|
try {
|
||||||
|
const rkey = new AtUri(item.notification.author.viewer.following).rkey
|
||||||
|
followingTimestamp = TID.fromStr(rkey).timestamp()
|
||||||
|
} catch (e) {
|
||||||
|
// For some reason the following URI was invalid. Default to it not being a follow back.
|
||||||
|
console.error('Invalid following URI')
|
||||||
|
}
|
||||||
|
if (followingTimestamp) {
|
||||||
|
const followedTimestamp =
|
||||||
|
new Date(item.notification.record.createdAt).getTime() * 1000
|
||||||
|
isFollowBack = followedTimestamp > followingTimestamp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isFollowBack && gate('ungroup_follow_backs')) {
|
||||||
action = _(msg`followed you back`)
|
action = _(msg`followed you back`)
|
||||||
} else {
|
} else {
|
||||||
action = _(msg`followed you`)
|
action = _(msg`followed you`)
|
||||||
|
|||||||
@@ -1,38 +1,57 @@
|
|||||||
import React, {useCallback, useMemo, useState} from 'react'
|
import React, {useCallback, useMemo, useState} from 'react'
|
||||||
import {ActivityIndicator, StyleSheet, View} from 'react-native'
|
|
||||||
import {AppBskyFeedGetLikes as GetLikes} from '@atproto/api'
|
import {AppBskyFeedGetLikes as GetLikes} from '@atproto/api'
|
||||||
import {CenteredView} from '../util/Views'
|
import {msg} from '@lingui/macro'
|
||||||
import {List} from '../util/List'
|
import {useLingui} from '@lingui/react'
|
||||||
import {ErrorMessage} from '../util/error/ErrorMessage'
|
|
||||||
import {ProfileCardWithFollowBtn} from '../profile/ProfileCard'
|
|
||||||
import {logger} from '#/logger'
|
|
||||||
import {LoadingScreen} from '../util/LoadingScreen'
|
|
||||||
import {useResolveUriQuery} from '#/state/queries/resolve-uri'
|
|
||||||
import {useLikedByQuery} from '#/state/queries/post-liked-by'
|
|
||||||
import {cleanError} from '#/lib/strings/errors'
|
import {cleanError} from '#/lib/strings/errors'
|
||||||
|
import {logger} from '#/logger'
|
||||||
|
import {useLikedByQuery} from '#/state/queries/post-liked-by'
|
||||||
|
import {useResolveUriQuery} from '#/state/queries/resolve-uri'
|
||||||
|
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
|
||||||
|
import {
|
||||||
|
ListFooter,
|
||||||
|
ListHeaderDesktop,
|
||||||
|
ListMaybePlaceholder,
|
||||||
|
} from '#/components/Lists'
|
||||||
|
import {ProfileCardWithFollowBtn} from '../profile/ProfileCard'
|
||||||
|
import {List} from '../util/List'
|
||||||
|
|
||||||
|
function renderItem({item}: {item: GetLikes.Like}) {
|
||||||
|
return <ProfileCardWithFollowBtn key={item.actor.did} profile={item.actor} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyExtractor(item: GetLikes.Like) {
|
||||||
|
return item.actor.did
|
||||||
|
}
|
||||||
|
|
||||||
export function PostLikedBy({uri}: {uri: string}) {
|
export function PostLikedBy({uri}: {uri: string}) {
|
||||||
|
const {_} = useLingui()
|
||||||
|
const initialNumToRender = useInitialNumToRender()
|
||||||
|
|
||||||
const [isPTRing, setIsPTRing] = useState(false)
|
const [isPTRing, setIsPTRing] = useState(false)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: resolvedUri,
|
data: resolvedUri,
|
||||||
error: resolveError,
|
error: resolveError,
|
||||||
isFetching: isFetchingResolvedUri,
|
isLoading: isLoadingUri,
|
||||||
} = useResolveUriQuery(uri)
|
} = useResolveUriQuery(uri)
|
||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
isFetching,
|
isLoading: isLoadingLikes,
|
||||||
isFetched,
|
|
||||||
isFetchingNextPage,
|
isFetchingNextPage,
|
||||||
hasNextPage,
|
hasNextPage,
|
||||||
fetchNextPage,
|
fetchNextPage,
|
||||||
isError,
|
|
||||||
error,
|
error,
|
||||||
refetch,
|
refetch,
|
||||||
} = useLikedByQuery(resolvedUri?.uri)
|
} = useLikedByQuery(resolvedUri?.uri)
|
||||||
|
|
||||||
|
const isError = Boolean(resolveError || error)
|
||||||
|
|
||||||
const likes = useMemo(() => {
|
const likes = useMemo(() => {
|
||||||
if (data?.pages) {
|
if (data?.pages) {
|
||||||
return data.pages.flatMap(page => page.likes)
|
return data.pages.flatMap(page => page.likes)
|
||||||
}
|
}
|
||||||
|
return []
|
||||||
}, [data])
|
}, [data])
|
||||||
|
|
||||||
const onRefresh = useCallback(async () => {
|
const onRefresh = useCallback(async () => {
|
||||||
@@ -46,64 +65,44 @@ export function PostLikedBy({uri}: {uri: string}) {
|
|||||||
}, [refetch, setIsPTRing])
|
}, [refetch, setIsPTRing])
|
||||||
|
|
||||||
const onEndReached = useCallback(async () => {
|
const onEndReached = useCallback(async () => {
|
||||||
if (isFetching || !hasNextPage || isError) return
|
if (isFetchingNextPage || !hasNextPage || isError) return
|
||||||
try {
|
try {
|
||||||
await fetchNextPage()
|
await fetchNextPage()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Failed to load more likes', {message: err})
|
logger.error('Failed to load more likes', {message: err})
|
||||||
}
|
}
|
||||||
}, [isFetching, hasNextPage, isError, fetchNextPage])
|
}, [isFetchingNextPage, hasNextPage, isError, fetchNextPage])
|
||||||
|
|
||||||
const renderItem = useCallback(({item}: {item: GetLikes.Like}) => {
|
if (likes.length < 1) {
|
||||||
return (
|
return (
|
||||||
<ProfileCardWithFollowBtn key={item.actor.did} profile={item.actor} />
|
<ListMaybePlaceholder
|
||||||
)
|
isLoading={isLoadingUri || isLoadingLikes}
|
||||||
}, [])
|
isError={isError}
|
||||||
|
/>
|
||||||
if (isFetchingResolvedUri || !isFetched) {
|
|
||||||
return <LoadingScreen />
|
|
||||||
}
|
|
||||||
|
|
||||||
// error
|
|
||||||
// =
|
|
||||||
if (resolveError || isError) {
|
|
||||||
return (
|
|
||||||
<CenteredView>
|
|
||||||
<ErrorMessage
|
|
||||||
message={cleanError(resolveError || error)}
|
|
||||||
onPressTryAgain={onRefresh}
|
|
||||||
/>
|
|
||||||
</CenteredView>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// loaded
|
|
||||||
// =
|
|
||||||
return (
|
return (
|
||||||
<List
|
<List
|
||||||
data={likes}
|
data={likes}
|
||||||
keyExtractor={item => item.actor.did}
|
renderItem={renderItem}
|
||||||
|
keyExtractor={keyExtractor}
|
||||||
refreshing={isPTRing}
|
refreshing={isPTRing}
|
||||||
onRefresh={onRefresh}
|
onRefresh={onRefresh}
|
||||||
onEndReached={onEndReached}
|
onEndReached={onEndReached}
|
||||||
renderItem={renderItem}
|
onEndReachedThreshold={4}
|
||||||
initialNumToRender={15}
|
ListHeaderComponent={<ListHeaderDesktop title={_(msg`Liked By`)} />}
|
||||||
// FIXME(dan)
|
ListFooterComponent={
|
||||||
// eslint-disable-next-line react/no-unstable-nested-components
|
<ListFooter
|
||||||
ListFooterComponent={() => (
|
isFetchingNextPage={isFetchingNextPage}
|
||||||
<View style={styles.footer}>
|
error={cleanError(error)}
|
||||||
{(isFetching || isFetchingNextPage) && <ActivityIndicator />}
|
onRetry={fetchNextPage}
|
||||||
</View>
|
/>
|
||||||
)}
|
}
|
||||||
// @ts-ignore our .web version only -prf
|
// @ts-ignore our .web version only -prf
|
||||||
desktopFixedHeight
|
desktopFixedHeight
|
||||||
|
initialNumToRender={initialNumToRender}
|
||||||
|
windowSize={11}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
footer: {
|
|
||||||
height: 200,
|
|
||||||
paddingTop: 20,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -1,38 +1,57 @@
|
|||||||
import React, {useMemo, useCallback, useState} from 'react'
|
import React, {useCallback, useMemo, useState} from 'react'
|
||||||
import {ActivityIndicator, StyleSheet, View} from 'react-native'
|
|
||||||
import {AppBskyActorDefs as ActorDefs} from '@atproto/api'
|
import {AppBskyActorDefs as ActorDefs} from '@atproto/api'
|
||||||
import {CenteredView} from '../util/Views'
|
import {msg} from '@lingui/macro'
|
||||||
import {List} from '../util/List'
|
import {useLingui} from '@lingui/react'
|
||||||
import {ProfileCardWithFollowBtn} from '../profile/ProfileCard'
|
|
||||||
import {ErrorMessage} from '../util/error/ErrorMessage'
|
|
||||||
import {logger} from '#/logger'
|
|
||||||
import {LoadingScreen} from '../util/LoadingScreen'
|
|
||||||
import {useResolveUriQuery} from '#/state/queries/resolve-uri'
|
|
||||||
import {usePostRepostedByQuery} from '#/state/queries/post-reposted-by'
|
|
||||||
import {cleanError} from '#/lib/strings/errors'
|
import {cleanError} from '#/lib/strings/errors'
|
||||||
|
import {logger} from '#/logger'
|
||||||
|
import {usePostRepostedByQuery} from '#/state/queries/post-reposted-by'
|
||||||
|
import {useResolveUriQuery} from '#/state/queries/resolve-uri'
|
||||||
|
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
|
||||||
|
import {
|
||||||
|
ListFooter,
|
||||||
|
ListHeaderDesktop,
|
||||||
|
ListMaybePlaceholder,
|
||||||
|
} from '#/components/Lists'
|
||||||
|
import {ProfileCardWithFollowBtn} from '../profile/ProfileCard'
|
||||||
|
import {List} from '../util/List'
|
||||||
|
|
||||||
|
function renderItem({item}: {item: ActorDefs.ProfileViewBasic}) {
|
||||||
|
return <ProfileCardWithFollowBtn key={item.did} profile={item} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyExtractor(item: ActorDefs.ProfileViewBasic) {
|
||||||
|
return item.did
|
||||||
|
}
|
||||||
|
|
||||||
export function PostRepostedBy({uri}: {uri: string}) {
|
export function PostRepostedBy({uri}: {uri: string}) {
|
||||||
|
const {_} = useLingui()
|
||||||
|
const initialNumToRender = useInitialNumToRender()
|
||||||
|
|
||||||
const [isPTRing, setIsPTRing] = useState(false)
|
const [isPTRing, setIsPTRing] = useState(false)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: resolvedUri,
|
data: resolvedUri,
|
||||||
error: resolveError,
|
error: resolveError,
|
||||||
isFetching: isFetchingResolvedUri,
|
isLoading: isLoadingUri,
|
||||||
} = useResolveUriQuery(uri)
|
} = useResolveUriQuery(uri)
|
||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
isFetching,
|
isLoading: isLoadingRepostedBy,
|
||||||
isFetched,
|
|
||||||
isFetchingNextPage,
|
isFetchingNextPage,
|
||||||
hasNextPage,
|
hasNextPage,
|
||||||
fetchNextPage,
|
fetchNextPage,
|
||||||
isError,
|
|
||||||
error,
|
error,
|
||||||
refetch,
|
refetch,
|
||||||
} = usePostRepostedByQuery(resolvedUri?.uri)
|
} = usePostRepostedByQuery(resolvedUri?.uri)
|
||||||
|
|
||||||
|
const isError = Boolean(resolveError || error)
|
||||||
|
|
||||||
const repostedBy = useMemo(() => {
|
const repostedBy = useMemo(() => {
|
||||||
if (data?.pages) {
|
if (data?.pages) {
|
||||||
return data.pages.flatMap(page => page.repostedBy)
|
return data.pages.flatMap(page => page.repostedBy)
|
||||||
}
|
}
|
||||||
|
return []
|
||||||
}, [data])
|
}, [data])
|
||||||
|
|
||||||
const onRefresh = useCallback(async () => {
|
const onRefresh = useCallback(async () => {
|
||||||
@@ -46,35 +65,20 @@ export function PostRepostedBy({uri}: {uri: string}) {
|
|||||||
}, [refetch, setIsPTRing])
|
}, [refetch, setIsPTRing])
|
||||||
|
|
||||||
const onEndReached = useCallback(async () => {
|
const onEndReached = useCallback(async () => {
|
||||||
if (isFetching || !hasNextPage || isError) return
|
if (isFetchingNextPage || !hasNextPage || isError) return
|
||||||
try {
|
try {
|
||||||
await fetchNextPage()
|
await fetchNextPage()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Failed to load more reposts', {message: err})
|
logger.error('Failed to load more reposts', {message: err})
|
||||||
}
|
}
|
||||||
}, [isFetching, hasNextPage, isError, fetchNextPage])
|
}, [isFetchingNextPage, hasNextPage, isError, fetchNextPage])
|
||||||
|
|
||||||
const renderItem = useCallback(
|
if (repostedBy.length < 1) {
|
||||||
({item}: {item: ActorDefs.ProfileViewBasic}) => {
|
|
||||||
return <ProfileCardWithFollowBtn key={item.did} profile={item} />
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
|
|
||||||
if (isFetchingResolvedUri || !isFetched) {
|
|
||||||
return <LoadingScreen />
|
|
||||||
}
|
|
||||||
|
|
||||||
// error
|
|
||||||
// =
|
|
||||||
if (resolveError || isError) {
|
|
||||||
return (
|
return (
|
||||||
<CenteredView>
|
<ListMaybePlaceholder
|
||||||
<ErrorMessage
|
isLoading={isLoadingUri || isLoadingRepostedBy}
|
||||||
message={cleanError(resolveError || error)}
|
isError={isError}
|
||||||
onPressTryAgain={onRefresh}
|
/>
|
||||||
/>
|
|
||||||
</CenteredView>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,28 +87,24 @@ export function PostRepostedBy({uri}: {uri: string}) {
|
|||||||
return (
|
return (
|
||||||
<List
|
<List
|
||||||
data={repostedBy}
|
data={repostedBy}
|
||||||
keyExtractor={item => item.did}
|
renderItem={renderItem}
|
||||||
|
keyExtractor={keyExtractor}
|
||||||
refreshing={isPTRing}
|
refreshing={isPTRing}
|
||||||
onRefresh={onRefresh}
|
onRefresh={onRefresh}
|
||||||
onEndReached={onEndReached}
|
onEndReached={onEndReached}
|
||||||
renderItem={renderItem}
|
onEndReachedThreshold={4}
|
||||||
initialNumToRender={15}
|
ListHeaderComponent={<ListHeaderDesktop title={_(msg`Reposted By`)} />}
|
||||||
// FIXME(dan)
|
ListFooterComponent={
|
||||||
// eslint-disable-next-line react/no-unstable-nested-components
|
<ListFooter
|
||||||
ListFooterComponent={() => (
|
isFetchingNextPage={isFetchingNextPage}
|
||||||
<View style={styles.footer}>
|
error={cleanError(error)}
|
||||||
{(isFetching || isFetchingNextPage) && <ActivityIndicator />}
|
onRetry={fetchNextPage}
|
||||||
</View>
|
/>
|
||||||
)}
|
}
|
||||||
// @ts-ignore our .web version only -prf
|
// @ts-ignore our .web version only -prf
|
||||||
desktopFixedHeight
|
desktopFixedHeight
|
||||||
|
initialNumToRender={initialNumToRender}
|
||||||
|
windowSize={11}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
footer: {
|
|
||||||
height: 200,
|
|
||||||
paddingTop: 20,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ let FeedSlice = ({
|
|||||||
hideTopBorder?: boolean
|
hideTopBorder?: boolean
|
||||||
}): React.ReactNode => {
|
}): React.ReactNode => {
|
||||||
if (slice.isThread && slice.items.length > 3) {
|
if (slice.isThread && slice.items.length > 3) {
|
||||||
|
const beforeLast = slice.items.length - 2
|
||||||
const last = slice.items.length - 1
|
const last = slice.items.length - 1
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -36,20 +37,20 @@ let FeedSlice = ({
|
|||||||
hideTopBorder={hideTopBorder}
|
hideTopBorder={hideTopBorder}
|
||||||
isParentBlocked={slice.items[0].isParentBlocked}
|
isParentBlocked={slice.items[0].isParentBlocked}
|
||||||
/>
|
/>
|
||||||
<FeedItem
|
|
||||||
key={slice.items[1]._reactKey}
|
|
||||||
post={slice.items[1].post}
|
|
||||||
record={slice.items[1].record}
|
|
||||||
reason={slice.items[1].reason}
|
|
||||||
feedContext={slice.items[1].feedContext}
|
|
||||||
parentAuthor={slice.items[1].parentAuthor}
|
|
||||||
showReplyTo={false}
|
|
||||||
moderation={slice.items[1].moderation}
|
|
||||||
isThreadParent={isThreadParentAt(slice.items, 1)}
|
|
||||||
isThreadChild={isThreadChildAt(slice.items, 1)}
|
|
||||||
isParentBlocked={slice.items[1].isParentBlocked}
|
|
||||||
/>
|
|
||||||
<ViewFullThread slice={slice} />
|
<ViewFullThread slice={slice} />
|
||||||
|
<FeedItem
|
||||||
|
key={slice.items[beforeLast]._reactKey}
|
||||||
|
post={slice.items[beforeLast].post}
|
||||||
|
record={slice.items[beforeLast].record}
|
||||||
|
reason={slice.items[beforeLast].reason}
|
||||||
|
feedContext={slice.items[beforeLast].feedContext}
|
||||||
|
parentAuthor={slice.items[beforeLast].parentAuthor}
|
||||||
|
showReplyTo={false}
|
||||||
|
moderation={slice.items[beforeLast].moderation}
|
||||||
|
isThreadParent={isThreadParentAt(slice.items, beforeLast)}
|
||||||
|
isThreadChild={isThreadChildAt(slice.items, beforeLast)}
|
||||||
|
isParentBlocked={slice.items[beforeLast].isParentBlocked}
|
||||||
|
/>
|
||||||
<FeedItem
|
<FeedItem
|
||||||
key={slice.items[last]._reactKey}
|
key={slice.items[last]._reactKey}
|
||||||
post={slice.items[last].post}
|
post={slice.items[last].post}
|
||||||
|
|||||||
@@ -344,10 +344,11 @@ function ListImpl<ItemT>(
|
|||||||
style={[styles.aboveTheFoldDetector, {height: headerOffset}]}
|
style={[styles.aboveTheFoldDetector, {height: headerOffset}]}
|
||||||
/>
|
/>
|
||||||
{onStartReached && !isEmpty && (
|
{onStartReached && !isEmpty && (
|
||||||
<Visibility
|
<EdgeVisibility
|
||||||
root={disableFullWindowScroll ? nativeRef : null}
|
root={disableFullWindowScroll ? nativeRef : null}
|
||||||
onVisibleChange={onHeadVisibilityChange}
|
onVisibleChange={onHeadVisibilityChange}
|
||||||
topMargin={(onStartReachedThreshold ?? 0) * 100 + '%'}
|
topMargin={(onStartReachedThreshold ?? 0) * 100 + '%'}
|
||||||
|
containerRef={containerRef}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{headerComponent}
|
{headerComponent}
|
||||||
@@ -368,11 +369,11 @@ function ListImpl<ItemT>(
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
{onEndReached && !isEmpty && (
|
{onEndReached && !isEmpty && (
|
||||||
<Visibility
|
<EdgeVisibility
|
||||||
root={disableFullWindowScroll ? nativeRef : null}
|
root={disableFullWindowScroll ? nativeRef : null}
|
||||||
onVisibleChange={onTailVisibilityChange}
|
onVisibleChange={onTailVisibilityChange}
|
||||||
bottomMargin={(onEndReachedThreshold ?? 0) * 100 + '%'}
|
bottomMargin={(onEndReachedThreshold ?? 0) * 100 + '%'}
|
||||||
key={data?.length}
|
containerRef={containerRef}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{footerComponent}
|
{footerComponent}
|
||||||
@@ -381,6 +382,34 @@ function ListImpl<ItemT>(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function EdgeVisibility({
|
||||||
|
root,
|
||||||
|
topMargin,
|
||||||
|
bottomMargin,
|
||||||
|
containerRef,
|
||||||
|
onVisibleChange,
|
||||||
|
}: {
|
||||||
|
root?: React.RefObject<HTMLDivElement> | null
|
||||||
|
topMargin?: string
|
||||||
|
bottomMargin?: string
|
||||||
|
containerRef: React.RefObject<Element>
|
||||||
|
onVisibleChange: (isVisible: boolean) => void
|
||||||
|
}) {
|
||||||
|
const [containerHeight, setContainerHeight] = React.useState(0)
|
||||||
|
useResizeObserver(containerRef, (w, h) => {
|
||||||
|
setContainerHeight(h)
|
||||||
|
})
|
||||||
|
return (
|
||||||
|
<Visibility
|
||||||
|
key={containerHeight}
|
||||||
|
root={root}
|
||||||
|
topMargin={topMargin}
|
||||||
|
bottomMargin={bottomMargin}
|
||||||
|
onVisibleChange={onVisibleChange}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function useResizeObserver(
|
function useResizeObserver(
|
||||||
ref: React.RefObject<Element>,
|
ref: React.RefObject<Element>,
|
||||||
onResize: undefined | ((w: number, h: number) => void),
|
onResize: undefined | ((w: number, h: number) => void),
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ import {faListUl} from '@fortawesome/free-solid-svg-icons/faListUl'
|
|||||||
import {faLock} from '@fortawesome/free-solid-svg-icons/faLock'
|
import {faLock} from '@fortawesome/free-solid-svg-icons/faLock'
|
||||||
import {faMagnifyingGlass} from '@fortawesome/free-solid-svg-icons/faMagnifyingGlass'
|
import {faMagnifyingGlass} from '@fortawesome/free-solid-svg-icons/faMagnifyingGlass'
|
||||||
import {faNoteSticky} from '@fortawesome/free-solid-svg-icons/faNoteSticky'
|
import {faNoteSticky} from '@fortawesome/free-solid-svg-icons/faNoteSticky'
|
||||||
|
import {faPaintRoller} from '@fortawesome/free-solid-svg-icons/faPaintRoller'
|
||||||
import {faPause} from '@fortawesome/free-solid-svg-icons/faPause'
|
import {faPause} from '@fortawesome/free-solid-svg-icons/faPause'
|
||||||
import {faPen} from '@fortawesome/free-solid-svg-icons/faPen'
|
import {faPen} from '@fortawesome/free-solid-svg-icons/faPen'
|
||||||
import {faPenNib} from '@fortawesome/free-solid-svg-icons/faPenNib'
|
import {faPenNib} from '@fortawesome/free-solid-svg-icons/faPenNib'
|
||||||
@@ -178,6 +179,7 @@ library.add(
|
|||||||
faMagnifyingGlass,
|
faMagnifyingGlass,
|
||||||
faMessage,
|
faMessage,
|
||||||
faNoteSticky,
|
faNoteSticky,
|
||||||
|
faPaintRoller,
|
||||||
faPaste,
|
faPaste,
|
||||||
faPause,
|
faPause,
|
||||||
faPen,
|
faPen,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {ToggleButton} from '#/view/com/util/forms/ToggleButton'
|
|||||||
import {SimpleViewHeader} from '#/view/com/util/SimpleViewHeader'
|
import {SimpleViewHeader} from '#/view/com/util/SimpleViewHeader'
|
||||||
import {Text} from '#/view/com/util/text/Text'
|
import {Text} from '#/view/com/util/text/Text'
|
||||||
import {ScrollView} from '#/view/com/util/Views'
|
import {ScrollView} from '#/view/com/util/Views'
|
||||||
|
import {atoms as a} from '#/alf'
|
||||||
|
|
||||||
type Props = NativeStackScreenProps<
|
type Props = NativeStackScreenProps<
|
||||||
CommonNavigatorParams,
|
CommonNavigatorParams,
|
||||||
@@ -61,10 +62,13 @@ export function AccessibilitySettingsScreen({}: Props) {
|
|||||||
showBackButton={isTabletOrMobile}
|
showBackButton={isTabletOrMobile}
|
||||||
style={[
|
style={[
|
||||||
pal.border,
|
pal.border,
|
||||||
{borderBottomWidth: 1},
|
a.border_b,
|
||||||
!isMobile && {borderLeftWidth: 1, borderRightWidth: 1},
|
!isMobile && {
|
||||||
|
borderLeftWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderRightWidth: StyleSheet.hairlineWidth,
|
||||||
|
},
|
||||||
]}>
|
]}>
|
||||||
<View style={{flex: 1}}>
|
<View style={a.flex_1}>
|
||||||
<Text type="title-lg" style={[pal.text, {fontWeight: 'bold'}]}>
|
<Text type="title-lg" style={[pal.text, {fontWeight: 'bold'}]}>
|
||||||
<Trans>Accessibility Settings</Trans>
|
<Trans>Accessibility Settings</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import {useFocusEffect} from '@react-navigation/native'
|
|
||||||
import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
|
|
||||||
import {ViewHeader} from '../com/util/ViewHeader'
|
|
||||||
import {PostLikedBy as PostLikedByComponent} from '../com/post-thread/PostLikedBy'
|
|
||||||
import {makeRecordUri} from 'lib/strings/url-helpers'
|
|
||||||
import {useSetMinimalShellMode} from '#/state/shell'
|
|
||||||
import {msg} from '@lingui/macro'
|
import {msg} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
import {useFocusEffect} from '@react-navigation/native'
|
||||||
|
|
||||||
|
import {useSetMinimalShellMode} from '#/state/shell'
|
||||||
|
import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types'
|
||||||
|
import {makeRecordUri} from 'lib/strings/url-helpers'
|
||||||
|
import {PostLikedBy as PostLikedByComponent} from '../com/post-thread/PostLikedBy'
|
||||||
|
import {ViewHeader} from '../com/util/ViewHeader'
|
||||||
|
|
||||||
type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostLikedBy'>
|
type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostLikedBy'>
|
||||||
export const PostLikedByScreen = ({route}: Props) => {
|
export const PostLikedByScreen = ({route}: Props) => {
|
||||||
@@ -23,7 +24,7 @@ export const PostLikedByScreen = ({route}: Props) => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<View style={{flex: 1}}>
|
||||||
<ViewHeader title={_(msg`Liked By`)} />
|
<ViewHeader title={_(msg`Liked By`)} />
|
||||||
<PostLikedByComponent uri={uri} />
|
<PostLikedByComponent uri={uri} />
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import {useFocusEffect} from '@react-navigation/native'
|
|
||||||
import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
|
|
||||||
import {ViewHeader} from '../com/util/ViewHeader'
|
|
||||||
import {PostRepostedBy as PostRepostedByComponent} from '../com/post-thread/PostRepostedBy'
|
|
||||||
import {makeRecordUri} from 'lib/strings/url-helpers'
|
|
||||||
import {useSetMinimalShellMode} from '#/state/shell'
|
|
||||||
import {useLingui} from '@lingui/react'
|
|
||||||
import {msg} from '@lingui/macro'
|
import {msg} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
import {useFocusEffect} from '@react-navigation/native'
|
||||||
|
|
||||||
|
import {useSetMinimalShellMode} from '#/state/shell'
|
||||||
|
import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types'
|
||||||
|
import {makeRecordUri} from 'lib/strings/url-helpers'
|
||||||
|
import {PostRepostedBy as PostRepostedByComponent} from '../com/post-thread/PostRepostedBy'
|
||||||
|
import {ViewHeader} from '../com/util/ViewHeader'
|
||||||
|
|
||||||
type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostRepostedBy'>
|
type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostRepostedBy'>
|
||||||
export const PostRepostedByScreen = ({route}: Props) => {
|
export const PostRepostedByScreen = ({route}: Props) => {
|
||||||
@@ -23,7 +24,7 @@ export const PostRepostedByScreen = ({route}: Props) => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<View style={{flex: 1}}>
|
||||||
<ViewHeader title={_(msg`Reposted By`)} />
|
<ViewHeader title={_(msg`Reposted By`)} />
|
||||||
<PostRepostedByComponent uri={uri} />
|
<PostRepostedByComponent uri={uri} />
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
useSetExternalEmbedPref,
|
useSetExternalEmbedPref,
|
||||||
} from 'state/preferences'
|
} from 'state/preferences'
|
||||||
import {ToggleButton} from 'view/com/util/forms/ToggleButton'
|
import {ToggleButton} from 'view/com/util/forms/ToggleButton'
|
||||||
|
import {atoms as a} from '#/alf'
|
||||||
import {SimpleViewHeader} from '../com/util/SimpleViewHeader'
|
import {SimpleViewHeader} from '../com/util/SimpleViewHeader'
|
||||||
import {Text} from '../com/util/text/Text'
|
import {Text} from '../com/util/text/Text'
|
||||||
import {ScrollView} from '../com/util/Views'
|
import {ScrollView} from '../com/util/Views'
|
||||||
@@ -47,8 +48,8 @@ export function PreferencesExternalEmbeds({}: Props) {
|
|||||||
contentContainerStyle={[pal.viewLight, {paddingBottom: 75}]}>
|
contentContainerStyle={[pal.viewLight, {paddingBottom: 75}]}>
|
||||||
<SimpleViewHeader
|
<SimpleViewHeader
|
||||||
showBackButton={isTabletOrMobile}
|
showBackButton={isTabletOrMobile}
|
||||||
style={[pal.border, {borderBottomWidth: 1}]}>
|
style={[pal.border, a.border_b]}>
|
||||||
<View style={{flex: 1}}>
|
<View style={a.flex_1}>
|
||||||
<Text type="title-lg" style={[pal.text, {fontWeight: 'bold'}]}>
|
<Text type="title-lg" style={[pal.text, {fontWeight: 'bold'}]}>
|
||||||
<Trans>External Media Preferences</Trans>
|
<Trans>External Media Preferences</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
import React, {useState} from 'react'
|
import React from 'react'
|
||||||
import {StyleSheet, View} from 'react-native'
|
import {StyleSheet, View} from 'react-native'
|
||||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||||
import {msg, Plural, Trans} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {Slider} from '@miblanchard/react-native-slider'
|
|
||||||
import debounce from 'lodash.debounce'
|
|
||||||
|
|
||||||
import {usePalette} from '#/lib/hooks/usePalette'
|
import {usePalette} from '#/lib/hooks/usePalette'
|
||||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||||
import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
|
import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
|
||||||
import {colors, s} from '#/lib/styles'
|
import {colors, s} from '#/lib/styles'
|
||||||
import {isWeb} from '#/platform/detection'
|
|
||||||
import {
|
import {
|
||||||
usePreferencesQuery,
|
usePreferencesQuery,
|
||||||
useSetFeedViewPreferencesMutation,
|
useSetFeedViewPreferencesMutation,
|
||||||
@@ -19,61 +16,7 @@ import {ToggleButton} from '#/view/com/util/forms/ToggleButton'
|
|||||||
import {SimpleViewHeader} from '#/view/com/util/SimpleViewHeader'
|
import {SimpleViewHeader} from '#/view/com/util/SimpleViewHeader'
|
||||||
import {Text} from '#/view/com/util/text/Text'
|
import {Text} from '#/view/com/util/text/Text'
|
||||||
import {ScrollView} from '#/view/com/util/Views'
|
import {ScrollView} from '#/view/com/util/Views'
|
||||||
|
import {atoms as a} from '#/alf'
|
||||||
function RepliesThresholdInput({
|
|
||||||
enabled,
|
|
||||||
initialValue,
|
|
||||||
}: {
|
|
||||||
enabled: boolean
|
|
||||||
initialValue: number
|
|
||||||
}) {
|
|
||||||
const pal = usePalette('default')
|
|
||||||
const [value, setValue] = useState(initialValue)
|
|
||||||
const {mutate: setFeedViewPref} = useSetFeedViewPreferencesMutation()
|
|
||||||
const preValue = React.useRef(initialValue)
|
|
||||||
const save = React.useMemo(
|
|
||||||
() =>
|
|
||||||
debounce(
|
|
||||||
threshold =>
|
|
||||||
setFeedViewPref({
|
|
||||||
hideRepliesByLikeCount: threshold,
|
|
||||||
}),
|
|
||||||
500,
|
|
||||||
), // debouce for 500ms
|
|
||||||
[setFeedViewPref],
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={[!enabled && styles.dimmed]}>
|
|
||||||
<Slider
|
|
||||||
value={value}
|
|
||||||
onValueChange={(v: number | number[]) => {
|
|
||||||
let threshold = Array.isArray(v) ? v[0] : v
|
|
||||||
if (threshold > preValue.current) threshold = Math.floor(threshold)
|
|
||||||
else threshold = Math.ceil(threshold)
|
|
||||||
|
|
||||||
preValue.current = threshold
|
|
||||||
|
|
||||||
setValue(threshold)
|
|
||||||
save(threshold)
|
|
||||||
}}
|
|
||||||
minimumValue={0}
|
|
||||||
maximumValue={25}
|
|
||||||
containerStyle={isWeb ? undefined : s.flex1}
|
|
||||||
disabled={!enabled}
|
|
||||||
thumbTintColor={colors.blue3}
|
|
||||||
/>
|
|
||||||
<Text type="xs" style={pal.text}>
|
|
||||||
<Plural
|
|
||||||
value={value}
|
|
||||||
_0="Show all replies"
|
|
||||||
one="Show replies with at least # like"
|
|
||||||
other="Show replies with at least # likes"
|
|
||||||
/>
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
type Props = NativeStackScreenProps<
|
type Props = NativeStackScreenProps<
|
||||||
CommonNavigatorParams,
|
CommonNavigatorParams,
|
||||||
@@ -99,8 +42,8 @@ export function PreferencesFollowingFeed({}: Props) {
|
|||||||
contentContainerStyle={{paddingBottom: 75}}>
|
contentContainerStyle={{paddingBottom: 75}}>
|
||||||
<SimpleViewHeader
|
<SimpleViewHeader
|
||||||
showBackButton={isTabletOrMobile}
|
showBackButton={isTabletOrMobile}
|
||||||
style={[pal.border, {borderBottomWidth: 1}]}>
|
style={[pal.border, a.border_b]}>
|
||||||
<View style={{flex: 1}}>
|
<View style={a.flex_1}>
|
||||||
<Text type="title-lg" style={[pal.text, {fontWeight: 'bold'}]}>
|
<Text type="title-lg" style={[pal.text, {fontWeight: 'bold'}]}>
|
||||||
<Trans>Following Feed Preferences</Trans>
|
<Trans>Following Feed Preferences</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
@@ -136,51 +79,6 @@ export function PreferencesFollowingFeed({}: Props) {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<View
|
|
||||||
style={[pal.viewLight, styles.card, !showReplies && styles.dimmed]}>
|
|
||||||
<Text type="title-sm" style={[pal.text, s.pb5]}>
|
|
||||||
<Trans>Reply Filters</Trans>
|
|
||||||
</Text>
|
|
||||||
<Text style={[pal.text, s.pb10]}>
|
|
||||||
<Trans>
|
|
||||||
Enable this setting to only see replies between people you
|
|
||||||
follow.
|
|
||||||
</Trans>
|
|
||||||
</Text>
|
|
||||||
<ToggleButton
|
|
||||||
type="default-light"
|
|
||||||
label={_(msg`Followed users only`)}
|
|
||||||
isSelected={Boolean(
|
|
||||||
variables?.hideRepliesByUnfollowed ??
|
|
||||||
preferences?.feedViewPrefs?.hideRepliesByUnfollowed,
|
|
||||||
)}
|
|
||||||
onPress={
|
|
||||||
showReplies
|
|
||||||
? () =>
|
|
||||||
setFeedViewPref({
|
|
||||||
hideRepliesByUnfollowed: !(
|
|
||||||
variables?.hideRepliesByUnfollowed ??
|
|
||||||
preferences?.feedViewPrefs?.hideRepliesByUnfollowed
|
|
||||||
),
|
|
||||||
})
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
style={[s.mb10]}
|
|
||||||
/>
|
|
||||||
<Text style={[pal.text]}>
|
|
||||||
<Trans>
|
|
||||||
Adjust the number of likes a reply must have to be shown in your
|
|
||||||
feed.
|
|
||||||
</Trans>
|
|
||||||
</Text>
|
|
||||||
{preferences && (
|
|
||||||
<RepliesThresholdInput
|
|
||||||
enabled={showReplies}
|
|
||||||
initialValue={preferences.feedViewPrefs.hideRepliesByLikeCount}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={[pal.viewLight, styles.card]}>
|
<View style={[pal.viewLight, styles.card]}>
|
||||||
<Text type="title-sm" style={[pal.text, s.pb5]}>
|
<Text type="title-sm" style={[pal.text, s.pb5]}>
|
||||||
<Trans>Show Reposts</Trans>
|
<Trans>Show Reposts</Trans>
|
||||||
|
|||||||
@@ -45,8 +45,8 @@ export function PreferencesThreads({}: Props) {
|
|||||||
contentContainerStyle={{paddingBottom: 75}}>
|
contentContainerStyle={{paddingBottom: 75}}>
|
||||||
<SimpleViewHeader
|
<SimpleViewHeader
|
||||||
showBackButton={isTabletOrMobile}
|
showBackButton={isTabletOrMobile}
|
||||||
style={[pal.border, {borderBottomWidth: 1}]}>
|
style={[pal.border, a.border_b]}>
|
||||||
<View style={{flex: 1}}>
|
<View style={a.flex_1}>
|
||||||
<Text type="title-lg" style={[pal.text, {fontWeight: 'bold'}]}>
|
<Text type="title-lg" style={[pal.text, {fontWeight: 'bold'}]}>
|
||||||
<Trans>Thread Preferences</Trans>
|
<Trans>Thread Preferences</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import {useFocusEffect} from '@react-navigation/native'
|
|
||||||
import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
|
|
||||||
import {ViewHeader} from '../com/util/ViewHeader'
|
|
||||||
import {PostLikedBy as PostLikedByComponent} from '../com/post-thread/PostLikedBy'
|
|
||||||
import {makeRecordUri} from 'lib/strings/url-helpers'
|
|
||||||
import {useSetMinimalShellMode} from '#/state/shell'
|
|
||||||
import {useLingui} from '@lingui/react'
|
|
||||||
import {msg} from '@lingui/macro'
|
import {msg} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
import {useFocusEffect} from '@react-navigation/native'
|
||||||
|
|
||||||
|
import {useSetMinimalShellMode} from '#/state/shell'
|
||||||
|
import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types'
|
||||||
|
import {makeRecordUri} from 'lib/strings/url-helpers'
|
||||||
|
import {PostLikedBy as PostLikedByComponent} from '../com/post-thread/PostLikedBy'
|
||||||
|
import {ViewHeader} from '../com/util/ViewHeader'
|
||||||
|
|
||||||
type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileFeedLikedBy'>
|
type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileFeedLikedBy'>
|
||||||
export const ProfileFeedLikedByScreen = ({route}: Props) => {
|
export const ProfileFeedLikedByScreen = ({route}: Props) => {
|
||||||
@@ -23,7 +24,7 @@ export const ProfileFeedLikedByScreen = ({route}: Props) => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<View style={{flex: 1}}>
|
||||||
<ViewHeader title={_(msg`Liked By`)} />
|
<ViewHeader title={_(msg`Liked By`)} />
|
||||||
<PostLikedByComponent uri={uri} />
|
<PostLikedByComponent uri={uri} />
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -31,12 +31,7 @@ import {useClearPreferencesMutation} from '#/state/queries/preferences'
|
|||||||
import {RQKEY as RQKEY_PROFILE} from '#/state/queries/profile'
|
import {RQKEY as RQKEY_PROFILE} from '#/state/queries/profile'
|
||||||
import {useProfileQuery} from '#/state/queries/profile'
|
import {useProfileQuery} from '#/state/queries/profile'
|
||||||
import {SessionAccount, useSession, useSessionApi} from '#/state/session'
|
import {SessionAccount, useSession, useSessionApi} from '#/state/session'
|
||||||
import {
|
import {useOnboardingDispatch, useSetMinimalShellMode} from '#/state/shell'
|
||||||
useOnboardingDispatch,
|
|
||||||
useSetMinimalShellMode,
|
|
||||||
useSetThemePrefs,
|
|
||||||
useThemePrefs,
|
|
||||||
} from '#/state/shell'
|
|
||||||
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
|
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
|
||||||
import {useCloseAllActiveElements} from '#/state/util'
|
import {useCloseAllActiveElements} from '#/state/util'
|
||||||
import {useAnalytics} from 'lib/analytics/analytics'
|
import {useAnalytics} from 'lib/analytics/analytics'
|
||||||
@@ -52,7 +47,6 @@ import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types'
|
|||||||
import {NavigationProp} from 'lib/routes/types'
|
import {NavigationProp} from 'lib/routes/types'
|
||||||
import {colors, s} from 'lib/styles'
|
import {colors, s} from 'lib/styles'
|
||||||
import {AccountDropdownBtn} from 'view/com/util/AccountDropdownBtn'
|
import {AccountDropdownBtn} from 'view/com/util/AccountDropdownBtn'
|
||||||
import {SelectableBtn} from 'view/com/util/forms/SelectableBtn'
|
|
||||||
import {ToggleButton} from 'view/com/util/forms/ToggleButton'
|
import {ToggleButton} from 'view/com/util/forms/ToggleButton'
|
||||||
import {Link, TextLink} from 'view/com/util/Link'
|
import {Link, TextLink} from 'view/com/util/Link'
|
||||||
import {SimpleViewHeader} from 'view/com/util/SimpleViewHeader'
|
import {SimpleViewHeader} from 'view/com/util/SimpleViewHeader'
|
||||||
@@ -61,8 +55,7 @@ import * as Toast from 'view/com/util/Toast'
|
|||||||
import {UserAvatar} from 'view/com/util/UserAvatar'
|
import {UserAvatar} from 'view/com/util/UserAvatar'
|
||||||
import {ScrollView} from 'view/com/util/Views'
|
import {ScrollView} from 'view/com/util/Views'
|
||||||
import {DeactivateAccountDialog} from '#/screens/Settings/components/DeactivateAccountDialog'
|
import {DeactivateAccountDialog} from '#/screens/Settings/components/DeactivateAccountDialog'
|
||||||
import {useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {atoms as a} from '#/alf'
|
|
||||||
import {useDialogControl} from '#/components/Dialog'
|
import {useDialogControl} from '#/components/Dialog'
|
||||||
import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings'
|
import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings'
|
||||||
import {navigate, resetToTab} from '#/Navigation'
|
import {navigate, resetToTab} from '#/Navigation'
|
||||||
@@ -168,8 +161,6 @@ function SettingsAccountCard({
|
|||||||
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Settings'>
|
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Settings'>
|
||||||
export function SettingsScreen({}: Props) {
|
export function SettingsScreen({}: Props) {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const {colorMode, darkTheme} = useThemePrefs()
|
|
||||||
const {setColorMode, setDarkTheme} = useSetThemePrefs()
|
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const setMinimalShellMode = useSetMinimalShellMode()
|
const setMinimalShellMode = useSetMinimalShellMode()
|
||||||
@@ -296,6 +287,10 @@ export function SettingsScreen({}: Props) {
|
|||||||
navigation.navigate('AccessibilitySettings')
|
navigation.navigate('AccessibilitySettings')
|
||||||
}, [navigation])
|
}, [navigation])
|
||||||
|
|
||||||
|
const onPressAppearanceSettings = React.useCallback(() => {
|
||||||
|
navigation.navigate('AppearanceSettings')
|
||||||
|
}, [navigation])
|
||||||
|
|
||||||
const onPressBirthday = React.useCallback(() => {
|
const onPressBirthday = React.useCallback(() => {
|
||||||
birthdayControl.open()
|
birthdayControl.open()
|
||||||
}, [birthdayControl])
|
}, [birthdayControl])
|
||||||
@@ -436,63 +431,6 @@ export function SettingsScreen({}: Props) {
|
|||||||
|
|
||||||
<View style={styles.spacer20} />
|
<View style={styles.spacer20} />
|
||||||
|
|
||||||
<Text type="xl-bold" style={[pal.text, styles.heading]}>
|
|
||||||
<Trans>Appearance</Trans>
|
|
||||||
</Text>
|
|
||||||
<View>
|
|
||||||
<View style={[styles.linkCard, pal.view, styles.selectableBtns]}>
|
|
||||||
<SelectableBtn
|
|
||||||
selected={colorMode === 'system'}
|
|
||||||
label={_(msg`System`)}
|
|
||||||
left
|
|
||||||
onSelect={() => setColorMode('system')}
|
|
||||||
accessibilityHint={_(msg`Sets color theme to system setting`)}
|
|
||||||
/>
|
|
||||||
<SelectableBtn
|
|
||||||
selected={colorMode === 'light'}
|
|
||||||
label={_(msg`Light`)}
|
|
||||||
onSelect={() => setColorMode('light')}
|
|
||||||
accessibilityHint={_(msg`Sets color theme to light`)}
|
|
||||||
/>
|
|
||||||
<SelectableBtn
|
|
||||||
selected={colorMode === 'dark'}
|
|
||||||
label={_(msg`Dark`)}
|
|
||||||
right
|
|
||||||
onSelect={() => setColorMode('dark')}
|
|
||||||
accessibilityHint={_(msg`Sets color theme to dark`)}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.spacer20} />
|
|
||||||
|
|
||||||
{colorMode !== 'light' && (
|
|
||||||
<>
|
|
||||||
<Text type="xl-bold" style={[pal.text, styles.heading]}>
|
|
||||||
<Trans>Dark Theme</Trans>
|
|
||||||
</Text>
|
|
||||||
<View>
|
|
||||||
<View style={[styles.linkCard, pal.view, styles.selectableBtns]}>
|
|
||||||
<SelectableBtn
|
|
||||||
selected={!darkTheme || darkTheme === 'dim'}
|
|
||||||
label={_(msg`Dim`)}
|
|
||||||
left
|
|
||||||
onSelect={() => setDarkTheme('dim')}
|
|
||||||
accessibilityHint={_(msg`Sets dark theme to the dim theme`)}
|
|
||||||
/>
|
|
||||||
<SelectableBtn
|
|
||||||
selected={darkTheme === 'dark'}
|
|
||||||
label={_(msg`Dark`)}
|
|
||||||
right
|
|
||||||
onSelect={() => setDarkTheme('dark')}
|
|
||||||
accessibilityHint={_(msg`Sets dark theme to the dark theme`)}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<View style={styles.spacer20} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Text type="xl-bold" style={[pal.text, styles.heading]}>
|
<Text type="xl-bold" style={[pal.text, styles.heading]}>
|
||||||
<Trans>Basics</Trans>
|
<Trans>Basics</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
@@ -519,6 +457,27 @@ export function SettingsScreen({}: Props) {
|
|||||||
<Trans>Accessibility</Trans>
|
<Trans>Accessibility</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
testID="appearanceSettingsBtn"
|
||||||
|
style={[
|
||||||
|
styles.linkCard,
|
||||||
|
pal.view,
|
||||||
|
isSwitchingAccounts && styles.dimmed,
|
||||||
|
]}
|
||||||
|
onPress={isSwitchingAccounts ? undefined : onPressAppearanceSettings}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={_(msg`Appearance settings`)}
|
||||||
|
accessibilityHint={_(msg`Opens appearance settings`)}>
|
||||||
|
<View style={[styles.iconContainer, pal.btn]}>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon="paint-roller"
|
||||||
|
style={pal.text as FontAwesomeIconStyle}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text type="lg" style={pal.text}>
|
||||||
|
<Trans>Appearance</Trans>
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
testID="languageSettingsBtn"
|
testID="languageSettingsBtn"
|
||||||
style={[
|
style={[
|
||||||
|
|||||||
@@ -34,10 +34,10 @@
|
|||||||
jsonpointer "^5.0.0"
|
jsonpointer "^5.0.0"
|
||||||
leven "^3.1.0"
|
leven "^3.1.0"
|
||||||
|
|
||||||
"@atproto/api@0.12.25":
|
"@atproto/api@^0.12.26":
|
||||||
version "0.12.25"
|
version "0.12.26"
|
||||||
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.25.tgz#9eeb51484106a5e07f89f124e505674a3574f93b"
|
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.26.tgz#940888466522cc9ff8c03d8164dc39221b29d9ca"
|
||||||
integrity sha512-IV3vGPnDw9bmyP/JOd8YKbm8fOpRAgJpEUVnIZNVb/Vo8v+WOroOjrJxtzdHOcXTL9IEcTTyXSCc7yE7kwhN2A==
|
integrity sha512-RH0ymOGbDfT8IL8eNzzY+hwtyTgknHfkzUVqRd0sstNblvTf8WGpDR2FSTveiiMR3OpVO6zG8fRYVzBfmY1+pA==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@atproto/common-web" "^0.3.0"
|
"@atproto/common-web" "^0.3.0"
|
||||||
"@atproto/lexicon" "^0.4.0"
|
"@atproto/lexicon" "^0.4.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user