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/main.go b/bskyweb/cmd/bskyweb/main.go
index 908486aa7e..d9235afdee 100644
--- a/bskyweb/cmd/bskyweb/main.go
+++ b/bskyweb/cmd/bskyweb/main.go
@@ -41,10 +41,10 @@ func run(args []string) {
EnvVars: []string{"ATP_APPVIEW_HOST", "ATP_PDS_HOST"},
},
&cli.StringFlag{
- Name: "ogcard-host",
- Usage: "scheme, hostname, and port of ogcard service",
+ Name: "ogcard-host",
+ Usage: "scheme, hostname, and port of ogcard service",
Required: false,
- EnvVars: []string{"OGCARD_HOST"},
+ EnvVars: []string{"OGCARD_HOST"},
},
&cli.StringFlag{
Name: "http-address",
@@ -67,6 +67,13 @@ func run(args []string) {
Required: false,
EnvVars: []string{"DEBUG"},
},
+ &cli.StringFlag{
+ Name: "basic-auth-password",
+ Usage: "optional password to restrict access to web interface",
+ Required: false,
+ Value: "",
+ EnvVars: []string{"BASIC_AUTH_PASSWORD"},
+ },
},
},
}
diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go
index 61a524a70b..fdef01ce78 100644
--- a/bskyweb/cmd/bskyweb/server.go
+++ b/bskyweb/cmd/bskyweb/server.go
@@ -2,6 +2,7 @@ package main
import (
"context"
+ "crypto/subtle"
"errors"
"fmt"
"io/fs"
@@ -48,6 +49,7 @@ func serve(cctx *cli.Context) error {
appviewHost := cctx.String("appview-host")
ogcardHost := cctx.String("ogcard-host")
linkHost := cctx.String("link-host")
+ basicAuthPassword := cctx.String("basic-auth-password")
// Echo
e := echo.New()
@@ -140,6 +142,18 @@ func serve(cctx *cli.Context) error {
},
}))
+ // optional password gating of entire web interface
+ if basicAuthPassword != "" {
+ e.Use(middleware.BasicAuth(func(username, password string, c echo.Context) (bool, error) {
+ // Be careful to use constant time comparison to prevent timing attacks
+ if subtle.ConstantTimeCompare([]byte(username), []byte("admin")) == 1 &&
+ subtle.ConstantTimeCompare([]byte(password), []byte(basicAuthPassword)) == 1 {
+ return true, nil
+ }
+ return false, nil
+ }))
+ }
+
// redirect trailing slash to non-trailing slash.
// all of our current endpoints have no trailing slash.
e.Use(middleware.RemoveTrailingSlashWithConfig(middleware.TrailingSlashConfig{
@@ -211,6 +225,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 e433aabab4..98969f6188 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.10",
"@braintree/sanitize-url": "^6.0.2",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
diff --git a/patches/expo-modules-core+1.12.11.patch b/patches/expo-modules-core+1.12.11.patch
new file mode 100644
index 0000000000..60fe47f91a
--- /dev/null
+++ b/patches/expo-modules-core+1.12.11.patch
@@ -0,0 +1,12 @@
+diff --git a/node_modules/expo-modules-core/build/uuid/uuid.js b/node_modules/expo-modules-core/build/uuid/uuid.js
+index 109d3fe..c7fce9e 100644
+--- a/node_modules/expo-modules-core/build/uuid/uuid.js
++++ b/node_modules/expo-modules-core/build/uuid/uuid.js
+@@ -1,5 +1,7 @@
+ import bytesToUuid from './lib/bytesToUuid';
+ import { Uuidv5Namespace } from './uuid.types';
++import { ensureNativeModulesAreInstalled } from '../ensureNativeModulesAreInstalled';
++ensureNativeModulesAreInstalled();
+ const nativeUuidv4 = globalThis?.expo?.uuidv4;
+ const nativeUuidv5 = globalThis?.expo?.uuidv5;
+ function uuidv4() {
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/Lists.tsx b/src/components/Lists.tsx
index e706e101f5..beeb554763 100644
--- a/src/components/Lists.tsx
+++ b/src/components/Lists.tsx
@@ -122,8 +122,16 @@ export function ListHeaderDesktop({
if (!gtTablet) return null
return (
-
- {title}
+
+ {title}
{subtitle ? (
{subtitle}
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 c9e425197b..293729b1bc 100644
--- a/src/components/TagMenu/index.web.tsx
+++ b/src/components/TagMenu/index.web.tsx
@@ -1,6 +1,16 @@
import React from 'react'
+// import {NavigationProp} from '#/lib/routes/types'
+// import {isInvalidHandle} from '#/lib/strings/handles'
+// import {enforceLen} from '#/lib/strings/helpers'
+// import {
+// usePreferencesQuery,
+// useRemoveMutedWordsMutation,
+// useUpsertMutedWordsMutation,
+// } from '#/state/queries/preferences'
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'
// @TODO Fabric
@@ -32,8 +42,8 @@ export function TagMenu({}: React.PropsWithChildren<{
// 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'),
@@ -41,10 +51,21 @@ export function TagMenu({}: React.PropsWithChildren<{
// 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 [
// {
@@ -90,9 +111,11 @@ export function TagMenu({}: React.PropsWithChildren<{
// : _(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',
@@ -114,7 +137,8 @@ export function TagMenu({}: React.PropsWithChildren<{
// 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/components/moderation/ScreenHider.tsx b/src/components/moderation/ScreenHider.tsx
index 0d316bc885..f855d63331 100644
--- a/src/components/moderation/ScreenHider.tsx
+++ b/src/components/moderation/ScreenHider.tsx
@@ -14,7 +14,7 @@ import {useModerationCauseDescription} from '#/lib/moderation/useModerationCause
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {NavigationProp} from 'lib/routes/types'
import {CenteredView} from '#/view/com/util/Views'
-import {atoms as a, useTheme} from '#/alf'
+import {atoms as a, useTheme, web} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import {
ModerationDetailsDialog,
@@ -105,6 +105,7 @@ export function ScreenHider({
a.mb_md,
a.px_lg,
a.text_center,
+ a.leading_snug,
t.atoms.text_contrast_medium,
]}>
{isNoPwi ? (
@@ -113,8 +114,15 @@ export function ScreenHider({
) : (
<>
- This {screenDescription} has been flagged:
-
+ This {screenDescription} has been flagged:{' '}
+
{desc.name}.{' '}
Learn More
-
>
)}{' '}
diff --git a/src/lib/api/feed-manip.ts b/src/lib/api/feed-manip.ts
index 89f6a0bb45..7ddb79434a 100644
--- a/src/lib/api/feed-manip.ts
+++ b/src/lib/api/feed-manip.ts
@@ -82,10 +82,6 @@ export class FeedViewPostsSlice {
return AppBskyFeedDefs.isReasonRepost(reason)
}
- get includesThreadRoot() {
- return !this.items[0].reply
- }
-
get likeCount() {
return this._feedPost.post.likeCount ?? 0
}
@@ -119,30 +115,19 @@ export class FeedViewPostsSlice {
isFollowingAllAuthors(userDid: string) {
const feedPost = this._feedPost
- if (feedPost.post.author.did === userDid) {
- return true
- }
- if (AppBskyFeedDefs.isPostView(feedPost.reply?.parent)) {
- const parent = feedPost.reply?.parent
- if (parent?.author.did === userDid) {
- return true
+ const authors = [feedPost.post.author]
+ if (feedPost.reply) {
+ if (AppBskyFeedDefs.isPostView(feedPost.reply.parent)) {
+ authors.push(feedPost.reply.parent.author)
+ }
+ if (feedPost.reply.grandparentAuthor) {
+ authors.push(feedPost.reply.grandparentAuthor)
+ }
+ if (AppBskyFeedDefs.isPostView(feedPost.reply.root)) {
+ authors.push(feedPost.reply.root.author)
}
- return (
- parent?.author.viewer?.following &&
- feedPost.post.author.viewer?.following
- )
}
- return false
- }
-}
-
-export class NoopFeedTuner {
- reset() {}
- tune(
- feed: FeedViewPost[],
- _opts?: {dryRun: boolean; maintainOrder: boolean},
- ): FeedViewPostsSlice[] {
- return feed.map(item => new FeedViewPostsSlice(item))
+ return authors.every(a => a.did === userDid || a.viewer?.following)
}
}
@@ -309,34 +294,19 @@ 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[],
): FeedViewPostsSlice[] => {
- // remove any replies without at least minLikes likes
for (let i = slices.length - 1; i >= 0; i--) {
const slice = slices[i]
- if (slice.isReply) {
- if (slice.isThread && slice.includesThreadRoot) {
- continue
- }
- if (slice.isRepost) {
- continue
- }
- if (slice.likeCount < minLikes) {
- slices.splice(i, 1)
- } else if (followedOnly && !slice.isFollowingAllAuthors(userDid)) {
- slices.splice(i, 1)
- }
+ if (
+ slice.isReply &&
+ !slice.isRepost &&
+ !slice.isFollowingAllAuthors(userDid)
+ ) {
+ slices.splice(i, 1)
}
}
return slices
diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts
index 5b1c998cb8..12e30bf6c1 100644
--- a/src/lib/api/index.ts
+++ b/src/lib/api/index.ts
@@ -54,6 +54,10 @@ interface PostOpts {
uri: string
cid: string
}
+ video?: {
+ uri: string
+ cid: string
+ }
extLink?: ExternalEmbedDraft
images?: ImageModel[]
labels?: string[]
diff --git a/src/lib/hooks/useDedupe.ts b/src/lib/hooks/useDedupe.ts
index d9432cb2c2..13b5b83f58 100644
--- a/src/lib/hooks/useDedupe.ts
+++ b/src/lib/hooks/useDedupe.ts
@@ -3,7 +3,7 @@ import React from 'react'
export const useDedupe = () => {
const canDo = React.useRef(true)
- return React.useRef((cb: () => unknown) => {
+ return React.useCallback((cb: () => unknown) => {
if (canDo.current) {
canDo.current = false
setTimeout(() => {
@@ -13,5 +13,5 @@ export const useDedupe = () => {
return true
}
return false
- }).current
+ }, [])
}
diff --git a/src/lib/media/video/types.ts b/src/lib/media/video/types.ts
new file mode 100644
index 0000000000..c458da96e0
--- /dev/null
+++ b/src/lib/media/video/types.ts
@@ -0,0 +1,36 @@
+/**
+ * TEMPORARY: THIS IS A TEMPORARY PLACEHOLDER. THAT MEANS IT IS TEMPORARY. I.E. WILL BE REMOVED. NOT TO USE IN PRODUCTION.
+ * @temporary
+ * PS: This is a temporary placeholder for the video types. It will be removed once the actual types are implemented.
+ * Not joking, this is temporary.
+ */
+
+export interface JobStatus {
+ jobId: string
+ did: string
+ cid: string
+ state: JobState
+ progress?: number
+ errorHuman?: string
+ errorMachine?: string
+}
+
+export enum JobState {
+ JOB_STATE_UNSPECIFIED = 'JOB_STATE_UNSPECIFIED',
+ JOB_STATE_CREATED = 'JOB_STATE_CREATED',
+ JOB_STATE_ENCODING = 'JOB_STATE_ENCODING',
+ JOB_STATE_ENCODED = 'JOB_STATE_ENCODED',
+ JOB_STATE_UPLOADING = 'JOB_STATE_UPLOADING',
+ JOB_STATE_UPLOADED = 'JOB_STATE_UPLOADED',
+ JOB_STATE_CDN_PROCESSING = 'JOB_STATE_CDN_PROCESSING',
+ JOB_STATE_CDN_PROCESSED = 'JOB_STATE_CDN_PROCESSED',
+ JOB_STATE_FAILED = 'JOB_STATE_FAILED',
+ JOB_STATE_COMPLETED = 'JOB_STATE_COMPLETED',
+}
+
+export interface UploadVideoResponse {
+ job_id: string
+ did: string
+ cid: string
+ state: JobState
+}
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/screens/StarterPack/Wizard/StepFeeds.tsx b/src/screens/StarterPack/Wizard/StepFeeds.tsx
index de8d856aba..f047b612ae 100644
--- a/src/screens/StarterPack/Wizard/StepFeeds.tsx
+++ b/src/screens/StarterPack/Wizard/StepFeeds.tsx
@@ -8,8 +8,8 @@ import {useA11y} from '#/state/a11y'
import {DISCOVER_FEED_URI} from 'lib/constants'
import {
useGetPopularFeedsQuery,
+ usePopularFeedsSearch,
useSavedFeeds,
- useSearchPopularFeedsQuery,
} from 'state/queries/feed'
import {SearchInput} from 'view/com/util/forms/SearchInput'
import {List} from 'view/com/util/List'
@@ -59,7 +59,7 @@ export function StepFeeds({moderationOpts}: {moderationOpts: ModerationOpts}) {
: undefined
const {data: searchedFeeds, isFetching: isFetchingSearchedFeeds} =
- useSearchPopularFeedsQuery({q: throttledQuery})
+ usePopularFeedsSearch({query: throttledQuery})
const isLoading =
!isFetchedSavedFeeds || isLoadingPopularFeeds || isFetchingSearchedFeeds
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/feed.ts b/src/state/queries/feed.ts
index 36555c1813..2b6751e890 100644
--- a/src/state/queries/feed.ts
+++ b/src/state/queries/feed.ts
@@ -5,6 +5,7 @@ import {
AppBskyGraphDefs,
AppBskyUnspeccedGetPopularFeedGenerators,
AtUri,
+ moderateFeedGenerator,
RichText,
} from '@atproto/api'
import {
@@ -26,6 +27,7 @@ import {RQKEY as listQueryKey} from '#/state/queries/list'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {useAgent, useSession} from '#/state/session'
import {router} from '#/routes'
+import {useModerationOpts} from '../preferences/moderation-opts'
import {FeedDescriptor} from './post-feed'
import {precacheResolvedUri} from './resolve-uri'
@@ -207,14 +209,16 @@ export function useGetPopularFeedsQuery(options?: GetPopularFeedsOptions) {
const limit = options?.limit || 10
const {data: preferences} = usePreferencesQuery()
const queryClient = useQueryClient()
+ const moderationOpts = useModerationOpts()
// Make sure this doesn't invalidate unless really needed.
const selectArgs = useMemo(
() => ({
hasSession,
savedFeeds: preferences?.savedFeeds || [],
+ moderationOpts,
}),
- [hasSession, preferences?.savedFeeds],
+ [hasSession, preferences?.savedFeeds, moderationOpts],
)
const lastPageCountRef = useRef(0)
@@ -225,6 +229,7 @@ export function useGetPopularFeedsQuery(options?: GetPopularFeedsOptions) {
QueryKey,
string | undefined
>({
+ enabled: Boolean(moderationOpts),
queryKey: createGetPopularFeedsQueryKey(options),
queryFn: async ({pageParam}) => {
const res = await agent.app.bsky.unspecced.getPopularFeedGenerators({
@@ -246,7 +251,11 @@ export function useGetPopularFeedsQuery(options?: GetPopularFeedsOptions) {
(
data: InfiniteData,
) => {
- const {savedFeeds, hasSession: hasSessionInner} = selectArgs
+ const {
+ savedFeeds,
+ hasSession: hasSessionInner,
+ moderationOpts,
+ } = selectArgs
return {
...data,
pages: data.pages.map(page => {
@@ -264,7 +273,8 @@ export function useGetPopularFeedsQuery(options?: GetPopularFeedsOptions) {
return f.value === feed.uri
}),
)
- return !alreadySaved
+ const decision = moderateFeedGenerator(feed, moderationOpts!)
+ return !alreadySaved && !decision.ui('contentList').filter
}),
}
}),
@@ -304,6 +314,8 @@ export function useGetPopularFeedsQuery(options?: GetPopularFeedsOptions) {
export function useSearchPopularFeedsMutation() {
const agent = useAgent()
+ const moderationOpts = useModerationOpts()
+
return useMutation({
mutationFn: async (query: string) => {
const res = await agent.app.bsky.unspecced.getPopularFeedGenerators({
@@ -311,24 +323,15 @@ export function useSearchPopularFeedsMutation() {
query: query,
})
- return res.data.feeds
- },
- })
-}
-
-export function useSearchPopularFeedsQuery({q}: {q: string}) {
- const agent = useAgent()
- return useQuery({
- queryKey: ['searchPopularFeeds', q],
- queryFn: async () => {
- const res = await agent.app.bsky.unspecced.getPopularFeedGenerators({
- limit: 15,
- query: q,
- })
+ if (moderationOpts) {
+ return res.data.feeds.filter(feed => {
+ const decision = moderateFeedGenerator(feed, moderationOpts)
+ return !decision.ui('contentList').filter
+ })
+ }
return res.data.feeds
},
- placeholderData: keepPreviousData,
})
}
@@ -346,17 +349,27 @@ export function usePopularFeedsSearch({
enabled?: boolean
}) {
const agent = useAgent()
+ const moderationOpts = useModerationOpts()
+ const enabledInner = enabled ?? Boolean(moderationOpts)
+
return useQuery({
- enabled,
+ enabled: enabledInner,
queryKey: createPopularFeedsSearchQueryKey(query),
queryFn: async () => {
const res = await agent.app.bsky.unspecced.getPopularFeedGenerators({
- limit: 10,
+ limit: 15,
query: query,
})
return res.data.feeds
},
+ placeholderData: keepPreviousData,
+ select(data) {
+ return data.filter(feed => {
+ const decision = moderateFeedGenerator(feed, moderationOpts!)
+ return !decision.ui('contentList').filter
+ })
+ },
})
}
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/state/queries/profile-feedgens.ts b/src/state/queries/profile-feedgens.ts
index 8ad12ab611..b50a2a2890 100644
--- a/src/state/queries/profile-feedgens.ts
+++ b/src/state/queries/profile-feedgens.ts
@@ -1,7 +1,8 @@
-import {AppBskyFeedGetActorFeeds} from '@atproto/api'
+import {AppBskyFeedGetActorFeeds, moderateFeedGenerator} from '@atproto/api'
import {InfiniteData, QueryKey, useInfiniteQuery} from '@tanstack/react-query'
import {useAgent} from '#/state/session'
+import {useModerationOpts} from '../preferences/moderation-opts'
const PAGE_SIZE = 50
type RQPageParam = string | undefined
@@ -14,7 +15,8 @@ export function useProfileFeedgensQuery(
did: string,
opts?: {enabled?: boolean},
) {
- const enabled = opts?.enabled !== false
+ const moderationOpts = useModerationOpts()
+ const enabled = opts?.enabled !== false && Boolean(moderationOpts)
const agent = useAgent()
return useInfiniteQuery<
AppBskyFeedGetActorFeeds.OutputSchema,
@@ -38,5 +40,21 @@ export function useProfileFeedgensQuery(
initialPageParam: undefined,
getNextPageParam: lastPage => lastPage.cursor,
enabled,
+ select(data) {
+ return {
+ ...data,
+ pages: data.pages.map(page => {
+ return {
+ ...page,
+ feeds: page.feeds
+ // filter by labels
+ .filter(list => {
+ const decision = moderateFeedGenerator(list, moderationOpts!)
+ return !decision.ui('contentList').filter
+ }),
+ }
+ }),
+ }
+ },
})
}
diff --git a/src/state/queries/profile-lists.ts b/src/state/queries/profile-lists.ts
index 112a62c839..75e3dd6e48 100644
--- a/src/state/queries/profile-lists.ts
+++ b/src/state/queries/profile-lists.ts
@@ -1,7 +1,8 @@
-import {AppBskyGraphGetLists} from '@atproto/api'
+import {AppBskyGraphGetLists, moderateUserList} from '@atproto/api'
import {InfiniteData, QueryKey, useInfiniteQuery} from '@tanstack/react-query'
import {useAgent} from '#/state/session'
+import {useModerationOpts} from '../preferences/moderation-opts'
const PAGE_SIZE = 30
type RQPageParam = string | undefined
@@ -10,7 +11,8 @@ const RQKEY_ROOT = 'profile-lists'
export const RQKEY = (did: string) => [RQKEY_ROOT, did]
export function useProfileListsQuery(did: string, opts?: {enabled?: boolean}) {
- const enabled = opts?.enabled !== false
+ const moderationOpts = useModerationOpts()
+ const enabled = opts?.enabled !== false && Boolean(moderationOpts)
const agent = useAgent()
return useInfiniteQuery<
AppBskyGraphGetLists.OutputSchema,
@@ -27,17 +29,32 @@ export function useProfileListsQuery(did: string, opts?: {enabled?: boolean}) {
cursor: pageParam,
})
- // Starter packs use a reference list, which we do not want to show on profiles. At some point we could probably
- // just filter this out on the backend instead of in the client.
- return {
- ...res.data,
- lists: res.data.lists.filter(
- l => l.purpose !== 'app.bsky.graph.defs#referencelist',
- ),
- }
+ return res.data
},
initialPageParam: undefined,
getNextPageParam: lastPage => lastPage.cursor,
enabled,
+ select(data) {
+ return {
+ ...data,
+ pages: data.pages.map(page => {
+ return {
+ ...page,
+ lists: page.lists
+ /*
+ * Starter packs use a reference list, which we do not want to
+ * show on profiles. At some point we could probably just filter
+ * this out on the backend instead of in the client.
+ */
+ .filter(l => l.purpose !== 'app.bsky.graph.defs#referencelist')
+ // filter by labels
+ .filter(list => {
+ const decision = moderateUserList(list, moderationOpts!)
+ return !decision.ui('contentList').filter
+ }),
+ }
+ }),
+ }
+ },
})
}
diff --git a/src/state/queries/video/compress-video.ts b/src/state/queries/video/compress-video.ts
new file mode 100644
index 0000000000..a2c739cfde
--- /dev/null
+++ b/src/state/queries/video/compress-video.ts
@@ -0,0 +1,31 @@
+import {ImagePickerAsset} from 'expo-image-picker'
+import {useMutation} from '@tanstack/react-query'
+
+import {CompressedVideo, compressVideo} from 'lib/media/video/compress'
+
+export function useCompressVideoMutation({
+ onProgress,
+ onSuccess,
+ onError,
+}: {
+ onProgress: (progress: number) => void
+ onError: (e: any) => void
+ onSuccess: (video: CompressedVideo) => void
+}) {
+ return useMutation({
+ mutationFn: async (asset: ImagePickerAsset) => {
+ return await compressVideo(asset.uri, {
+ onProgress: num => onProgress(trunc2dp(num)),
+ })
+ },
+ onError,
+ onSuccess,
+ onMutate: () => {
+ onProgress(0)
+ },
+ })
+}
+
+function trunc2dp(num: number) {
+ return Math.trunc(num * 100) / 100
+}
diff --git a/src/state/queries/video/util.ts b/src/state/queries/video/util.ts
new file mode 100644
index 0000000000..266d8aee37
--- /dev/null
+++ b/src/state/queries/video/util.ts
@@ -0,0 +1,15 @@
+const UPLOAD_ENDPOINT = process.env.EXPO_PUBLIC_VIDEO_ROOT_ENDPOINT ?? ''
+
+export const createVideoEndpointUrl = (
+ route: string,
+ params?: Record,
+) => {
+ const url = new URL(`${UPLOAD_ENDPOINT}`)
+ url.pathname = route
+ if (params) {
+ for (const key in params) {
+ url.searchParams.set(key, params[key])
+ }
+ }
+ return url.href
+}
diff --git a/src/state/queries/video/video-upload.ts b/src/state/queries/video/video-upload.ts
new file mode 100644
index 0000000000..4d7f7995c5
--- /dev/null
+++ b/src/state/queries/video/video-upload.ts
@@ -0,0 +1,59 @@
+import {createUploadTask, FileSystemUploadType} from 'expo-file-system'
+import {useMutation} from '@tanstack/react-query'
+import {nanoid} from 'nanoid/non-secure'
+
+import {CompressedVideo} from 'lib/media/video/compress'
+import {UploadVideoResponse} from 'lib/media/video/types'
+import {createVideoEndpointUrl} from 'state/queries/video/util'
+import {useSession} from 'state/session'
+const UPLOAD_HEADER = process.env.EXPO_PUBLIC_VIDEO_HEADER ?? ''
+
+export const useUploadVideoMutation = ({
+ onSuccess,
+ onError,
+ setProgress,
+}: {
+ onSuccess: (response: UploadVideoResponse) => void
+ onError: (e: any) => void
+ setProgress: (progress: number) => void
+}) => {
+ const {currentAccount} = useSession()
+
+ return useMutation({
+ mutationFn: async (video: CompressedVideo) => {
+ const uri = createVideoEndpointUrl('/upload', {
+ did: currentAccount!.did,
+ name: `${nanoid(12)}.mp4`, // @TODO what are we limiting this to?
+ })
+
+ const uploadTask = createUploadTask(
+ uri,
+ video.uri,
+ {
+ headers: {
+ 'dev-key': UPLOAD_HEADER,
+ 'content-type': 'video/mp4', // @TODO same question here. does the compression step always output mp4?
+ },
+ httpMethod: 'POST',
+ uploadType: FileSystemUploadType.BINARY_CONTENT,
+ },
+ p => {
+ setProgress(p.totalBytesSent / p.totalBytesExpectedToSend)
+ },
+ )
+ const res = await uploadTask.uploadAsync()
+
+ if (!res?.body) {
+ throw new Error('No response')
+ }
+
+ // @TODO rm, useful for debugging/getting video cid
+ console.log('[VIDEO]', res.body)
+ const responseBody = JSON.parse(res.body) as UploadVideoResponse
+ onSuccess(responseBody)
+ return responseBody
+ },
+ onError,
+ onSuccess,
+ })
+}
diff --git a/src/state/queries/video/video-upload.web.ts b/src/state/queries/video/video-upload.web.ts
new file mode 100644
index 0000000000..b5b9e93bf9
--- /dev/null
+++ b/src/state/queries/video/video-upload.web.ts
@@ -0,0 +1,66 @@
+import {useMutation} from '@tanstack/react-query'
+import {nanoid} from 'nanoid/non-secure'
+
+import {CompressedVideo} from 'lib/media/video/compress'
+import {UploadVideoResponse} from 'lib/media/video/types'
+import {createVideoEndpointUrl} from 'state/queries/video/util'
+import {useSession} from 'state/session'
+const UPLOAD_HEADER = process.env.EXPO_PUBLIC_VIDEO_HEADER ?? ''
+
+export const useUploadVideoMutation = ({
+ onSuccess,
+ onError,
+ setProgress,
+}: {
+ onSuccess: (response: UploadVideoResponse) => void
+ onError: (e: any) => void
+ setProgress: (progress: number) => void
+}) => {
+ const {currentAccount} = useSession()
+
+ return useMutation({
+ mutationFn: async (video: CompressedVideo) => {
+ const uri = createVideoEndpointUrl('/upload', {
+ did: currentAccount!.did,
+ name: `${nanoid(12)}.mp4`, // @TODO what are we limiting this to?
+ })
+
+ const bytes = await fetch(video.uri).then(res => res.arrayBuffer())
+
+ const xhr = new XMLHttpRequest()
+ const res = (await new Promise((resolve, reject) => {
+ xhr.upload.addEventListener('progress', e => {
+ const progress = e.loaded / e.total
+ setProgress(progress)
+ })
+ xhr.onloadend = () => {
+ if (xhr.readyState === 4) {
+ const uploadRes = JSON.parse(
+ xhr.responseText,
+ ) as UploadVideoResponse
+ resolve(uploadRes)
+ onSuccess(uploadRes)
+ } else {
+ reject()
+ onError(new Error('Failed to upload video'))
+ }
+ }
+ xhr.onerror = () => {
+ reject()
+ onError(new Error('Failed to upload video'))
+ }
+ xhr.open('POST', uri)
+ xhr.setRequestHeader('Content-Type', 'video/mp4') // @TODO how we we set the proper content type?
+ // @TODO remove this header for prod
+ xhr.setRequestHeader('dev-key', UPLOAD_HEADER)
+ xhr.send(bytes)
+ })) as UploadVideoResponse
+
+ // @TODO rm for prod
+ console.log('[VIDEO]', res)
+ return res
+ },
+ onError,
+ onSuccess,
+ })
+}
diff --git a/src/state/queries/video/video.ts b/src/state/queries/video/video.ts
new file mode 100644
index 0000000000..295db38b43
--- /dev/null
+++ b/src/state/queries/video/video.ts
@@ -0,0 +1,212 @@
+import React from 'react'
+import {ImagePickerAsset} from 'expo-image-picker'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useQuery} from '@tanstack/react-query'
+
+import {logger} from '#/logger'
+import {CompressedVideo} from 'lib/media/video/compress'
+import {VideoTooLargeError} from 'lib/media/video/errors'
+import {JobState, JobStatus} from 'lib/media/video/types'
+import {useCompressVideoMutation} from 'state/queries/video/compress-video'
+import {createVideoEndpointUrl} from 'state/queries/video/util'
+import {useUploadVideoMutation} from 'state/queries/video/video-upload'
+
+type Status = 'idle' | 'compressing' | 'processing' | 'uploading' | 'done'
+
+type Action =
+ | {
+ type: 'SetStatus'
+ status: Status
+ }
+ | {
+ type: 'SetProgress'
+ progress: number
+ }
+ | {
+ type: 'SetError'
+ error: string | undefined
+ }
+ | {type: 'Reset'}
+ | {type: 'SetAsset'; asset: ImagePickerAsset}
+ | {type: 'SetVideo'; video: CompressedVideo}
+ | {type: 'SetJobStatus'; jobStatus: JobStatus}
+
+export interface State {
+ status: Status
+ progress: number
+ asset?: ImagePickerAsset
+ video: CompressedVideo | null
+ jobStatus?: JobStatus
+ error?: string
+}
+
+function reducer(state: State, action: Action): State {
+ let updatedState = state
+ if (action.type === 'SetStatus') {
+ updatedState = {...state, status: action.status}
+ } else if (action.type === 'SetProgress') {
+ updatedState = {...state, progress: action.progress}
+ } else if (action.type === 'SetError') {
+ updatedState = {...state, error: action.error}
+ } else if (action.type === 'Reset') {
+ updatedState = {
+ status: 'idle',
+ progress: 0,
+ video: null,
+ }
+ } else if (action.type === 'SetAsset') {
+ updatedState = {...state, asset: action.asset}
+ } else if (action.type === 'SetVideo') {
+ updatedState = {...state, video: action.video}
+ } else if (action.type === 'SetJobStatus') {
+ updatedState = {...state, jobStatus: action.jobStatus}
+ }
+ return updatedState
+}
+
+export function useUploadVideo({
+ setStatus,
+ onSuccess,
+}: {
+ setStatus: (status: string) => void
+ onSuccess: () => void
+}) {
+ const {_} = useLingui()
+ const [state, dispatch] = React.useReducer(reducer, {
+ status: 'idle',
+ progress: 0,
+ video: null,
+ })
+
+ const {setJobId} = useUploadStatusQuery({
+ onStatusChange: (status: JobStatus) => {
+ // This might prove unuseful, most of the job status steps happen too quickly to even be displayed to the user
+ // Leaving it for now though
+ dispatch({
+ type: 'SetJobStatus',
+ jobStatus: status,
+ })
+ setStatus(status.state.toString())
+ },
+ onSuccess: () => {
+ dispatch({
+ type: 'SetStatus',
+ status: 'idle',
+ })
+ onSuccess()
+ },
+ })
+
+ const {mutate: onVideoCompressed} = useUploadVideoMutation({
+ onSuccess: response => {
+ dispatch({
+ type: 'SetStatus',
+ status: 'processing',
+ })
+ setJobId(response.job_id)
+ },
+ onError: e => {
+ dispatch({
+ type: 'SetError',
+ error: _(msg`An error occurred while uploading the video.`),
+ })
+ logger.error('Error uploading video', {safeMessage: e})
+ },
+ setProgress: p => {
+ dispatch({type: 'SetProgress', progress: p})
+ },
+ })
+
+ const {mutate: onSelectVideo} = useCompressVideoMutation({
+ onProgress: p => {
+ dispatch({type: 'SetProgress', progress: p})
+ },
+ onError: e => {
+ if (e instanceof VideoTooLargeError) {
+ dispatch({
+ type: 'SetError',
+ error: _(msg`The selected video is larger than 100MB.`),
+ })
+ } else {
+ dispatch({
+ type: 'SetError',
+ // @TODO better error message from server, left untranslated on purpose
+ error: 'An error occurred while compressing the video.',
+ })
+ logger.error('Error compressing video', {safeMessage: e})
+ }
+ },
+ onSuccess: (video: CompressedVideo) => {
+ dispatch({
+ type: 'SetVideo',
+ video,
+ })
+ dispatch({
+ type: 'SetStatus',
+ status: 'uploading',
+ })
+ onVideoCompressed(video)
+ },
+ })
+
+ const selectVideo = (asset: ImagePickerAsset) => {
+ dispatch({
+ type: 'SetAsset',
+ asset,
+ })
+ dispatch({
+ type: 'SetStatus',
+ status: 'compressing',
+ })
+ onSelectVideo(asset)
+ }
+
+ const clearVideo = () => {
+ // @TODO cancel any running jobs
+ dispatch({type: 'Reset'})
+ }
+
+ return {
+ state,
+ dispatch,
+ selectVideo,
+ clearVideo,
+ }
+}
+
+const useUploadStatusQuery = ({
+ onStatusChange,
+ onSuccess,
+}: {
+ onStatusChange: (status: JobStatus) => void
+ onSuccess: () => void
+}) => {
+ const [enabled, setEnabled] = React.useState(true)
+ const [jobId, setJobId] = React.useState()
+
+ const {isLoading, isError} = useQuery({
+ queryKey: ['video-upload'],
+ queryFn: async () => {
+ const url = createVideoEndpointUrl(`/job/${jobId}/status`)
+ const res = await fetch(url)
+ const status = (await res.json()) as JobStatus
+ if (status.state === JobState.JOB_STATE_COMPLETED) {
+ setEnabled(false)
+ onSuccess()
+ }
+ onStatusChange(status)
+ return status
+ },
+ enabled: Boolean(jobId && enabled),
+ refetchInterval: 1500,
+ })
+
+ return {
+ isLoading,
+ isError,
+ setJobId: (_jobId: string) => {
+ setJobId(_jobId)
+ },
+ }
+}
diff --git a/src/state/shell/post-progress.tsx b/src/state/shell/post-progress.tsx
new file mode 100644
index 0000000000..0df2a6be4a
--- /dev/null
+++ b/src/state/shell/post-progress.tsx
@@ -0,0 +1,18 @@
+import React from 'react'
+
+interface PostProgressState {
+ progress: number
+ status: 'pending' | 'success' | 'error' | 'idle'
+ error?: string
+}
+
+const PostProgressContext = React.createContext({
+ progress: 0,
+ status: 'idle',
+})
+
+export function Provider() {}
+
+export function usePostProgress() {
+ return React.useContext(PostProgressContext)
+}
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index 72b6fae5fd..08ce4441f0 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -13,10 +13,16 @@ import {
Keyboard,
KeyboardAvoidingView,
LayoutChangeEvent,
+ StyleProp,
StyleSheet,
View,
+ ViewStyle,
} from 'react-native'
+// @ts-expect-error no type definition
+import ProgressCircle from 'react-native-progress/Circle'
import Animated, {
+ FadeIn,
+ FadeOut,
interpolateColor,
useAnimatedStyle,
useSharedValue,
@@ -55,6 +61,7 @@ import {
import {useProfileQuery} from '#/state/queries/profile'
import {Gif} from '#/state/queries/tenor'
import {ThreadgateSetting} from '#/state/queries/threadgate'
+import {useUploadVideo} from '#/state/queries/video/video'
import {useAgent, useSession} from '#/state/session'
import {useComposerControls} from '#/state/shell/composer'
import {useAnalytics} from 'lib/analytics/analytics'
@@ -70,6 +77,7 @@ import {colors, s} from 'lib/styles'
import {isAndroid, isIOS, isNative, isWeb} from 'platform/detection'
import {useDialogStateControlContext} from 'state/dialogs'
import {GalleryModel} from 'state/models/media/gallery'
+import {State as VideoUploadState} from 'state/queries/video/video'
import {ComposerOpts} from 'state/shell/composer'
import {ComposerReplyTo} from 'view/com/composer/ComposerReplyTo'
import {atoms as a, useTheme} from '#/alf'
@@ -96,7 +104,6 @@ import {TextInput, TextInputRef} from './text-input/TextInput'
import {ThreadgateBtn} from './threadgate/ThreadgateBtn'
import {useExternalLinkFetch} from './useExternalLinkFetch'
import {SelectVideoBtn} from './videos/SelectVideoBtn'
-import {useVideoState} from './videos/state'
import {VideoPreview} from './videos/VideoPreview'
import {VideoTranscodeProgress} from './videos/VideoTranscodeProgress'
@@ -159,14 +166,21 @@ export const ComposePost = observer(function ComposePost({
const [quote, setQuote] = useState(
initQuote,
)
+
const {
- video,
- onSelectVideo,
- videoPending,
- videoProcessingData,
+ selectVideo,
clearVideo,
- videoProcessingProgress,
- } = useVideoState({setError})
+ state: videoUploadState,
+ } = useUploadVideo({
+ setStatus: (status: string) => setProcessingState(status),
+ onSuccess: () => {
+ if (publishOnUpload) {
+ onPressPublish(true)
+ }
+ },
+ })
+ const [publishOnUpload, setPublishOnUpload] = useState(false)
+
const {extLink, setExtLink} = useExternalLinkFetch({setQuote})
const [extGif, setExtGif] = useState()
const [labels, setLabels] = useState([])
@@ -274,7 +288,7 @@ export const ComposePost = observer(function ComposePost({
return false
}, [gallery.needsAltText, extLink, extGif, requireAltTextEnabled])
- const onPressPublish = async () => {
+ const onPressPublish = async (finishedUploading?: boolean) => {
if (isProcessing || graphemeLength > MAX_GRAPHEME_LENGTH) {
return
}
@@ -283,6 +297,15 @@ export const ComposePost = observer(function ComposePost({
return
}
+ if (
+ !finishedUploading &&
+ videoUploadState.status !== 'idle' &&
+ videoUploadState.asset
+ ) {
+ setPublishOnUpload(true)
+ return
+ }
+
setError('')
if (
@@ -387,8 +410,12 @@ export const ComposePost = observer(function ComposePost({
: _(msg`What's up?`)
const canSelectImages =
- gallery.size < 4 && !extLink && !video && !videoPending
- const hasMedia = gallery.size > 0 || Boolean(extLink) || Boolean(video)
+ gallery.size < 4 &&
+ !extLink &&
+ videoUploadState.status === 'idle' &&
+ !videoUploadState.video
+ const hasMedia =
+ gallery.size > 0 || Boolean(extLink) || Boolean(videoUploadState.video)
const onEmojiButtonPress = useCallback(() => {
openPicker?.(textInput.current?.getCursorPosition())
@@ -500,7 +527,10 @@ export const ComposePost = observer(function ComposePost({
shape="default"
size="small"
style={[a.rounded_full, a.py_sm]}
- onPress={onPressPublish}>
+ onPress={() => onPressPublish()}
+ disabled={
+ videoUploadState.status !== 'idle' && publishOnUpload
+ }>
{replyTo ? (
Reply
@@ -572,7 +602,7 @@ export const ComposePost = observer(function ComposePost({
autoFocus
setRichText={setRichText}
onPhotoPasted={onPhotoPasted}
- onPressPublish={onPressPublish}
+ onPressPublish={() => onPressPublish()}
onNewLink={onNewLink}
onError={setError}
accessible={true}
@@ -602,29 +632,33 @@ export const ComposePost = observer(function ComposePost({
)}
- {quote ? (
-
-
-
+
+ {quote ? (
+
+
+
+
+ {quote.uri !== initQuote?.uri && (
+ setQuote(undefined)} />
+ )}
- {quote.uri !== initQuote?.uri && (
- setQuote(undefined)} />
- )}
-
- ) : null}
- {videoPending && videoProcessingData ? (
-
- ) : (
- video && (
+ ) : null}
+ {videoUploadState.status === 'compressing' &&
+ videoUploadState.asset ? (
+
+ ) : videoUploadState.video ? (
// remove suspense when we get rid of lazy
-
+
- )
- )}
+ ) : null}
+
@@ -641,33 +675,37 @@ export const ComposePost = observer(function ComposePost({
t.atoms.border_contrast_medium,
styles.bottomBar,
]}>
-
-
- {gate('videos') && (
-
+ ) : (
+
+
+ {gate('videos') && (
+
+ )}
+
+
- )}
-
-
- {!isMobile ? (
-
- ) : null}
-
+ {!isMobile ? (
+
+ ) : null}
+
+ )}
@@ -893,3 +931,44 @@ const styles = StyleSheet.create({
borderTopWidth: StyleSheet.hairlineWidth,
},
})
+
+function ToolbarWrapper({
+ style,
+ children,
+}: {
+ style: StyleProp
+ children: React.ReactNode
+}) {
+ if (isWeb) return children
+ return (
+
+ {children}
+
+ )
+}
+
+function VideoUploadToolbar({state}: {state: VideoUploadState}) {
+ const t = useTheme()
+
+ const progress =
+ state.status === 'compressing' || state.status === 'uploading'
+ ? state.progress
+ : state.jobStatus?.progress ?? 100
+
+ return (
+
+
+ {state.status}
+
+ )
+}
diff --git a/src/view/com/composer/videos/VideoPreview.tsx b/src/view/com/composer/videos/VideoPreview.tsx
index b04cdf1c8b..8e2a22852d 100644
--- a/src/view/com/composer/videos/VideoPreview.tsx
+++ b/src/view/com/composer/videos/VideoPreview.tsx
@@ -17,6 +17,7 @@ export function VideoPreview({
const player = useVideoPlayer(video.uri, player => {
player.loop = true
player.play()
+ player.volume = 0
})
return (
diff --git a/src/view/com/composer/videos/VideoTranscodeProgress.tsx b/src/view/com/composer/videos/VideoTranscodeProgress.tsx
index 79407cd3ef..db58448a30 100644
--- a/src/view/com/composer/videos/VideoTranscodeProgress.tsx
+++ b/src/view/com/composer/videos/VideoTranscodeProgress.tsx
@@ -9,15 +9,15 @@ import {Text} from '#/components/Typography'
import {VideoTranscodeBackdrop} from './VideoTranscodeBackdrop'
export function VideoTranscodeProgress({
- input,
+ asset,
progress,
}: {
- input: ImagePickerAsset
+ asset: ImagePickerAsset
progress: number
}) {
const t = useTheme()
- const aspectRatio = input.width / input.height
+ const aspectRatio = asset.width / asset.height
return (
-
+
void}) {
- const {_} = useLingui()
- const [progress, setProgress] = useState(0)
-
- const {mutate, data, isPending, isError, reset, variables} = useMutation({
- mutationFn: async (asset: ImagePickerAsset) => {
- const compressed = await compressVideo(asset.uri, {
- onProgress: num => setProgress(trunc2dp(num)),
- })
-
- return compressed
- },
- onError: (e: any) => {
- // Don't log these errors in sentry, just let the user know
- if (e instanceof VideoTooLargeError) {
- Toast.show(_(msg`Videos cannot be larger than 100MB`), 'xmark')
- return
- }
- logger.error('Failed to compress video', {safeError: e})
- setError(_(msg`Could not compress video`))
- },
- onMutate: () => {
- setProgress(0)
- },
- })
-
- return {
- video: data,
- onSelectVideo: mutate,
- videoPending: isPending,
- videoProcessingData: variables,
- videoError: isError,
- clearVideo: reset,
- videoProcessingProgress: progress,
- }
-}
-
-function trunc2dp(num: number) {
- return Math.trunc(num * 100) / 100
-}
diff --git a/src/view/com/feeds/ProfileFeedgens.tsx b/src/view/com/feeds/ProfileFeedgens.tsx
index 831ab4d1dd..6f98cc49a4 100644
--- a/src/view/com/feeds/ProfileFeedgens.tsx
+++ b/src/view/com/feeds/ProfileFeedgens.tsx
@@ -129,46 +129,49 @@ export const ProfileFeedgens = React.forwardRef<
// rendering
// =
- const renderItem = ({item, index}: ListRenderItemInfo) => {
- if (item === EMPTY) {
- return (
-
- )
- } else if (item === ERROR_ITEM) {
- return (
-
- )
- } else if (item === LOAD_MORE_ERROR_ITEM) {
- return (
-
- )
- } else if (item === LOADING) {
- return
- }
- if (preferences) {
- return (
-
-
-
- )
- }
- return null
- }
+ const renderItem = React.useCallback(
+ ({item, index}: ListRenderItemInfo) => {
+ if (item === EMPTY) {
+ return (
+
+ )
+ } else if (item === ERROR_ITEM) {
+ return (
+
+ )
+ } else if (item === LOAD_MORE_ERROR_ITEM) {
+ return (
+
+ )
+ } else if (item === LOADING) {
+ return
+ }
+ if (preferences) {
+ return (
+
+
+
+ )
+ }
+ return null
+ },
+ [_, t, error, refetch, onPressRetryLoadMore, preferences],
+ )
React.useEffect(() => {
if (enabled && scrollElRef.current) {
diff --git a/src/view/com/lists/ProfileLists.tsx b/src/view/com/lists/ProfileLists.tsx
index dc385d4361..f633774c7a 100644
--- a/src/view/com/lists/ProfileLists.tsx
+++ b/src/view/com/lists/ProfileLists.tsx
@@ -75,12 +75,7 @@ export const ProfileLists = React.forwardRef(
items = items.concat([EMPTY])
} else if (data?.pages) {
for (const page of data?.pages) {
- items = items.concat(
- page.lists.map(l => ({
- ...l,
- _reactKey: l.uri,
- })),
- )
+ items = items.concat(page.lists)
}
}
if (isError && !isEmpty) {
@@ -192,7 +187,7 @@ export const ProfileLists = React.forwardRef(
testID={testID ? `${testID}-flatlist` : undefined}
ref={scrollElRef}
data={items}
- keyExtractor={(item: any) => item._reactKey}
+ keyExtractor={(item: any) => item._reactKey || item.uri}
renderItem={renderItemInner}
refreshing={isPTRing}
onRefresh={onRefresh}
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/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx
index a0dc94e4d8..0462212fbd 100644
--- a/src/view/com/util/post-embeds/index.tsx
+++ b/src/view/com/util/post-embeds/index.tsx
@@ -15,11 +15,14 @@ import {
AppBskyEmbedRecordWithMedia,
AppBskyFeedDefs,
AppBskyGraphDefs,
+ moderateFeedGenerator,
+ moderateUserList,
ModerationDecision,
} from '@atproto/api'
import {ImagesLightbox, useLightboxControls} from '#/state/lightbox'
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
+import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {usePalette} from 'lib/hooks/usePalette'
import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
import {atoms as a} from '#/alf'
@@ -51,7 +54,6 @@ export function PostEmbeds({
style?: StyleProp
allowNestedQuotes?: boolean
}) {
- const pal = usePalette('default')
const {openLightbox} = useLightboxControls()
const largeAltBadge = useLargeAltBadgeEnabled()
@@ -72,22 +74,13 @@ export function PostEmbeds({
if (AppBskyEmbedRecord.isView(embed)) {
// custom feed embed (i.e. generator view)
- // =
if (AppBskyFeedDefs.isGeneratorView(embed.record)) {
- // TODO moderation
- return (
-
- )
+ return
}
// list embed
if (AppBskyGraphDefs.isListView(embed.record)) {
- // TODO moderation
- return
+ return
}
if (AppBskyGraphDefs.isStarterPackViewBasic(embed.record)) {
@@ -185,6 +178,39 @@ export function PostEmbeds({
return
}
+function MaybeFeedCard({view}: {view: AppBskyFeedDefs.GeneratorView}) {
+ const pal = usePalette('default')
+ const moderationOpts = useModerationOpts()
+ const moderation = React.useMemo(() => {
+ return moderationOpts
+ ? moderateFeedGenerator(view, moderationOpts)
+ : undefined
+ }, [view, moderationOpts])
+
+ return (
+
+
+
+ )
+}
+
+function MaybeListCard({view}: {view: AppBskyGraphDefs.ListView}) {
+ const moderationOpts = useModerationOpts()
+ const moderation = React.useMemo(() => {
+ return moderationOpts ? moderateUserList(view, moderationOpts) : undefined
+ }, [view, moderationOpts])
+
+ return (
+
+
+
+ )
+}
+
const styles = StyleSheet.create({
container: {
marginTop: 8,
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/ProfileList.tsx b/src/view/screens/ProfileList.tsx
index 0da840c5ab..1b41d8d9af 100644
--- a/src/view/screens/ProfileList.tsx
+++ b/src/view/screens/ProfileList.tsx
@@ -1,6 +1,12 @@
import React, {useCallback, useMemo} from 'react'
import {Pressable, StyleSheet, View} from 'react-native'
-import {AppBskyGraphDefs, AtUri, RichText as RichTextAPI} from '@atproto/api'
+import {
+ AppBskyGraphDefs,
+ AtUri,
+ moderateUserList,
+ ModerationOpts,
+ RichText as RichTextAPI,
+} from '@atproto/api'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -14,6 +20,7 @@ import {logger} from '#/logger'
import {isNative} from '#/platform/detection'
import {listenSoftReset} from '#/state/events'
import {useModalControls} from '#/state/modals'
+import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {
useListBlockMutation,
useListDeleteMutation,
@@ -59,6 +66,7 @@ import * as Toast from 'view/com/util/Toast'
import {CenteredView} from 'view/com/util/Views'
import {atoms as a, useTheme} from '#/alf'
import {useDialogControl} from '#/components/Dialog'
+import {ScreenHider} from '#/components/moderation/ScreenHider'
import * as Prompt from '#/components/Prompt'
import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog'
import {RichText} from '#/components/RichText'
@@ -80,6 +88,7 @@ export function ProfileListScreen(props: Props) {
AtUri.make(handleOrDid, 'app.bsky.graph.list', rkey).toString(),
)
const {data: list, error: listError} = useListQuery(resolvedUri?.uri)
+ const moderationOpts = useModerationOpts()
if (resolveError) {
return (
@@ -100,8 +109,13 @@ export function ProfileListScreen(props: Props) {
)
}
- return resolvedUri && list ? (
-
+ return resolvedUri && list && moderationOpts ? (
+
) : (
)
@@ -111,7 +125,12 @@ function ProfileListScreenLoaded({
route,
uri,
list,
-}: Props & {uri: string; list: AppBskyGraphDefs.ListView}) {
+ moderationOpts,
+}: Props & {
+ uri: string
+ list: AppBskyGraphDefs.ListView
+ moderationOpts: ModerationOpts
+}) {
const {_} = useLingui()
const queryClient = useQueryClient()
const {openComposer} = useComposerControls()
@@ -123,6 +142,10 @@ function ProfileListScreenLoaded({
const isCurateList = list.purpose === 'app.bsky.graph.defs#curatelist'
const isScreenFocused = useIsFocused()
+ const moderation = React.useMemo(() => {
+ return moderateUserList(list, moderationOpts)
+ }, [list, moderationOpts])
+
useSetTitle(list.name)
useFocusEffect(
@@ -160,26 +183,65 @@ function ProfileListScreenLoaded({
if (isCurateList) {
return (
+
+
+
+ {({headerHeight, scrollElRef, isFocused}) => (
+
+ )}
+ {({headerHeight, scrollElRef}) => (
+
+ )}
+
+ openComposer({})}
+ icon={
+
+ }
+ accessibilityRole="button"
+ accessibilityLabel={_(msg`New post`)}
+ accessibilityHint=""
+ />
+
+
+ )
+ }
+ return (
+
- {({headerHeight, scrollElRef, isFocused}) => (
-
- )}
+ renderHeader={renderHeader}>
{({headerHeight, scrollElRef}) => (
@@ -200,34 +262,7 @@ function ProfileListScreenLoaded({
accessibilityHint=""
/>
- )
- }
- return (
-
-
- {({headerHeight, scrollElRef}) => (
-
- )}
-
- openComposer({})}
- icon={
-
- }
- accessibilityRole="button"
- accessibilityLabel={_(msg`New post`)}
- accessibilityHint=""
- />
-
+
)
}
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
+
+