diff --git a/assets/icons/moon_stroke2_corner2_rounded.svg b/assets/icons/moon_stroke2_corner2_rounded.svg
new file mode 100644
index 0000000000..8f5c03699b
--- /dev/null
+++ b/assets/icons/moon_stroke2_corner2_rounded.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/icons/phone_stroke2_corner2_rounded.svg b/assets/icons/phone_stroke2_corner2_rounded.svg
new file mode 100644
index 0000000000..4f44f08e52
--- /dev/null
+++ b/assets/icons/phone_stroke2_corner2_rounded.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go
index 61a524a70b..8da291fe56 100644
--- a/bskyweb/cmd/bskyweb/server.go
+++ b/bskyweb/cmd/bskyweb/server.go
@@ -211,6 +211,7 @@ func serve(cctx *cli.Context) error {
e.GET("/settings/threads", server.WebGeneric)
e.GET("/settings/external-embeds", server.WebGeneric)
e.GET("/settings/accessibility", server.WebGeneric)
+ e.GET("/settings/appearance", server.WebGeneric)
e.GET("/sys/debug", server.WebGeneric)
e.GET("/sys/debug-mod", server.WebGeneric)
e.GET("/sys/log", server.WebGeneric)
diff --git a/package.json b/package.json
index 91b427ae91..3d053bc83b 100644
--- a/package.json
+++ b/package.json
@@ -52,7 +52,7 @@
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
},
"dependencies": {
- "@atproto/api": "0.12.25",
+ "@atproto/api": "^0.12.26",
"@bam.tech/react-native-image-resizer": "^3.0.4",
"@braintree/sanitize-url": "^6.0.2",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index 8646577c8b..79856879c3 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -44,6 +44,7 @@ import HashtagScreen from '#/screens/Hashtag'
import {ModerationScreen} from '#/screens/Moderation'
import {ProfileKnownFollowersScreen} from '#/screens/Profile/KnownFollowers'
import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy'
+import {AppearanceSettingsScreen} from '#/screens/Settings/AppearanceSettings'
import {
StarterPackScreen,
StarterPackScreenShort,
@@ -310,6 +311,14 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
requireAuth: true,
}}
/>
+ AppearanceSettingsScreen}
+ options={{
+ title: title(msg`Appearance Settings`),
+ requireAuth: true,
+ }}
+ />
HashtagScreen}
diff --git a/src/components/TagMenu/index.tsx b/src/components/TagMenu/index.tsx
index 0ed7036671..2c6a0b674c 100644
--- a/src/components/TagMenu/index.tsx
+++ b/src/components/TagMenu/index.tsx
@@ -1,27 +1,27 @@
import React from 'react'
import {View} from 'react-native'
-import {useNavigation} from '@react-navigation/native'
-import {useLingui} from '@lingui/react'
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 {NavigationProp} from '#/lib/routes/types'
+import {isInvalidHandle} from '#/lib/strings/handles'
import {
usePreferencesQuery,
+ useRemoveMutedWordsMutation,
useUpsertMutedWordsMutation,
- useRemoveMutedWordMutation,
} 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 {isInvalidHandle} from '#/lib/strings/handles'
+import {Text} from '#/components/Typography'
export function useTagMenuControl() {
return Dialog.useDialogControl()
@@ -52,10 +52,10 @@ export function TagMenu({
reset: resetUpsert,
} = useUpsertMutedWordsMutation()
const {
- mutateAsync: removeMutedWord,
+ mutateAsync: removeMutedWords,
variables: optimisticRemove,
reset: resetRemove,
- } = useRemoveMutedWordMutation()
+ } = useRemoveMutedWordsMutation()
const displayTag = '#' + tag
const isMuted = Boolean(
@@ -65,9 +65,20 @@ export function TagMenu({
optimisticUpsert?.find(
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 (
<>
{children}
@@ -212,13 +223,16 @@ export function TagMenu({
control.close(() => {
if (isMuted) {
resetUpsert()
- removeMutedWord({
- value: tag,
- targets: ['tag'],
- })
+ removeMutedWords(removeableMuteWords)
} else {
resetRemove()
- upsertMutedWord([{value: tag, targets: ['tag']}])
+ upsertMutedWord([
+ {
+ value: tag,
+ targets: ['tag'],
+ actorTarget: 'all',
+ },
+ ])
}
})
}}>
diff --git a/src/components/TagMenu/index.web.tsx b/src/components/TagMenu/index.web.tsx
index 4336223861..b6c306439a 100644
--- a/src/components/TagMenu/index.web.tsx
+++ b/src/components/TagMenu/index.web.tsx
@@ -3,16 +3,16 @@ import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
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 {isInvalidHandle} from '#/lib/strings/handles'
+import {enforceLen} from '#/lib/strings/helpers'
import {
usePreferencesQuery,
+ useRemoveMutedWordsMutation,
useUpsertMutedWordsMutation,
- useRemoveMutedWordMutation,
} 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 * as Dialog from '#/components/Dialog'
@@ -47,8 +47,8 @@ export function TagMenu({
const {data: preferences} = usePreferencesQuery()
const {mutateAsync: upsertMutedWord, variables: optimisticUpsert} =
useUpsertMutedWordsMutation()
- const {mutateAsync: removeMutedWord, variables: optimisticRemove} =
- useRemoveMutedWordMutation()
+ const {mutateAsync: removeMutedWords, variables: optimisticRemove} =
+ useRemoveMutedWordsMutation()
const isMuted = Boolean(
(preferences?.moderationPrefs.mutedWords?.find(
m => m.value === tag && m.targets.includes('tag'),
@@ -56,10 +56,21 @@ export function TagMenu({
optimisticUpsert?.find(
m => m.value === tag && m.targets.includes('tag'),
)) &&
- !(optimisticRemove?.value === tag),
+ !optimisticRemove?.find(m => m?.value === tag),
)
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(() => {
return [
{
@@ -105,9 +116,11 @@ export function TagMenu({
: _(msg`Mute ${truncatedTag}`),
onPress() {
if (isMuted) {
- removeMutedWord({value: tag, targets: ['tag']})
+ removeMutedWords(removeableMuteWords)
} else {
- upsertMutedWord([{value: tag, targets: ['tag']}])
+ upsertMutedWord([
+ {value: tag, targets: ['tag'], actorTarget: 'all'},
+ ])
}
},
testID: 'tagMenuMute',
@@ -129,7 +142,8 @@ export function TagMenu({
tag,
truncatedTag,
upsertMutedWord,
- removeMutedWord,
+ removeMutedWords,
+ removeableMuteWords,
])
return (
diff --git a/src/components/dialogs/MutedWords.tsx b/src/components/dialogs/MutedWords.tsx
index 526652be95..38273aad54 100644
--- a/src/components/dialogs/MutedWords.tsx
+++ b/src/components/dialogs/MutedWords.tsx
@@ -1,5 +1,5 @@
import React from 'react'
-import {Keyboard, View} from 'react-native'
+import {View} from 'react-native'
import {AppBskyActorDefs, sanitizeMutedWordValue} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -24,6 +24,7 @@ import * as Dialog from '#/components/Dialog'
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
import {Divider} from '#/components/Divider'
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 {PageText_Stroke2_Corner0_Rounded as PageText} from '#/components/icons/PageText'
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 {Text} from '#/components/Typography'
+const ONE_DAY = 24 * 60 * 60 * 1000
+
export function MutedWordsDialog() {
const {mutedWordsDialogControl: control} = useGlobalDialogsControlContext()
return (
@@ -53,16 +56,32 @@ function MutedWordsInner() {
} = usePreferencesQuery()
const {isPending, mutateAsync: addMutedWord} = useUpsertMutedWordsMutation()
const [field, setField] = React.useState('')
- const [options, setOptions] = React.useState(['content'])
+ const [targets, setTargets] = React.useState(['content'])
const [error, setError] = React.useState('')
+ const [durations, setDurations] = React.useState(['forever'])
+ const [excludeFollowing, setExcludeFollowing] = React.useState(false)
const submit = React.useCallback(async () => {
const sanitizedValue = sanitizeMutedWordValue(field)
- const targets = ['tag', options.includes('content') && 'content'].filter(
+ const surfaces = ['tag', targets.includes('content') && 'content'].filter(
Boolean,
) 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('')
setError(_(msg`Please enter a valid word, tag, or phrase to mute`))
return
@@ -70,28 +89,37 @@ function MutedWordsInner() {
try {
// 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('')
} catch (e: any) {
logger.error(`Failed to save muted word`, {message: e.message})
setError(e.message)
}
- }, [_, field, options, addMutedWord, setField])
+ }, [_, field, targets, addMutedWord, setField, durations, excludeFollowing])
return (
-
+
Add muted words and tags
- 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.
-
+
+
+
+ values={durations}
+ onChange={setDurations}>
+
+ Duration:
+
+
+
+
+
+
+
+
+ Forever
+
+
+
+
+
+
+
+
+
+
+ 24 hours
+
+
+
+
+
+
+
+
+
+
+
+
+ 7 days
+
+
+
+
+
+
+
+
+
+
+ 30 days
+
+
+
+
+
+
+
+
+
+
+ Mute in:
+
+
+
+ style={[a.flex_1]}>
-
+
-
- Mute in text & tags
+
+ Text & tags
@@ -140,34 +273,64 @@ function MutedWordsInner() {
+ style={[a.flex_1]}>
-
+
-
- Mute in tags only
+
+ Tags only
-
-
+
+
+ Options:
+
+
+
+
+
+
+ Exclude users you follow
+
+
+
+
+
+
+
+
+
+
{error && (
)}
-
-
-
- We recommend avoiding common words that appear in many posts,
- since it can result in no posts being shown.
-
-
@@ -268,6 +417,9 @@ function MutedWordRow({
const {_} = useLingui()
const {isPending, mutateAsync: removeMutedWord} = useRemoveMutedWordMutation()
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 () => {
control.close()
@@ -280,7 +432,7 @@ function MutedWordRow({
control={control}
title={_(msg`Are you sure?`)}
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}
confirmButtonCta={_(msg`Remove`)}
@@ -289,53 +441,94 @@ function MutedWordRow({
-
- {word.value}
-
+
+
+
+ {word.targets.find(t => t === 'content') ? (
+
+ {word.value}{' '}
+
+ in{' '}
+
+ text & tags
+
+
+
+ ) : (
+
+ {word.value}{' '}
+
+ in{' '}
+
+ tags
+
+
+
+ )}
+
+
-
- {word.targets.map(target => (
-
+ {(expiryDate || word.actorTarget === 'exclude-following') && (
+
- {target === 'content' ? _(msg`text`) : _(msg`tag`)}
+ style={[
+ a.flex_1,
+ a.text_xs,
+ a.leading_snug,
+ t.atoms.text_contrast_medium,
+ ]}>
+ {expiryDate && (
+ <>
+ {isExpired ? (
+ Expired
+ ) : (
+
+ Expires{' '}
+ {formatDistance(expiryDate, new Date(), {
+ addSuffix: true,
+ })}
+
+ )}
+ >
+ )}
+ {word.actorTarget === 'exclude-following' && (
+ <>
+ {' • '}
+ Excludes users you follow
+ >
+ )}
- ))}
-
-
+ )}
+
+
>
)
diff --git a/src/components/forms/ToggleButton.tsx b/src/components/forms/ToggleButton.tsx
index 7528426380..f47a272b18 100644
--- a/src/components/forms/ToggleButton.tsx
+++ b/src/components/forms/ToggleButton.tsx
@@ -23,10 +23,10 @@ export function Group({children, multiple, ...props}: GroupProps) {
style={[
a.w_full,
a.flex_row,
- a.border,
a.rounded_sm,
a.overflow_hidden,
t.atoms.border_contrast_low,
+ {borderWidth: 1},
]}>
{children}
diff --git a/src/components/hooks/dates.ts b/src/components/hooks/dates.ts
new file mode 100644
index 0000000000..b0f94133b7
--- /dev/null
+++ b/src/components/hooks/dates.ts
@@ -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 = {
+ 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(
+ (date, baseDate, options) => {
+ const locale = locales[appLanguage as AppLanguage]
+ return formatDistance(date, baseDate, {...options, locale: locale})
+ },
+ [appLanguage],
+ )
+}
diff --git a/src/components/icons/Moon.tsx b/src/components/icons/Moon.tsx
new file mode 100644
index 0000000000..4994370b9e
--- /dev/null
+++ b/src/components/icons/Moon.tsx
@@ -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',
+})
diff --git a/src/components/icons/Phone.tsx b/src/components/icons/Phone.tsx
new file mode 100644
index 0000000000..62000a1e5d
--- /dev/null
+++ b/src/components/icons/Phone.tsx
@@ -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',
+})
diff --git a/src/lib/api/feed-manip.ts b/src/lib/api/feed-manip.ts
index 89f6a0bb45..01f05685dd 100644
--- a/src/lib/api/feed-manip.ts
+++ b/src/lib/api/feed-manip.ts
@@ -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 {
seenKeys: Set = new Set()
seenUris: Set = new Set()
@@ -309,15 +299,7 @@ export class FeedTuner {
return slices
}
- static thresholdRepliesOnly({
- userDid,
- minLikes,
- followedOnly,
- }: {
- userDid: string
- minLikes: number
- followedOnly: boolean
- }) {
+ static followedRepliesOnly({userDid}: {userDid: string}) {
return (
tuner: FeedTuner,
slices: FeedViewPostsSlice[],
@@ -332,9 +314,7 @@ export class FeedTuner {
if (slice.isRepost) {
continue
}
- if (slice.likeCount < minLikes) {
- slices.splice(i, 1)
- } else if (followedOnly && !slice.isFollowingAllAuthors(userDid)) {
+ if (!slice.isFollowingAllAuthors(userDid)) {
slices.splice(i, 1)
}
}
diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts
index fbb66c9e9a..0cc83b475a 100644
--- a/src/lib/routes/types.ts
+++ b/src/lib/routes/types.ts
@@ -38,6 +38,7 @@ export type CommonNavigatorParams = {
PreferencesThreads: undefined
PreferencesExternalEmbeds: undefined
AccessibilitySettings: undefined
+ AppearanceSettings: undefined
Search: {q?: string}
Hashtag: {tag: string; author?: string}
MessagesConversation: {conversation: string; embed?: string}
diff --git a/src/routes.ts b/src/routes.ts
index ddf4fb39fa..c9e23e08c8 100644
--- a/src/routes.ts
+++ b/src/routes.ts
@@ -32,6 +32,7 @@ export const router = new Router({
PreferencesThreads: '/settings/threads',
PreferencesExternalEmbeds: '/settings/external-embeds',
AccessibilitySettings: '/settings/accessibility',
+ AppearanceSettings: '/settings/appearance',
SavedFeeds: '/settings/saved-feeds',
Support: '/support',
PrivacyPolicy: '/support/privacy',
diff --git a/src/screens/Settings/AppearanceSettings.tsx b/src/screens/Settings/AppearanceSettings.tsx
new file mode 100644
index 0000000000..00a04bbfb6
--- /dev/null
+++ b/src/screens/Settings/AppearanceSettings.tsx
@@ -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
+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 (
+
+
+
+
+
+
+ Appearance
+
+
+
+
+
+
+
+
+ Mode
+
+
+
+
+
+ System
+
+
+
+
+ Light
+
+
+
+
+ Dark
+
+
+
+ {colorMode !== 'light' && (
+
+
+
+
+ Dark theme
+
+
+
+
+
+
+ Dim
+
+
+
+
+ Dark
+
+
+
+
+ )}
+
+
+
+
+ )
+}
diff --git a/src/state/preferences/feed-tuners.tsx b/src/state/preferences/feed-tuners.tsx
index 7d44515138..d816bde649 100644
--- a/src/state/preferences/feed-tuners.tsx
+++ b/src/state/preferences/feed-tuners.tsx
@@ -38,11 +38,8 @@ export function useFeedTuners(feedDesc: FeedDescriptor) {
feedTuners.push(FeedTuner.removeReplies)
} else {
feedTuners.push(
- FeedTuner.thresholdRepliesOnly({
+ FeedTuner.followedRepliesOnly({
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)
} else {
feedTuners.push(
- FeedTuner.thresholdRepliesOnly({
+ FeedTuner.followedRepliesOnly({
userDid: currentAccount?.did || '',
- minLikes: preferences?.feedViewPrefs.hideRepliesByLikeCount || 0,
- followedOnly: !!preferences?.feedViewPrefs.hideRepliesByUnfollowed,
}),
)
}
diff --git a/src/state/queries/notifications/feed.ts b/src/state/queries/notifications/feed.ts
index 3cafcb7168..3054860db2 100644
--- a/src/state/queries/notifications/feed.ts
+++ b/src/state/queries/notifications/feed.ts
@@ -59,7 +59,6 @@ export function useNotificationFeedQuery(opts?: {
const moderationOpts = useModerationOpts()
const unreads = useUnreadNotificationsApi()
const enabled = opts?.enabled !== false
- const lastPageCountRef = useRef(0)
const gate = useGate()
// 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(() => {
- const {isFetching, hasNextPage, data} = query
- if (isFetching || !hasNextPage) {
- return
- }
-
- // 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
+ const {data, isLoading, isRefetching, isFetchingNextPage, hasNextPage} =
+ query
+ // Count the items that we already have.
+ let itemCount = 0
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()
- lastPageCountRef.current = data?.pages?.length || 0
+
+ // If items got truncated, reset the state we're tracking below.
+ 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])
diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts
index 1d6ec80d91..65467e8023 100644
--- a/src/state/queries/post-feed.ts
+++ b/src/state/queries/post-feed.ts
@@ -31,7 +31,7 @@ import {LikesFeedAPI} from 'lib/api/feed/likes'
import {ListFeedAPI} from 'lib/api/feed/list'
import {MergeFeedAPI} from 'lib/api/feed/merge'
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 {KnownError} from '#/view/com/posts/FeedErrorMessage'
import {useFeedTuners} from '../preferences/feed-tuners'
@@ -61,7 +61,6 @@ export type FeedDescriptor =
| `list|${ListUri}`
| `list|${ListUri}|${ListFilter}`
export interface FeedParams {
- disableTuner?: boolean
mergeFeedEnabled?: boolean
mergeFeedSources?: string[]
}
@@ -105,7 +104,7 @@ export interface FeedPageUnselected {
export interface FeedPage {
api: FeedAPI
- tuner: FeedTuner | NoopFeedTuner
+ tuner: FeedTuner
cursor: string | undefined
slices: FeedPostSlice[]
fetchedAt: number
@@ -135,25 +134,17 @@ export function usePostFeedQuery(
args: typeof selectArgs
result: InfiniteData
} | null>(null)
- const lastPageCountRef = useRef(0)
const isDiscover = feedDesc.includes(DISCOVER_FEED_URI)
// Make sure this doesn't invalidate unless really needed.
const selectArgs = React.useMemo(
() => ({
feedTuners,
- disableTuner: params?.disableTuner,
moderationOpts,
ignoreFilterFor: opts?.ignoreFilterFor,
isDiscover,
}),
- [
- feedTuners,
- params?.disableTuner,
- moderationOpts,
- opts?.ignoreFilterFor,
- isDiscover,
- ],
+ [feedTuners, moderationOpts, opts?.ignoreFilterFor, isDiscover],
)
const query = useInfiniteQuery<
@@ -232,17 +223,10 @@ export function usePostFeedQuery(
(data: InfiniteData) => {
// If the selection depends on some data, that data should
// be included in the selectArgs object and read here.
- const {
- feedTuners,
- disableTuner,
- moderationOpts,
- ignoreFilterFor,
- isDiscover,
- } = selectArgs
+ const {feedTuners, moderationOpts, ignoreFilterFor, isDiscover} =
+ selectArgs
- const tuner = disableTuner
- ? new NoopFeedTuner()
- : new FeedTuner(feedTuners)
+ const tuner = new FeedTuner(feedTuners)
// Keep track of the last run and whether we can reuse
// 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(() => {
- const {isFetching, hasNextPage, data} = query
- if (isFetching || !hasNextPage) {
- return
- }
-
- // 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
+ const {data, isLoading, isRefetching, isFetchingNextPage, hasNextPage} =
+ query
+ // Count the items that we already have.
+ let itemCount = 0
for (const page of data?.pages || []) {
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()
- lastPageCountRef.current = data?.pages?.length || 0
+
+ // If items got truncated, reset the state we're tracking below.
+ 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])
diff --git a/src/state/queries/preferences/const.ts b/src/state/queries/preferences/const.ts
index 2a8c51165e..1ae7d20684 100644
--- a/src/state/queries/preferences/const.ts
+++ b/src/state/queries/preferences/const.ts
@@ -7,8 +7,8 @@ import {
export const DEFAULT_HOME_FEED_PREFS: UsePreferencesQueryResponse['feedViewPrefs'] =
{
hideReplies: false,
- hideRepliesByUnfollowed: true,
- hideRepliesByLikeCount: 0,
+ hideRepliesByUnfollowed: true, // Legacy, ignored
+ hideRepliesByLikeCount: 0, // Legacy, ignored
hideReposts: false,
hideQuotePosts: false,
lab_mergeFeedEnabled: false, // experimental
diff --git a/src/state/queries/preferences/index.ts b/src/state/queries/preferences/index.ts
index 9bb57fcaf6..6991f8647b 100644
--- a/src/state/queries/preferences/index.ts
+++ b/src/state/queries/preferences/index.ts
@@ -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() {
const queryClient = useQueryClient()
const agent = useAgent()
diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx
index 520a059ae2..e4294eaa5f 100644
--- a/src/view/com/notifications/FeedItem.tsx
+++ b/src/view/com/notifications/FeedItem.tsx
@@ -13,11 +13,13 @@ import {
AppBskyEmbedRecordWithMedia,
AppBskyFeedDefs,
AppBskyFeedPost,
+ AppBskyGraphFollow,
moderateProfile,
ModerationDecision,
ModerationOpts,
} from '@atproto/api'
import {AtUri} from '@atproto/api'
+import {TID} from '@atproto/common-web'
import {msg, plural, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
@@ -184,10 +186,28 @@ let FeedItem = ({
action = _(msg`reposted your post`)
icon =
} else if (item.type === 'follow') {
+ let isFollowBack = false
+
if (
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`)
} else {
action = _(msg`followed you`)
diff --git a/src/view/com/post-thread/PostLikedBy.tsx b/src/view/com/post-thread/PostLikedBy.tsx
index 0760ed7ff3..da230aade9 100644
--- a/src/view/com/post-thread/PostLikedBy.tsx
+++ b/src/view/com/post-thread/PostLikedBy.tsx
@@ -1,38 +1,57 @@
import React, {useCallback, useMemo, useState} from 'react'
-import {ActivityIndicator, StyleSheet, View} from 'react-native'
import {AppBskyFeedGetLikes as GetLikes} from '@atproto/api'
-import {CenteredView} from '../util/Views'
-import {List} from '../util/List'
-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 {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
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
+}
+
+function keyExtractor(item: GetLikes.Like) {
+ return item.actor.did
+}
export function PostLikedBy({uri}: {uri: string}) {
+ const {_} = useLingui()
+ const initialNumToRender = useInitialNumToRender()
+
const [isPTRing, setIsPTRing] = useState(false)
+
const {
data: resolvedUri,
error: resolveError,
- isFetching: isFetchingResolvedUri,
+ isLoading: isLoadingUri,
} = useResolveUriQuery(uri)
const {
data,
- isFetching,
- isFetched,
+ isLoading: isLoadingLikes,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
- isError,
error,
refetch,
} = useLikedByQuery(resolvedUri?.uri)
+
+ const isError = Boolean(resolveError || error)
+
const likes = useMemo(() => {
if (data?.pages) {
return data.pages.flatMap(page => page.likes)
}
+ return []
}, [data])
const onRefresh = useCallback(async () => {
@@ -46,64 +65,44 @@ export function PostLikedBy({uri}: {uri: string}) {
}, [refetch, setIsPTRing])
const onEndReached = useCallback(async () => {
- if (isFetching || !hasNextPage || isError) return
+ if (isFetchingNextPage || !hasNextPage || isError) return
try {
await fetchNextPage()
} catch (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 (
-
- )
- }, [])
-
- if (isFetchingResolvedUri || !isFetched) {
- return
- }
-
- // error
- // =
- if (resolveError || isError) {
- return (
-
-
-
+
)
}
- // loaded
- // =
return (
item.actor.did}
+ renderItem={renderItem}
+ keyExtractor={keyExtractor}
refreshing={isPTRing}
onRefresh={onRefresh}
onEndReached={onEndReached}
- renderItem={renderItem}
- initialNumToRender={15}
- // FIXME(dan)
- // eslint-disable-next-line react/no-unstable-nested-components
- ListFooterComponent={() => (
-
- {(isFetching || isFetchingNextPage) && }
-
- )}
+ onEndReachedThreshold={4}
+ ListHeaderComponent={}
+ ListFooterComponent={
+
+ }
// @ts-ignore our .web version only -prf
desktopFixedHeight
+ initialNumToRender={initialNumToRender}
+ windowSize={11}
/>
)
}
-
-const styles = StyleSheet.create({
- footer: {
- height: 200,
- paddingTop: 20,
- },
-})
diff --git a/src/view/com/post-thread/PostRepostedBy.tsx b/src/view/com/post-thread/PostRepostedBy.tsx
index 31a0be832d..9038549a50 100644
--- a/src/view/com/post-thread/PostRepostedBy.tsx
+++ b/src/view/com/post-thread/PostRepostedBy.tsx
@@ -1,38 +1,57 @@
-import React, {useMemo, useCallback, useState} from 'react'
-import {ActivityIndicator, StyleSheet, View} from 'react-native'
+import React, {useCallback, useMemo, useState} from 'react'
import {AppBskyActorDefs as ActorDefs} from '@atproto/api'
-import {CenteredView} from '../util/Views'
-import {List} from '../util/List'
-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 {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
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
+}
+
+function keyExtractor(item: ActorDefs.ProfileViewBasic) {
+ return item.did
+}
export function PostRepostedBy({uri}: {uri: string}) {
+ const {_} = useLingui()
+ const initialNumToRender = useInitialNumToRender()
+
const [isPTRing, setIsPTRing] = useState(false)
+
const {
data: resolvedUri,
error: resolveError,
- isFetching: isFetchingResolvedUri,
+ isLoading: isLoadingUri,
} = useResolveUriQuery(uri)
const {
data,
- isFetching,
- isFetched,
+ isLoading: isLoadingRepostedBy,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
- isError,
error,
refetch,
} = usePostRepostedByQuery(resolvedUri?.uri)
+
+ const isError = Boolean(resolveError || error)
+
const repostedBy = useMemo(() => {
if (data?.pages) {
return data.pages.flatMap(page => page.repostedBy)
}
+ return []
}, [data])
const onRefresh = useCallback(async () => {
@@ -46,35 +65,20 @@ export function PostRepostedBy({uri}: {uri: string}) {
}, [refetch, setIsPTRing])
const onEndReached = useCallback(async () => {
- if (isFetching || !hasNextPage || isError) return
+ if (isFetchingNextPage || !hasNextPage || isError) return
try {
await fetchNextPage()
} catch (err) {
logger.error('Failed to load more reposts', {message: err})
}
- }, [isFetching, hasNextPage, isError, fetchNextPage])
+ }, [isFetchingNextPage, hasNextPage, isError, fetchNextPage])
- const renderItem = useCallback(
- ({item}: {item: ActorDefs.ProfileViewBasic}) => {
- return
- },
- [],
- )
-
- if (isFetchingResolvedUri || !isFetched) {
- return
- }
-
- // error
- // =
- if (resolveError || isError) {
+ if (repostedBy.length < 1) {
return (
-
-
-
+
)
}
@@ -83,28 +87,24 @@ export function PostRepostedBy({uri}: {uri: string}) {
return (
item.did}
+ renderItem={renderItem}
+ keyExtractor={keyExtractor}
refreshing={isPTRing}
onRefresh={onRefresh}
onEndReached={onEndReached}
- renderItem={renderItem}
- initialNumToRender={15}
- // FIXME(dan)
- // eslint-disable-next-line react/no-unstable-nested-components
- ListFooterComponent={() => (
-
- {(isFetching || isFetchingNextPage) && }
-
- )}
+ onEndReachedThreshold={4}
+ ListHeaderComponent={}
+ ListFooterComponent={
+
+ }
// @ts-ignore our .web version only -prf
desktopFixedHeight
+ initialNumToRender={initialNumToRender}
+ windowSize={11}
/>
)
}
-
-const styles = StyleSheet.create({
- footer: {
- height: 200,
- paddingTop: 20,
- },
-})
diff --git a/src/view/com/posts/FeedSlice.tsx b/src/view/com/posts/FeedSlice.tsx
index b8c9a1f2c7..8d707d78ea 100644
--- a/src/view/com/posts/FeedSlice.tsx
+++ b/src/view/com/posts/FeedSlice.tsx
@@ -19,6 +19,7 @@ let FeedSlice = ({
hideTopBorder?: boolean
}): React.ReactNode => {
if (slice.isThread && slice.items.length > 3) {
+ const beforeLast = slice.items.length - 2
const last = slice.items.length - 1
return (
<>
@@ -36,20 +37,20 @@ let FeedSlice = ({
hideTopBorder={hideTopBorder}
isParentBlocked={slice.items[0].isParentBlocked}
/>
-
+
(
style={[styles.aboveTheFoldDetector, {height: headerOffset}]}
/>
{onStartReached && !isEmpty && (
-
)}
{headerComponent}
@@ -368,11 +369,11 @@ function ListImpl(
)
})}
{onEndReached && !isEmpty && (
-
)}
{footerComponent}
@@ -381,6 +382,34 @@ function ListImpl(
)
}
+function EdgeVisibility({
+ root,
+ topMargin,
+ bottomMargin,
+ containerRef,
+ onVisibleChange,
+}: {
+ root?: React.RefObject | null
+ topMargin?: string
+ bottomMargin?: string
+ containerRef: React.RefObject
+ onVisibleChange: (isVisible: boolean) => void
+}) {
+ const [containerHeight, setContainerHeight] = React.useState(0)
+ useResizeObserver(containerRef, (w, h) => {
+ setContainerHeight(h)
+ })
+ return (
+
+ )
+}
+
function useResizeObserver(
ref: React.RefObject,
onResize: undefined | ((w: number, h: number) => void),
diff --git a/src/view/icons/index.tsx b/src/view/icons/index.tsx
index beb31eca4e..8b1655e6a8 100644
--- a/src/view/icons/index.tsx
+++ b/src/view/icons/index.tsx
@@ -77,6 +77,7 @@ import {faListUl} from '@fortawesome/free-solid-svg-icons/faListUl'
import {faLock} from '@fortawesome/free-solid-svg-icons/faLock'
import {faMagnifyingGlass} from '@fortawesome/free-solid-svg-icons/faMagnifyingGlass'
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 {faPen} from '@fortawesome/free-solid-svg-icons/faPen'
import {faPenNib} from '@fortawesome/free-solid-svg-icons/faPenNib'
@@ -178,6 +179,7 @@ library.add(
faMagnifyingGlass,
faMessage,
faNoteSticky,
+ faPaintRoller,
faPaste,
faPause,
faPen,
diff --git a/src/view/screens/AccessibilitySettings.tsx b/src/view/screens/AccessibilitySettings.tsx
index abe1550762..2a4477532d 100644
--- a/src/view/screens/AccessibilitySettings.tsx
+++ b/src/view/screens/AccessibilitySettings.tsx
@@ -27,6 +27,7 @@ import {ToggleButton} from '#/view/com/util/forms/ToggleButton'
import {SimpleViewHeader} from '#/view/com/util/SimpleViewHeader'
import {Text} from '#/view/com/util/text/Text'
import {ScrollView} from '#/view/com/util/Views'
+import {atoms as a} from '#/alf'
type Props = NativeStackScreenProps<
CommonNavigatorParams,
@@ -61,10 +62,13 @@ export function AccessibilitySettingsScreen({}: Props) {
showBackButton={isTabletOrMobile}
style={[
pal.border,
- {borderBottomWidth: 1},
- !isMobile && {borderLeftWidth: 1, borderRightWidth: 1},
+ a.border_b,
+ !isMobile && {
+ borderLeftWidth: StyleSheet.hairlineWidth,
+ borderRightWidth: StyleSheet.hairlineWidth,
+ },
]}>
-
+
Accessibility Settings
diff --git a/src/view/screens/PostLikedBy.tsx b/src/view/screens/PostLikedBy.tsx
index 604301544c..5ff5a1932e 100644
--- a/src/view/screens/PostLikedBy.tsx
+++ b/src/view/screens/PostLikedBy.tsx
@@ -1,13 +1,14 @@
import React from 'react'
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 {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
export const PostLikedByScreen = ({route}: Props) => {
@@ -23,7 +24,7 @@ export const PostLikedByScreen = ({route}: Props) => {
)
return (
-
+
diff --git a/src/view/screens/PostRepostedBy.tsx b/src/view/screens/PostRepostedBy.tsx
index 07017d6920..eaacc67807 100644
--- a/src/view/screens/PostRepostedBy.tsx
+++ b/src/view/screens/PostRepostedBy.tsx
@@ -1,13 +1,14 @@
import React from 'react'
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 {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
export const PostRepostedByScreen = ({route}: Props) => {
@@ -23,7 +24,7 @@ export const PostRepostedByScreen = ({route}: Props) => {
)
return (
-
+
diff --git a/src/view/screens/PreferencesExternalEmbeds.tsx b/src/view/screens/PreferencesExternalEmbeds.tsx
index 57ca5e7653..ade7a53d90 100644
--- a/src/view/screens/PreferencesExternalEmbeds.tsx
+++ b/src/view/screens/PreferencesExternalEmbeds.tsx
@@ -18,6 +18,7 @@ import {
useSetExternalEmbedPref,
} from 'state/preferences'
import {ToggleButton} from 'view/com/util/forms/ToggleButton'
+import {atoms as a} from '#/alf'
import {SimpleViewHeader} from '../com/util/SimpleViewHeader'
import {Text} from '../com/util/text/Text'
import {ScrollView} from '../com/util/Views'
@@ -47,8 +48,8 @@ export function PreferencesExternalEmbeds({}: Props) {
contentContainerStyle={[pal.viewLight, {paddingBottom: 75}]}>
-
+ style={[pal.border, a.border_b]}>
+
External Media Preferences
diff --git a/src/view/screens/PreferencesFollowingFeed.tsx b/src/view/screens/PreferencesFollowingFeed.tsx
index 879c925fbf..8aa4221e6c 100644
--- a/src/view/screens/PreferencesFollowingFeed.tsx
+++ b/src/view/screens/PreferencesFollowingFeed.tsx
@@ -1,16 +1,13 @@
-import React, {useState} from 'react'
+import React from 'react'
import {StyleSheet, View} from 'react-native'
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 {Slider} from '@miblanchard/react-native-slider'
-import debounce from 'lodash.debounce'
import {usePalette} from '#/lib/hooks/usePalette'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
import {colors, s} from '#/lib/styles'
-import {isWeb} from '#/platform/detection'
import {
usePreferencesQuery,
useSetFeedViewPreferencesMutation,
@@ -19,61 +16,7 @@ import {ToggleButton} from '#/view/com/util/forms/ToggleButton'
import {SimpleViewHeader} from '#/view/com/util/SimpleViewHeader'
import {Text} from '#/view/com/util/text/Text'
import {ScrollView} from '#/view/com/util/Views'
-
-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 (
-
- {
- 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}
- />
-
-
-
-
- )
-}
+import {atoms as a} from '#/alf'
type Props = NativeStackScreenProps<
CommonNavigatorParams,
@@ -99,8 +42,8 @@ export function PreferencesFollowingFeed({}: Props) {
contentContainerStyle={{paddingBottom: 75}}>
-
+ style={[pal.border, a.border_b]}>
+
Following Feed Preferences
@@ -136,51 +79,6 @@ export function PreferencesFollowingFeed({}: Props) {
}
/>
-
-
- Reply Filters
-
-
-
- Enable this setting to only see replies between people you
- follow.
-
-
-
- setFeedViewPref({
- hideRepliesByUnfollowed: !(
- variables?.hideRepliesByUnfollowed ??
- preferences?.feedViewPrefs?.hideRepliesByUnfollowed
- ),
- })
- : undefined
- }
- style={[s.mb10]}
- />
-
-
- Adjust the number of likes a reply must have to be shown in your
- feed.
-
-
- {preferences && (
-
- )}
-
-
Show Reposts
diff --git a/src/view/screens/PreferencesThreads.tsx b/src/view/screens/PreferencesThreads.tsx
index 3b09f0abb5..4a311f91ce 100644
--- a/src/view/screens/PreferencesThreads.tsx
+++ b/src/view/screens/PreferencesThreads.tsx
@@ -45,8 +45,8 @@ export function PreferencesThreads({}: Props) {
contentContainerStyle={{paddingBottom: 75}}>
-
+ style={[pal.border, a.border_b]}>
+
Thread Preferences
diff --git a/src/view/screens/ProfileFeedLikedBy.tsx b/src/view/screens/ProfileFeedLikedBy.tsx
index b1bcf48ba4..bb9ec2baeb 100644
--- a/src/view/screens/ProfileFeedLikedBy.tsx
+++ b/src/view/screens/ProfileFeedLikedBy.tsx
@@ -1,13 +1,14 @@
import React from 'react'
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 {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
export const ProfileFeedLikedByScreen = ({route}: Props) => {
@@ -23,7 +24,7 @@ export const ProfileFeedLikedByScreen = ({route}: Props) => {
)
return (
-
+
diff --git a/src/view/screens/Settings/index.tsx b/src/view/screens/Settings/index.tsx
index db74d5c0d5..c33be7d542 100644
--- a/src/view/screens/Settings/index.tsx
+++ b/src/view/screens/Settings/index.tsx
@@ -31,12 +31,7 @@ import {useClearPreferencesMutation} from '#/state/queries/preferences'
import {RQKEY as RQKEY_PROFILE} from '#/state/queries/profile'
import {useProfileQuery} from '#/state/queries/profile'
import {SessionAccount, useSession, useSessionApi} from '#/state/session'
-import {
- useOnboardingDispatch,
- useSetMinimalShellMode,
- useSetThemePrefs,
- useThemePrefs,
-} from '#/state/shell'
+import {useOnboardingDispatch, useSetMinimalShellMode} from '#/state/shell'
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
import {useCloseAllActiveElements} from '#/state/util'
import {useAnalytics} from 'lib/analytics/analytics'
@@ -52,7 +47,6 @@ import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types'
import {NavigationProp} from 'lib/routes/types'
import {colors, s} from 'lib/styles'
import {AccountDropdownBtn} from 'view/com/util/AccountDropdownBtn'
-import {SelectableBtn} from 'view/com/util/forms/SelectableBtn'
import {ToggleButton} from 'view/com/util/forms/ToggleButton'
import {Link, TextLink} from 'view/com/util/Link'
import {SimpleViewHeader} from 'view/com/util/SimpleViewHeader'
@@ -61,8 +55,7 @@ import * as Toast from 'view/com/util/Toast'
import {UserAvatar} from 'view/com/util/UserAvatar'
import {ScrollView} from 'view/com/util/Views'
import {DeactivateAccountDialog} from '#/screens/Settings/components/DeactivateAccountDialog'
-import {useTheme} from '#/alf'
-import {atoms as a} from '#/alf'
+import {atoms as a, useTheme} from '#/alf'
import {useDialogControl} from '#/components/Dialog'
import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings'
import {navigate, resetToTab} from '#/Navigation'
@@ -168,8 +161,6 @@ function SettingsAccountCard({
type Props = NativeStackScreenProps
export function SettingsScreen({}: Props) {
const queryClient = useQueryClient()
- const {colorMode, darkTheme} = useThemePrefs()
- const {setColorMode, setDarkTheme} = useSetThemePrefs()
const pal = usePalette('default')
const {_} = useLingui()
const setMinimalShellMode = useSetMinimalShellMode()
@@ -296,6 +287,10 @@ export function SettingsScreen({}: Props) {
navigation.navigate('AccessibilitySettings')
}, [navigation])
+ const onPressAppearanceSettings = React.useCallback(() => {
+ navigation.navigate('AppearanceSettings')
+ }, [navigation])
+
const onPressBirthday = React.useCallback(() => {
birthdayControl.open()
}, [birthdayControl])
@@ -436,63 +431,6 @@ export function SettingsScreen({}: Props) {
-
- Appearance
-
-
-
- setColorMode('system')}
- accessibilityHint={_(msg`Sets color theme to system setting`)}
- />
- setColorMode('light')}
- accessibilityHint={_(msg`Sets color theme to light`)}
- />
- setColorMode('dark')}
- accessibilityHint={_(msg`Sets color theme to dark`)}
- />
-
-
-
-
-
- {colorMode !== 'light' && (
- <>
-
- Dark Theme
-
-
-
- setDarkTheme('dim')}
- accessibilityHint={_(msg`Sets dark theme to the dim theme`)}
- />
- setDarkTheme('dark')}
- accessibilityHint={_(msg`Sets dark theme to the dark theme`)}
- />
-
-
-
- >
- )}
-
Basics
@@ -519,6 +457,27 @@ export function SettingsScreen({}: Props) {
Accessibility
+
+
+
+
+
+ Appearance
+
+