Merge branch 'main' into 0.74-fabric
This commit is contained in:
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M12.097 2.53a1 1 0 0 1-.041 1.07 6 6 0 0 0 8.345 8.344 1 1 0 0 1 1.563.908c-.434 5.122-4.728 9.144-9.962 9.144-5.522 0-9.998-4.476-9.998-9.998 0-5.234 4.021-9.528 9.144-9.962a1 1 0 0 1 .949.494ZM9.424 4.424a7.998 7.998 0 1 0 10.152 10.152A8 8 0 0 1 9.424 4.424Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 403 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M5 4a3 3 0 0 1 3-3h8a3 3 0 0 1 3 3v16a3 3 0 0 1-3 3H8a3 3 0 0 1-3-3V4Zm3-1a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1H8Zm2 2a1 1 0 0 1 1-1h2a1 1 0 1 1 0 2h-2a1 1 0 0 1-1-1Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 334 B |
@@ -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"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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() {
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="AppearanceSettings"
|
||||
getComponent={() => AppearanceSettingsScreen}
|
||||
options={{
|
||||
title: title(msg`Appearance Settings`),
|
||||
requireAuth: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Hashtag"
|
||||
getComponent={() => HashtagScreen}
|
||||
|
||||
@@ -122,8 +122,16 @@ export function ListHeaderDesktop({
|
||||
if (!gtTablet) return null
|
||||
|
||||
return (
|
||||
<View style={[a.w_full, a.py_lg, a.px_xl, a.gap_xs]}>
|
||||
<Text style={[a.text_3xl, a.font_bold]}>{title}</Text>
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.py_sm,
|
||||
a.px_xl,
|
||||
a.gap_xs,
|
||||
a.justify_center,
|
||||
{minHeight: 50},
|
||||
]}>
|
||||
<Text style={[a.text_2xl, a.font_bold]}>{title}</Text>
|
||||
{subtitle ? (
|
||||
<Text style={[a.text_md, t.atoms.text_contrast_medium]}>
|
||||
{subtitle}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
])
|
||||
}
|
||||
})
|
||||
}}>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
<Dialog.ScrollableInner label={_(msg`Manage your muted words and tags`)}>
|
||||
<View onTouchStart={Keyboard.dismiss}>
|
||||
<View>
|
||||
<Text
|
||||
style={[a.text_md, a.font_bold, a.pb_sm, t.atoms.text_contrast_high]}>
|
||||
<Trans>Add muted words and tags</Trans>
|
||||
</Text>
|
||||
<Text style={[a.pb_lg, a.leading_snug, t.atoms.text_contrast_medium]}>
|
||||
<Trans>
|
||||
Posts can be muted based on their text, their tags, or both.
|
||||
Posts can be muted based on their text, their tags, or both. We
|
||||
recommend avoiding common words that appear in many posts, since it
|
||||
can result in no posts being shown.
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<View style={[a.pb_lg]}>
|
||||
<View style={[a.pb_sm]}>
|
||||
<Dialog.Input
|
||||
autoCorrect={false}
|
||||
autoCapitalize="none"
|
||||
@@ -107,30 +135,135 @@ function MutedWordsInner() {
|
||||
}}
|
||||
onSubmitEditing={submit}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={[a.pb_xl, a.gap_sm]}>
|
||||
<Toggle.Group
|
||||
label={_(msg`Toggle between muted word options.`)}
|
||||
label={_(msg`Select how long to mute this word for.`)}
|
||||
type="radio"
|
||||
values={options}
|
||||
onChange={setOptions}>
|
||||
values={durations}
|
||||
onChange={setDurations}>
|
||||
<Text
|
||||
style={[
|
||||
a.pb_xs,
|
||||
a.text_sm,
|
||||
a.font_bold,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
<Trans>Duration:</Trans>
|
||||
</Text>
|
||||
|
||||
<View
|
||||
style={[
|
||||
a.pt_sm,
|
||||
a.py_sm,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
gtMobile && [a.flex_row, a.align_center, a.justify_start],
|
||||
a.gap_sm,
|
||||
a.flex_wrap,
|
||||
]}>
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.flex_row,
|
||||
a.justify_start,
|
||||
a.align_center,
|
||||
a.gap_sm,
|
||||
]}>
|
||||
<Toggle.Item
|
||||
label={_(msg`Mute this word until you unmute it`)}
|
||||
name="forever"
|
||||
style={[a.flex_1]}>
|
||||
<TargetToggle>
|
||||
<View
|
||||
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<Toggle.Radio />
|
||||
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
|
||||
<Trans>Forever</Trans>
|
||||
</Toggle.LabelText>
|
||||
</View>
|
||||
</TargetToggle>
|
||||
</Toggle.Item>
|
||||
|
||||
<Toggle.Item
|
||||
label={_(msg`Mute this word for 24 hours`)}
|
||||
name="24_hours"
|
||||
style={[a.flex_1]}>
|
||||
<TargetToggle>
|
||||
<View
|
||||
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<Toggle.Radio />
|
||||
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
|
||||
<Trans>24 hours</Trans>
|
||||
</Toggle.LabelText>
|
||||
</View>
|
||||
</TargetToggle>
|
||||
</Toggle.Item>
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.flex_row,
|
||||
a.justify_start,
|
||||
a.align_center,
|
||||
a.gap_sm,
|
||||
]}>
|
||||
<Toggle.Item
|
||||
label={_(msg`Mute this word for 7 days`)}
|
||||
name="7_days"
|
||||
style={[a.flex_1]}>
|
||||
<TargetToggle>
|
||||
<View
|
||||
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<Toggle.Radio />
|
||||
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
|
||||
<Trans>7 days</Trans>
|
||||
</Toggle.LabelText>
|
||||
</View>
|
||||
</TargetToggle>
|
||||
</Toggle.Item>
|
||||
|
||||
<Toggle.Item
|
||||
label={_(msg`Mute this word for 30 days`)}
|
||||
name="30_days"
|
||||
style={[a.flex_1]}>
|
||||
<TargetToggle>
|
||||
<View
|
||||
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<Toggle.Radio />
|
||||
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
|
||||
<Trans>30 days</Trans>
|
||||
</Toggle.LabelText>
|
||||
</View>
|
||||
</TargetToggle>
|
||||
</Toggle.Item>
|
||||
</View>
|
||||
</View>
|
||||
</Toggle.Group>
|
||||
|
||||
<Toggle.Group
|
||||
label={_(msg`Select what content this mute word should apply to.`)}
|
||||
type="radio"
|
||||
values={targets}
|
||||
onChange={setTargets}>
|
||||
<Text
|
||||
style={[
|
||||
a.pb_xs,
|
||||
a.text_sm,
|
||||
a.font_bold,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
<Trans>Mute in:</Trans>
|
||||
</Text>
|
||||
|
||||
<View style={[a.flex_row, a.align_center, a.gap_sm, a.flex_wrap]}>
|
||||
<Toggle.Item
|
||||
label={_(msg`Mute this word in post text and tags`)}
|
||||
name="content"
|
||||
style={[a.flex_1, !gtMobile && [a.w_full, a.flex_0]]}>
|
||||
style={[a.flex_1]}>
|
||||
<TargetToggle>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<View
|
||||
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<Toggle.Radio />
|
||||
<Toggle.LabelText>
|
||||
<Trans>Mute in text & tags</Trans>
|
||||
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
|
||||
<Trans>Text & tags</Trans>
|
||||
</Toggle.LabelText>
|
||||
</View>
|
||||
<PageText size="sm" />
|
||||
@@ -140,34 +273,64 @@ function MutedWordsInner() {
|
||||
<Toggle.Item
|
||||
label={_(msg`Mute this word in tags only`)}
|
||||
name="tag"
|
||||
style={[a.flex_1, !gtMobile && [a.w_full, a.flex_0]]}>
|
||||
style={[a.flex_1]}>
|
||||
<TargetToggle>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<View
|
||||
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<Toggle.Radio />
|
||||
<Toggle.LabelText>
|
||||
<Trans>Mute in tags only</Trans>
|
||||
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
|
||||
<Trans>Tags only</Trans>
|
||||
</Toggle.LabelText>
|
||||
</View>
|
||||
<Hashtag size="sm" />
|
||||
</TargetToggle>
|
||||
</Toggle.Item>
|
||||
|
||||
<Button
|
||||
disabled={isPending || !field}
|
||||
label={_(msg`Add mute word for configured settings`)}
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="solid"
|
||||
style={[!gtMobile && [a.w_full, a.flex_0]]}
|
||||
onPress={submit}>
|
||||
<ButtonText>
|
||||
<Trans>Add</Trans>
|
||||
</ButtonText>
|
||||
<ButtonIcon icon={isPending ? Loader : Plus} />
|
||||
</Button>
|
||||
</View>
|
||||
</Toggle.Group>
|
||||
|
||||
<View>
|
||||
<Text
|
||||
style={[
|
||||
a.pb_xs,
|
||||
a.text_sm,
|
||||
a.font_bold,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
<Trans>Options:</Trans>
|
||||
</Text>
|
||||
<Toggle.Item
|
||||
label={_(msg`Do not apply this mute word to users you follow`)}
|
||||
name="exclude_following"
|
||||
style={[a.flex_row, a.justify_between]}
|
||||
value={excludeFollowing}
|
||||
onChange={setExcludeFollowing}>
|
||||
<TargetToggle>
|
||||
<View style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<Toggle.Checkbox />
|
||||
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
|
||||
<Trans>Exclude users you follow</Trans>
|
||||
</Toggle.LabelText>
|
||||
</View>
|
||||
</TargetToggle>
|
||||
</Toggle.Item>
|
||||
</View>
|
||||
|
||||
<View style={[a.pt_xs]}>
|
||||
<Button
|
||||
disabled={isPending || !field}
|
||||
label={_(msg`Add mute word for configured settings`)}
|
||||
size="medium"
|
||||
color="primary"
|
||||
variant="solid"
|
||||
style={[]}
|
||||
onPress={submit}>
|
||||
<ButtonText>
|
||||
<Trans>Add</Trans>
|
||||
</ButtonText>
|
||||
<ButtonIcon icon={isPending ? Loader : Plus} position="right" />
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
{error && (
|
||||
<View
|
||||
style={[
|
||||
@@ -191,20 +354,6 @@ function MutedWordsInner() {
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Text
|
||||
style={[
|
||||
a.pt_xs,
|
||||
a.text_sm,
|
||||
a.italic,
|
||||
a.leading_snug,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
<Trans>
|
||||
We recommend avoiding common words that appear in many posts,
|
||||
since it can result in no posts being shown.
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Divider />
|
||||
@@ -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({
|
||||
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.justify_between,
|
||||
a.py_md,
|
||||
a.px_lg,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_between,
|
||||
a.rounded_md,
|
||||
a.gap_md,
|
||||
style,
|
||||
]}>
|
||||
<Text
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.leading_snug,
|
||||
a.w_full,
|
||||
a.font_bold,
|
||||
t.atoms.text_contrast_high,
|
||||
web({
|
||||
overflowWrap: 'break-word',
|
||||
wordBreak: 'break-word',
|
||||
}),
|
||||
]}>
|
||||
{word.value}
|
||||
</Text>
|
||||
<View style={[a.flex_1, a.gap_xs]}>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<Text
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.leading_snug,
|
||||
a.font_bold,
|
||||
web({
|
||||
overflowWrap: 'break-word',
|
||||
wordBreak: 'break-word',
|
||||
}),
|
||||
]}>
|
||||
{word.targets.find(t => t === 'content') ? (
|
||||
<Trans comment="Pattern: {wordValue} in text, tags">
|
||||
{word.value}{' '}
|
||||
<Text style={[a.font_normal, t.atoms.text_contrast_medium]}>
|
||||
in{' '}
|
||||
<Text style={[a.font_bold, t.atoms.text_contrast_medium]}>
|
||||
text & tags
|
||||
</Text>
|
||||
</Text>
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans comment="Pattern: {wordValue} in tags">
|
||||
{word.value}{' '}
|
||||
<Text style={[a.font_normal, t.atoms.text_contrast_medium]}>
|
||||
in{' '}
|
||||
<Text style={[a.font_bold, t.atoms.text_contrast_medium]}>
|
||||
tags
|
||||
</Text>
|
||||
</Text>
|
||||
</Trans>
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={[a.flex_row, a.align_center, a.justify_end, a.gap_sm]}>
|
||||
{word.targets.map(target => (
|
||||
<View
|
||||
key={target}
|
||||
style={[a.py_xs, a.px_sm, a.rounded_sm, t.atoms.bg_contrast_100]}>
|
||||
{(expiryDate || word.actorTarget === 'exclude-following') && (
|
||||
<View style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<Text
|
||||
style={[a.text_xs, a.font_bold, t.atoms.text_contrast_medium]}>
|
||||
{target === 'content' ? _(msg`text`) : _(msg`tag`)}
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.text_xs,
|
||||
a.leading_snug,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
{expiryDate && (
|
||||
<>
|
||||
{isExpired ? (
|
||||
<Trans>Expired</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
Expires{' '}
|
||||
{formatDistance(expiryDate, new Date(), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</Trans>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{word.actorTarget === 'exclude-following' && (
|
||||
<>
|
||||
{' • '}
|
||||
<Trans>Excludes users you follow</Trans>
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
|
||||
<Button
|
||||
label={_(msg`Remove mute word from your list`)}
|
||||
size="tiny"
|
||||
shape="round"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
onPress={() => control.open()}
|
||||
style={[a.ml_sm]}>
|
||||
<ButtonIcon icon={isPending ? Loader : X} />
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Button
|
||||
label={_(msg`Remove mute word from your list`)}
|
||||
size="tiny"
|
||||
shape="round"
|
||||
variant="outline"
|
||||
color="secondary"
|
||||
onPress={() => control.open()}
|
||||
style={[a.ml_sm]}>
|
||||
<ButtonIcon icon={isPending ? Loader : X} />
|
||||
</Button>
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
</View>
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Hooks for date-fns localized formatters.
|
||||
*
|
||||
* Our app supports some languages that are not included in date-fns by
|
||||
* default, in which case it will fall back to English.
|
||||
*
|
||||
* {@link https://github.com/date-fns/date-fns/blob/main/docs/i18n.md}
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import {formatDistance, Locale} from 'date-fns'
|
||||
import {
|
||||
ca,
|
||||
de,
|
||||
es,
|
||||
fi,
|
||||
fr,
|
||||
hi,
|
||||
id,
|
||||
it,
|
||||
ja,
|
||||
ko,
|
||||
ptBR,
|
||||
tr,
|
||||
uk,
|
||||
zhCN,
|
||||
zhTW,
|
||||
} from 'date-fns/locale'
|
||||
|
||||
import {AppLanguage} from '#/locale/languages'
|
||||
import {useLanguagePrefs} from '#/state/preferences'
|
||||
|
||||
/**
|
||||
* {@link AppLanguage}
|
||||
*/
|
||||
const locales: Record<AppLanguage, Locale | undefined> = {
|
||||
en: undefined,
|
||||
ca,
|
||||
de,
|
||||
es,
|
||||
fi,
|
||||
fr,
|
||||
ga: undefined,
|
||||
hi,
|
||||
id,
|
||||
it,
|
||||
ja,
|
||||
ko,
|
||||
['pt-BR']: ptBR,
|
||||
tr,
|
||||
uk,
|
||||
['zh-CN']: zhCN,
|
||||
['zh-TW']: zhTW,
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a localized `formatDistance` function.
|
||||
* {@link formatDistance}
|
||||
*/
|
||||
export function useFormatDistance() {
|
||||
const {appLanguage} = useLanguagePrefs()
|
||||
return React.useCallback<typeof formatDistance>(
|
||||
(date, baseDate, options) => {
|
||||
const locale = locales[appLanguage as AppLanguage]
|
||||
return formatDistance(date, baseDate, {...options, locale: locale})
|
||||
},
|
||||
[appLanguage],
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const Moon_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M12.097 2.53a1 1 0 0 1-.041 1.07 6 6 0 0 0 8.345 8.344 1 1 0 0 1 1.563.908c-.434 5.122-4.728 9.144-9.962 9.144-5.522 0-9.998-4.476-9.998-9.998 0-5.234 4.021-9.528 9.144-9.962a1 1 0 0 1 .949.494ZM9.424 4.424a7.998 7.998 0 1 0 10.152 10.152A8 8 0 0 1 9.424 4.424Z',
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const Phone_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M5 4a3 3 0 0 1 3-3h8a3 3 0 0 1 3 3v16a3 3 0 0 1-3 3H8a3 3 0 0 1-3-3V4Zm3-1a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1H8Zm2 2a1 1 0 0 1 1-1h2a1 1 0 1 1 0 2h-2a1 1 0 0 1-1-1Z',
|
||||
})
|
||||
@@ -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({
|
||||
</Trans>
|
||||
) : (
|
||||
<>
|
||||
<Trans>This {screenDescription} has been flagged:</Trans>
|
||||
<Text style={[a.text_lg, a.font_semibold, t.atoms.text, a.ml_xs]}>
|
||||
<Trans>This {screenDescription} has been flagged:</Trans>{' '}
|
||||
<Text
|
||||
style={[
|
||||
a.text_lg,
|
||||
a.font_semibold,
|
||||
a.leading_snug,
|
||||
t.atoms.text,
|
||||
a.ml_xs,
|
||||
]}>
|
||||
{desc.name}.{' '}
|
||||
</Text>
|
||||
<TouchableWithoutFeedback
|
||||
@@ -127,16 +135,17 @@ export function ScreenHider({
|
||||
<Text
|
||||
style={[
|
||||
a.text_lg,
|
||||
a.leading_snug,
|
||||
{
|
||||
color: t.palette.primary_500,
|
||||
// @ts-ignore web only -prf
|
||||
cursor: 'pointer',
|
||||
},
|
||||
web({
|
||||
cursor: 'pointer',
|
||||
}),
|
||||
]}>
|
||||
<Trans>Learn More</Trans>
|
||||
</Text>
|
||||
</TouchableWithoutFeedback>
|
||||
|
||||
<ModerationDetailsDialog control={control} modcause={blur} />
|
||||
</>
|
||||
)}{' '}
|
||||
|
||||
+18
-48
@@ -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
|
||||
|
||||
@@ -54,6 +54,10 @@ interface PostOpts {
|
||||
uri: string
|
||||
cid: string
|
||||
}
|
||||
video?: {
|
||||
uri: string
|
||||
cid: string
|
||||
}
|
||||
extLink?: ExternalEmbedDraft
|
||||
images?: ImageModel[]
|
||||
labels?: string[]
|
||||
|
||||
@@ -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
|
||||
}, [])
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import React, {useCallback} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import Animated, {
|
||||
FadeInDown,
|
||||
FadeOutDown,
|
||||
LayoutAnimationConfig,
|
||||
} from 'react-native-reanimated'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||
import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
|
||||
import {s} from '#/lib/styles'
|
||||
import {useSetThemePrefs, useThemePrefs} from '#/state/shell'
|
||||
import {SimpleViewHeader} from '#/view/com/util/SimpleViewHeader'
|
||||
import {ScrollView} from '#/view/com/util/Views'
|
||||
import {atoms as a, native, useTheme} from '#/alf'
|
||||
import * as ToggleButton from '#/components/forms/ToggleButton'
|
||||
import {Moon_Stroke2_Corner0_Rounded as MoonIcon} from '#/components/icons/Moon'
|
||||
import {Phone_Stroke2_Corner0_Rounded as PhoneIcon} from '#/components/icons/Phone'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
type Props = NativeStackScreenProps<CommonNavigatorParams, 'AppearanceSettings'>
|
||||
export function AppearanceSettingsScreen({}: Props) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const {isTabletOrMobile} = useWebMediaQueries()
|
||||
|
||||
const {colorMode, darkTheme} = useThemePrefs()
|
||||
const {setColorMode, setDarkTheme} = useSetThemePrefs()
|
||||
|
||||
const onChangeAppearance = useCallback(
|
||||
(keys: string[]) => {
|
||||
const appearance = keys.find(key => key !== colorMode) as
|
||||
| 'system'
|
||||
| 'light'
|
||||
| 'dark'
|
||||
| undefined
|
||||
if (!appearance) return
|
||||
setColorMode(appearance)
|
||||
},
|
||||
[setColorMode, colorMode],
|
||||
)
|
||||
|
||||
const onChangeDarkTheme = useCallback(
|
||||
(keys: string[]) => {
|
||||
const theme = keys.find(key => key !== darkTheme) as
|
||||
| 'dim'
|
||||
| 'dark'
|
||||
| undefined
|
||||
if (!theme) return
|
||||
setDarkTheme(theme)
|
||||
},
|
||||
[setDarkTheme, darkTheme],
|
||||
)
|
||||
|
||||
return (
|
||||
<LayoutAnimationConfig skipExiting skipEntering>
|
||||
<View testID="preferencesThreadsScreen" style={s.hContentRegion}>
|
||||
<ScrollView
|
||||
// @ts-ignore web only -prf
|
||||
dataSet={{'stable-gutters': 1}}
|
||||
contentContainerStyle={{paddingBottom: 75}}>
|
||||
<SimpleViewHeader
|
||||
showBackButton={isTabletOrMobile}
|
||||
style={[t.atoms.border_contrast_medium, a.border_b]}>
|
||||
<View style={a.flex_1}>
|
||||
<Text style={[a.text_2xl, a.font_bold]}>
|
||||
<Trans>Appearance</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</SimpleViewHeader>
|
||||
|
||||
<View style={[a.p_xl, a.gap_lg]}>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_md]}>
|
||||
<PhoneIcon style={t.atoms.text} />
|
||||
<Text style={a.text_md}>
|
||||
<Trans>Mode</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
<ToggleButton.Group
|
||||
label={_(msg`Dark mode`)}
|
||||
values={[colorMode]}
|
||||
onChange={onChangeAppearance}>
|
||||
<ToggleButton.Button label={_(msg`System`)} name="system">
|
||||
<ToggleButton.ButtonText>
|
||||
<Trans>System</Trans>
|
||||
</ToggleButton.ButtonText>
|
||||
</ToggleButton.Button>
|
||||
<ToggleButton.Button label={_(msg`Light`)} name="light">
|
||||
<ToggleButton.ButtonText>
|
||||
<Trans>Light</Trans>
|
||||
</ToggleButton.ButtonText>
|
||||
</ToggleButton.Button>
|
||||
<ToggleButton.Button label={_(msg`Dark`)} name="dark">
|
||||
<ToggleButton.ButtonText>
|
||||
<Trans>Dark</Trans>
|
||||
</ToggleButton.ButtonText>
|
||||
</ToggleButton.Button>
|
||||
</ToggleButton.Group>
|
||||
{colorMode !== 'light' && (
|
||||
<Animated.View
|
||||
entering={native(FadeInDown)}
|
||||
exiting={native(FadeOutDown)}
|
||||
style={[a.mt_md, a.gap_lg]}>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_md]}>
|
||||
<MoonIcon style={t.atoms.text} />
|
||||
<Text style={a.text_md}>
|
||||
<Trans>Dark theme</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<ToggleButton.Group
|
||||
label={_(msg`Dark theme`)}
|
||||
values={[darkTheme ?? 'dim']}
|
||||
onChange={onChangeDarkTheme}>
|
||||
<ToggleButton.Button label={_(msg`Dim`)} name="dim">
|
||||
<ToggleButton.ButtonText>
|
||||
<Trans>Dim</Trans>
|
||||
</ToggleButton.ButtonText>
|
||||
</ToggleButton.Button>
|
||||
<ToggleButton.Button label={_(msg`Dark`)} name="dark">
|
||||
<ToggleButton.ButtonText>
|
||||
<Trans>Dark</Trans>
|
||||
</ToggleButton.ButtonText>
|
||||
</ToggleButton.Button>
|
||||
</ToggleButton.Group>
|
||||
</Animated.View>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</LayoutAnimationConfig>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
+33
-20
@@ -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<AppBskyUnspeccedGetPopularFeedGenerators.OutputSchema>,
|
||||
) => {
|
||||
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
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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<FeedPage>
|
||||
} | 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<FeedPageUnselected, RQPageParam>) => {
|
||||
// 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])
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}),
|
||||
}
|
||||
}),
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}),
|
||||
}
|
||||
}),
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
const UPLOAD_ENDPOINT = process.env.EXPO_PUBLIC_VIDEO_ROOT_ENDPOINT ?? ''
|
||||
|
||||
export const createVideoEndpointUrl = (
|
||||
route: string,
|
||||
params?: Record<string, string>,
|
||||
) => {
|
||||
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
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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<string>()
|
||||
|
||||
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)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import React from 'react'
|
||||
|
||||
interface PostProgressState {
|
||||
progress: number
|
||||
status: 'pending' | 'success' | 'error' | 'idle'
|
||||
error?: string
|
||||
}
|
||||
|
||||
const PostProgressContext = React.createContext<PostProgressState>({
|
||||
progress: 0,
|
||||
status: 'idle',
|
||||
})
|
||||
|
||||
export function Provider() {}
|
||||
|
||||
export function usePostProgress() {
|
||||
return React.useContext(PostProgressContext)
|
||||
}
|
||||
@@ -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<ComposerOpts['quote'] | undefined>(
|
||||
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<Gif>()
|
||||
const [labels, setLabels] = useState<string[]>([])
|
||||
@@ -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
|
||||
}>
|
||||
<ButtonText style={[a.text_md]}>
|
||||
{replyTo ? (
|
||||
<Trans context="action">Reply</Trans>
|
||||
@@ -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({
|
||||
</View>
|
||||
)}
|
||||
|
||||
{quote ? (
|
||||
<View style={[s.mt5, s.mb2, isWeb && s.mb10]}>
|
||||
<View style={{pointerEvents: 'none'}}>
|
||||
<QuoteEmbed quote={quote} />
|
||||
<View style={[a.mt_md]}>
|
||||
{quote ? (
|
||||
<View style={[s.mt5, s.mb2, isWeb && s.mb10]}>
|
||||
<View style={{pointerEvents: 'none'}}>
|
||||
<QuoteEmbed quote={quote} />
|
||||
</View>
|
||||
{quote.uri !== initQuote?.uri && (
|
||||
<QuoteX onRemove={() => setQuote(undefined)} />
|
||||
)}
|
||||
</View>
|
||||
{quote.uri !== initQuote?.uri && (
|
||||
<QuoteX onRemove={() => setQuote(undefined)} />
|
||||
)}
|
||||
</View>
|
||||
) : null}
|
||||
{videoPending && videoProcessingData ? (
|
||||
<VideoTranscodeProgress
|
||||
input={videoProcessingData}
|
||||
progress={videoProcessingProgress}
|
||||
/>
|
||||
) : (
|
||||
video && (
|
||||
) : null}
|
||||
{videoUploadState.status === 'compressing' &&
|
||||
videoUploadState.asset ? (
|
||||
<VideoTranscodeProgress
|
||||
asset={videoUploadState.asset}
|
||||
progress={videoUploadState.progress}
|
||||
/>
|
||||
) : videoUploadState.video ? (
|
||||
// remove suspense when we get rid of lazy
|
||||
<Suspense fallback={null}>
|
||||
<VideoPreview video={video} clear={clearVideo} />
|
||||
<VideoPreview
|
||||
video={videoUploadState.video}
|
||||
clear={clearVideo}
|
||||
/>
|
||||
</Suspense>
|
||||
)
|
||||
)}
|
||||
) : null}
|
||||
</View>
|
||||
</Animated.ScrollView>
|
||||
<SuggestedLanguage text={richtext.text} />
|
||||
|
||||
@@ -641,33 +675,37 @@ export const ComposePost = observer(function ComposePost({
|
||||
t.atoms.border_contrast_medium,
|
||||
styles.bottomBar,
|
||||
]}>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
|
||||
<SelectPhotoBtn gallery={gallery} disabled={!canSelectImages} />
|
||||
{gate('videos') && (
|
||||
<SelectVideoBtn
|
||||
onSelectVideo={onSelectVideo}
|
||||
disabled={!canSelectImages}
|
||||
{videoUploadState.status !== 'idle' ? (
|
||||
<VideoUploadToolbar state={videoUploadState} />
|
||||
) : (
|
||||
<ToolbarWrapper style={[a.flex_row, a.align_center, a.gap_xs]}>
|
||||
<SelectPhotoBtn gallery={gallery} disabled={!canSelectImages} />
|
||||
{gate('videos') && (
|
||||
<SelectVideoBtn
|
||||
onSelectVideo={selectVideo}
|
||||
disabled={!canSelectImages}
|
||||
/>
|
||||
)}
|
||||
<OpenCameraBtn gallery={gallery} disabled={!canSelectImages} />
|
||||
<SelectGifBtn
|
||||
onClose={focusTextInput}
|
||||
onSelectGif={onSelectGif}
|
||||
disabled={hasMedia}
|
||||
/>
|
||||
)}
|
||||
<OpenCameraBtn gallery={gallery} disabled={!canSelectImages} />
|
||||
<SelectGifBtn
|
||||
onClose={focusTextInput}
|
||||
onSelectGif={onSelectGif}
|
||||
disabled={hasMedia}
|
||||
/>
|
||||
{!isMobile ? (
|
||||
<Button
|
||||
onPress={onEmojiButtonPress}
|
||||
style={a.p_sm}
|
||||
label={_(msg`Open emoji picker`)}
|
||||
accessibilityHint={_(msg`Open emoji picker`)}
|
||||
variant="ghost"
|
||||
shape="round"
|
||||
color="primary">
|
||||
<EmojiSmile size="lg" />
|
||||
</Button>
|
||||
) : null}
|
||||
</View>
|
||||
{!isMobile ? (
|
||||
<Button
|
||||
onPress={onEmojiButtonPress}
|
||||
style={a.p_sm}
|
||||
label={_(msg`Open emoji picker`)}
|
||||
accessibilityHint={_(msg`Open emoji picker`)}
|
||||
variant="ghost"
|
||||
shape="round"
|
||||
color="primary">
|
||||
<EmojiSmile size="lg" />
|
||||
</Button>
|
||||
) : null}
|
||||
</ToolbarWrapper>
|
||||
)}
|
||||
<View style={a.flex_1} />
|
||||
<SelectLangBtn />
|
||||
<CharProgress count={graphemeLength} />
|
||||
@@ -893,3 +931,44 @@ const styles = StyleSheet.create({
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
},
|
||||
})
|
||||
|
||||
function ToolbarWrapper({
|
||||
style,
|
||||
children,
|
||||
}: {
|
||||
style: StyleProp<ViewStyle>
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
if (isWeb) return children
|
||||
return (
|
||||
<Animated.View
|
||||
style={style}
|
||||
entering={FadeIn.duration(400)}
|
||||
exiting={FadeOut.duration(400)}>
|
||||
{children}
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
|
||||
function VideoUploadToolbar({state}: {state: VideoUploadState}) {
|
||||
const t = useTheme()
|
||||
|
||||
const progress =
|
||||
state.status === 'compressing' || state.status === 'uploading'
|
||||
? state.progress
|
||||
: state.jobStatus?.progress ?? 100
|
||||
|
||||
return (
|
||||
<ToolbarWrapper
|
||||
style={[a.gap_sm, a.flex_row, a.align_center, {paddingVertical: 5}]}>
|
||||
<ProgressCircle
|
||||
size={30}
|
||||
borderWidth={1}
|
||||
borderColor={t.atoms.border_contrast_low.borderColor}
|
||||
color={t.palette.primary_500}
|
||||
progress={progress}
|
||||
/>
|
||||
<Text>{state.status}</Text>
|
||||
</ToolbarWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ export function VideoPreview({
|
||||
const player = useVideoPlayer(video.uri, player => {
|
||||
player.loop = true
|
||||
player.play()
|
||||
player.volume = 0
|
||||
})
|
||||
|
||||
return (
|
||||
|
||||
@@ -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 (
|
||||
<View
|
||||
@@ -29,7 +29,7 @@ export function VideoTranscodeProgress({
|
||||
a.overflow_hidden,
|
||||
{aspectRatio: isNaN(aspectRatio) ? 16 / 9 : aspectRatio},
|
||||
]}>
|
||||
<VideoTranscodeBackdrop uri={input.uri} />
|
||||
<VideoTranscodeBackdrop uri={asset.uri} />
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import {useState} from 'react'
|
||||
import {ImagePickerAsset} from 'expo-image-picker'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useMutation} from '@tanstack/react-query'
|
||||
|
||||
import {compressVideo} from '#/lib/media/video/compress'
|
||||
import {logger} from '#/logger'
|
||||
import {VideoTooLargeError} from 'lib/media/video/errors'
|
||||
import * as Toast from 'view/com/util/Toast'
|
||||
|
||||
export function useVideoState({setError}: {setError: (error: string) => 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
|
||||
}
|
||||
@@ -129,46 +129,49 @@ export const ProfileFeedgens = React.forwardRef<
|
||||
// rendering
|
||||
// =
|
||||
|
||||
const renderItem = ({item, index}: ListRenderItemInfo<any>) => {
|
||||
if (item === EMPTY) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon="hashtag"
|
||||
message={_(msg`You have no feeds.`)}
|
||||
testID="listsEmpty"
|
||||
/>
|
||||
)
|
||||
} else if (item === ERROR_ITEM) {
|
||||
return (
|
||||
<ErrorMessage message={cleanError(error)} onPressTryAgain={refetch} />
|
||||
)
|
||||
} else if (item === LOAD_MORE_ERROR_ITEM) {
|
||||
return (
|
||||
<LoadMoreRetryBtn
|
||||
label={_(
|
||||
msg`There was an issue fetching your lists. Tap here to try again.`,
|
||||
)}
|
||||
onPress={onPressRetryLoadMore}
|
||||
/>
|
||||
)
|
||||
} else if (item === LOADING) {
|
||||
return <FeedLoadingPlaceholder />
|
||||
}
|
||||
if (preferences) {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
(index !== 0 || isWeb) && a.border_t,
|
||||
t.atoms.border_contrast_low,
|
||||
a.px_lg,
|
||||
a.py_lg,
|
||||
]}>
|
||||
<FeedCard.Default view={item} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
const renderItem = React.useCallback(
|
||||
({item, index}: ListRenderItemInfo<any>) => {
|
||||
if (item === EMPTY) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon="hashtag"
|
||||
message={_(msg`You have no feeds.`)}
|
||||
testID="listsEmpty"
|
||||
/>
|
||||
)
|
||||
} else if (item === ERROR_ITEM) {
|
||||
return (
|
||||
<ErrorMessage message={cleanError(error)} onPressTryAgain={refetch} />
|
||||
)
|
||||
} else if (item === LOAD_MORE_ERROR_ITEM) {
|
||||
return (
|
||||
<LoadMoreRetryBtn
|
||||
label={_(
|
||||
msg`There was an issue fetching your lists. Tap here to try again.`,
|
||||
)}
|
||||
onPress={onPressRetryLoadMore}
|
||||
/>
|
||||
)
|
||||
} else if (item === LOADING) {
|
||||
return <FeedLoadingPlaceholder />
|
||||
}
|
||||
if (preferences) {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
(index !== 0 || isWeb) && a.border_t,
|
||||
t.atoms.border_contrast_low,
|
||||
a.px_lg,
|
||||
a.py_lg,
|
||||
]}>
|
||||
<FeedCard.Default view={item} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
return null
|
||||
},
|
||||
[_, t, error, refetch, onPressRetryLoadMore, preferences],
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (enabled && scrollElRef.current) {
|
||||
|
||||
@@ -75,12 +75,7 @@ export const ProfileLists = React.forwardRef<SectionRef, ProfileListsProps>(
|
||||
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<SectionRef, ProfileListsProps>(
|
||||
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}
|
||||
|
||||
@@ -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 = <RepostIcon size="xl" style={{color: t.palette.positive_600}} />
|
||||
} 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`)
|
||||
|
||||
@@ -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 <ProfileCardWithFollowBtn key={item.actor.did} profile={item.actor} />
|
||||
}
|
||||
|
||||
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 (
|
||||
<ProfileCardWithFollowBtn key={item.actor.did} profile={item.actor} />
|
||||
)
|
||||
}, [])
|
||||
|
||||
if (isFetchingResolvedUri || !isFetched) {
|
||||
return <LoadingScreen />
|
||||
}
|
||||
|
||||
// error
|
||||
// =
|
||||
if (resolveError || isError) {
|
||||
return (
|
||||
<CenteredView>
|
||||
<ErrorMessage
|
||||
message={cleanError(resolveError || error)}
|
||||
onPressTryAgain={onRefresh}
|
||||
/>
|
||||
</CenteredView>
|
||||
<ListMaybePlaceholder
|
||||
isLoading={isLoadingUri || isLoadingLikes}
|
||||
isError={isError}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// loaded
|
||||
// =
|
||||
return (
|
||||
<List
|
||||
data={likes}
|
||||
keyExtractor={item => 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={() => (
|
||||
<View style={styles.footer}>
|
||||
{(isFetching || isFetchingNextPage) && <ActivityIndicator />}
|
||||
</View>
|
||||
)}
|
||||
onEndReachedThreshold={4}
|
||||
ListHeaderComponent={<ListHeaderDesktop title={_(msg`Liked By`)} />}
|
||||
ListFooterComponent={
|
||||
<ListFooter
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
error={cleanError(error)}
|
||||
onRetry={fetchNextPage}
|
||||
/>
|
||||
}
|
||||
// @ts-ignore our .web version only -prf
|
||||
desktopFixedHeight
|
||||
initialNumToRender={initialNumToRender}
|
||||
windowSize={11}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
footer: {
|
||||
height: 200,
|
||||
paddingTop: 20,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,38 +1,57 @@
|
||||
import React, {useMemo, useCallback, useState} from 'react'
|
||||
import {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 <ProfileCardWithFollowBtn key={item.did} profile={item} />
|
||||
}
|
||||
|
||||
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 <ProfileCardWithFollowBtn key={item.did} profile={item} />
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
if (isFetchingResolvedUri || !isFetched) {
|
||||
return <LoadingScreen />
|
||||
}
|
||||
|
||||
// error
|
||||
// =
|
||||
if (resolveError || isError) {
|
||||
if (repostedBy.length < 1) {
|
||||
return (
|
||||
<CenteredView>
|
||||
<ErrorMessage
|
||||
message={cleanError(resolveError || error)}
|
||||
onPressTryAgain={onRefresh}
|
||||
/>
|
||||
</CenteredView>
|
||||
<ListMaybePlaceholder
|
||||
isLoading={isLoadingUri || isLoadingRepostedBy}
|
||||
isError={isError}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -83,28 +87,24 @@ export function PostRepostedBy({uri}: {uri: string}) {
|
||||
return (
|
||||
<List
|
||||
data={repostedBy}
|
||||
keyExtractor={item => 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={() => (
|
||||
<View style={styles.footer}>
|
||||
{(isFetching || isFetchingNextPage) && <ActivityIndicator />}
|
||||
</View>
|
||||
)}
|
||||
onEndReachedThreshold={4}
|
||||
ListHeaderComponent={<ListHeaderDesktop title={_(msg`Reposted By`)} />}
|
||||
ListFooterComponent={
|
||||
<ListFooter
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
error={cleanError(error)}
|
||||
onRetry={fetchNextPage}
|
||||
/>
|
||||
}
|
||||
// @ts-ignore our .web version only -prf
|
||||
desktopFixedHeight
|
||||
initialNumToRender={initialNumToRender}
|
||||
windowSize={11}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
footer: {
|
||||
height: 200,
|
||||
paddingTop: 20,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
<FeedItem
|
||||
key={slice.items[1]._reactKey}
|
||||
post={slice.items[1].post}
|
||||
record={slice.items[1].record}
|
||||
reason={slice.items[1].reason}
|
||||
feedContext={slice.items[1].feedContext}
|
||||
parentAuthor={slice.items[1].parentAuthor}
|
||||
showReplyTo={false}
|
||||
moderation={slice.items[1].moderation}
|
||||
isThreadParent={isThreadParentAt(slice.items, 1)}
|
||||
isThreadChild={isThreadChildAt(slice.items, 1)}
|
||||
isParentBlocked={slice.items[1].isParentBlocked}
|
||||
/>
|
||||
<ViewFullThread slice={slice} />
|
||||
<FeedItem
|
||||
key={slice.items[beforeLast]._reactKey}
|
||||
post={slice.items[beforeLast].post}
|
||||
record={slice.items[beforeLast].record}
|
||||
reason={slice.items[beforeLast].reason}
|
||||
feedContext={slice.items[beforeLast].feedContext}
|
||||
parentAuthor={slice.items[beforeLast].parentAuthor}
|
||||
showReplyTo={false}
|
||||
moderation={slice.items[beforeLast].moderation}
|
||||
isThreadParent={isThreadParentAt(slice.items, beforeLast)}
|
||||
isThreadChild={isThreadChildAt(slice.items, beforeLast)}
|
||||
isParentBlocked={slice.items[beforeLast].isParentBlocked}
|
||||
/>
|
||||
<FeedItem
|
||||
key={slice.items[last]._reactKey}
|
||||
post={slice.items[last].post}
|
||||
|
||||
@@ -344,10 +344,11 @@ function ListImpl<ItemT>(
|
||||
style={[styles.aboveTheFoldDetector, {height: headerOffset}]}
|
||||
/>
|
||||
{onStartReached && !isEmpty && (
|
||||
<Visibility
|
||||
<EdgeVisibility
|
||||
root={disableFullWindowScroll ? nativeRef : null}
|
||||
onVisibleChange={onHeadVisibilityChange}
|
||||
topMargin={(onStartReachedThreshold ?? 0) * 100 + '%'}
|
||||
containerRef={containerRef}
|
||||
/>
|
||||
)}
|
||||
{headerComponent}
|
||||
@@ -368,11 +369,11 @@ function ListImpl<ItemT>(
|
||||
)
|
||||
})}
|
||||
{onEndReached && !isEmpty && (
|
||||
<Visibility
|
||||
<EdgeVisibility
|
||||
root={disableFullWindowScroll ? nativeRef : null}
|
||||
onVisibleChange={onTailVisibilityChange}
|
||||
bottomMargin={(onEndReachedThreshold ?? 0) * 100 + '%'}
|
||||
key={data?.length}
|
||||
containerRef={containerRef}
|
||||
/>
|
||||
)}
|
||||
{footerComponent}
|
||||
@@ -381,6 +382,34 @@ function ListImpl<ItemT>(
|
||||
)
|
||||
}
|
||||
|
||||
function EdgeVisibility({
|
||||
root,
|
||||
topMargin,
|
||||
bottomMargin,
|
||||
containerRef,
|
||||
onVisibleChange,
|
||||
}: {
|
||||
root?: React.RefObject<HTMLDivElement> | null
|
||||
topMargin?: string
|
||||
bottomMargin?: string
|
||||
containerRef: React.RefObject<Element>
|
||||
onVisibleChange: (isVisible: boolean) => void
|
||||
}) {
|
||||
const [containerHeight, setContainerHeight] = React.useState(0)
|
||||
useResizeObserver(containerRef, (w, h) => {
|
||||
setContainerHeight(h)
|
||||
})
|
||||
return (
|
||||
<Visibility
|
||||
key={containerHeight}
|
||||
root={root}
|
||||
topMargin={topMargin}
|
||||
bottomMargin={bottomMargin}
|
||||
onVisibleChange={onVisibleChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function useResizeObserver(
|
||||
ref: React.RefObject<Element>,
|
||||
onResize: undefined | ((w: number, h: number) => void),
|
||||
|
||||
@@ -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<ViewStyle>
|
||||
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 (
|
||||
<FeedSourceCard
|
||||
feedUri={embed.record.uri}
|
||||
style={[pal.view, pal.border, styles.customFeedOuter]}
|
||||
showLikes
|
||||
/>
|
||||
)
|
||||
return <MaybeFeedCard view={embed.record} />
|
||||
}
|
||||
|
||||
// list embed
|
||||
if (AppBskyGraphDefs.isListView(embed.record)) {
|
||||
// TODO moderation
|
||||
return <ListEmbed item={embed.record} />
|
||||
return <MaybeListCard view={embed.record} />
|
||||
}
|
||||
|
||||
if (AppBskyGraphDefs.isStarterPackViewBasic(embed.record)) {
|
||||
@@ -185,6 +178,39 @@ export function PostEmbeds({
|
||||
return <View />
|
||||
}
|
||||
|
||||
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 (
|
||||
<ContentHider modui={moderation?.ui('contentList')}>
|
||||
<FeedSourceCard
|
||||
feedUri={view.uri}
|
||||
style={[pal.view, pal.border, styles.customFeedOuter]}
|
||||
showLikes
|
||||
/>
|
||||
</ContentHider>
|
||||
)
|
||||
}
|
||||
|
||||
function MaybeListCard({view}: {view: AppBskyGraphDefs.ListView}) {
|
||||
const moderationOpts = useModerationOpts()
|
||||
const moderation = React.useMemo(() => {
|
||||
return moderationOpts ? moderateUserList(view, moderationOpts) : undefined
|
||||
}, [view, moderationOpts])
|
||||
|
||||
return (
|
||||
<ContentHider modui={moderation?.ui('contentList')}>
|
||||
<ListEmbed item={view} />
|
||||
</ContentHider>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginTop: 8,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]}>
|
||||
<View style={{flex: 1}}>
|
||||
<View style={a.flex_1}>
|
||||
<Text type="title-lg" style={[pal.text, {fontWeight: 'bold'}]}>
|
||||
<Trans>Accessibility Settings</Trans>
|
||||
</Text>
|
||||
|
||||
@@ -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<CommonNavigatorParams, 'PostLikedBy'>
|
||||
export const PostLikedByScreen = ({route}: Props) => {
|
||||
@@ -23,7 +24,7 @@ export const PostLikedByScreen = ({route}: Props) => {
|
||||
)
|
||||
|
||||
return (
|
||||
<View>
|
||||
<View style={{flex: 1}}>
|
||||
<ViewHeader title={_(msg`Liked By`)} />
|
||||
<PostLikedByComponent uri={uri} />
|
||||
</View>
|
||||
|
||||
@@ -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<CommonNavigatorParams, 'PostRepostedBy'>
|
||||
export const PostRepostedByScreen = ({route}: Props) => {
|
||||
@@ -23,7 +24,7 @@ export const PostRepostedByScreen = ({route}: Props) => {
|
||||
)
|
||||
|
||||
return (
|
||||
<View>
|
||||
<View style={{flex: 1}}>
|
||||
<ViewHeader title={_(msg`Reposted By`)} />
|
||||
<PostRepostedByComponent uri={uri} />
|
||||
</View>
|
||||
|
||||
@@ -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}]}>
|
||||
<SimpleViewHeader
|
||||
showBackButton={isTabletOrMobile}
|
||||
style={[pal.border, {borderBottomWidth: 1}]}>
|
||||
<View style={{flex: 1}}>
|
||||
style={[pal.border, a.border_b]}>
|
||||
<View style={a.flex_1}>
|
||||
<Text type="title-lg" style={[pal.text, {fontWeight: 'bold'}]}>
|
||||
<Trans>External Media Preferences</Trans>
|
||||
</Text>
|
||||
|
||||
@@ -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 (
|
||||
<View style={[!enabled && styles.dimmed]}>
|
||||
<Slider
|
||||
value={value}
|
||||
onValueChange={(v: number | number[]) => {
|
||||
let threshold = Array.isArray(v) ? v[0] : v
|
||||
if (threshold > preValue.current) threshold = Math.floor(threshold)
|
||||
else threshold = Math.ceil(threshold)
|
||||
|
||||
preValue.current = threshold
|
||||
|
||||
setValue(threshold)
|
||||
save(threshold)
|
||||
}}
|
||||
minimumValue={0}
|
||||
maximumValue={25}
|
||||
containerStyle={isWeb ? undefined : s.flex1}
|
||||
disabled={!enabled}
|
||||
thumbTintColor={colors.blue3}
|
||||
/>
|
||||
<Text type="xs" style={pal.text}>
|
||||
<Plural
|
||||
value={value}
|
||||
_0="Show all replies"
|
||||
one="Show replies with at least # like"
|
||||
other="Show replies with at least # likes"
|
||||
/>
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
import {atoms as a} from '#/alf'
|
||||
|
||||
type Props = NativeStackScreenProps<
|
||||
CommonNavigatorParams,
|
||||
@@ -99,8 +42,8 @@ export function PreferencesFollowingFeed({}: Props) {
|
||||
contentContainerStyle={{paddingBottom: 75}}>
|
||||
<SimpleViewHeader
|
||||
showBackButton={isTabletOrMobile}
|
||||
style={[pal.border, {borderBottomWidth: 1}]}>
|
||||
<View style={{flex: 1}}>
|
||||
style={[pal.border, a.border_b]}>
|
||||
<View style={a.flex_1}>
|
||||
<Text type="title-lg" style={[pal.text, {fontWeight: 'bold'}]}>
|
||||
<Trans>Following Feed Preferences</Trans>
|
||||
</Text>
|
||||
@@ -136,51 +79,6 @@ export function PreferencesFollowingFeed({}: Props) {
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
<View
|
||||
style={[pal.viewLight, styles.card, !showReplies && styles.dimmed]}>
|
||||
<Text type="title-sm" style={[pal.text, s.pb5]}>
|
||||
<Trans>Reply Filters</Trans>
|
||||
</Text>
|
||||
<Text style={[pal.text, s.pb10]}>
|
||||
<Trans>
|
||||
Enable this setting to only see replies between people you
|
||||
follow.
|
||||
</Trans>
|
||||
</Text>
|
||||
<ToggleButton
|
||||
type="default-light"
|
||||
label={_(msg`Followed users only`)}
|
||||
isSelected={Boolean(
|
||||
variables?.hideRepliesByUnfollowed ??
|
||||
preferences?.feedViewPrefs?.hideRepliesByUnfollowed,
|
||||
)}
|
||||
onPress={
|
||||
showReplies
|
||||
? () =>
|
||||
setFeedViewPref({
|
||||
hideRepliesByUnfollowed: !(
|
||||
variables?.hideRepliesByUnfollowed ??
|
||||
preferences?.feedViewPrefs?.hideRepliesByUnfollowed
|
||||
),
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
style={[s.mb10]}
|
||||
/>
|
||||
<Text style={[pal.text]}>
|
||||
<Trans>
|
||||
Adjust the number of likes a reply must have to be shown in your
|
||||
feed.
|
||||
</Trans>
|
||||
</Text>
|
||||
{preferences && (
|
||||
<RepliesThresholdInput
|
||||
enabled={showReplies}
|
||||
initialValue={preferences.feedViewPrefs.hideRepliesByLikeCount}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={[pal.viewLight, styles.card]}>
|
||||
<Text type="title-sm" style={[pal.text, s.pb5]}>
|
||||
<Trans>Show Reposts</Trans>
|
||||
|
||||
@@ -45,8 +45,8 @@ export function PreferencesThreads({}: Props) {
|
||||
contentContainerStyle={{paddingBottom: 75}}>
|
||||
<SimpleViewHeader
|
||||
showBackButton={isTabletOrMobile}
|
||||
style={[pal.border, {borderBottomWidth: 1}]}>
|
||||
<View style={{flex: 1}}>
|
||||
style={[pal.border, a.border_b]}>
|
||||
<View style={a.flex_1}>
|
||||
<Text type="title-lg" style={[pal.text, {fontWeight: 'bold'}]}>
|
||||
<Trans>Thread Preferences</Trans>
|
||||
</Text>
|
||||
|
||||
@@ -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<CommonNavigatorParams, 'ProfileFeedLikedBy'>
|
||||
export const ProfileFeedLikedByScreen = ({route}: Props) => {
|
||||
@@ -23,7 +24,7 @@ export const ProfileFeedLikedByScreen = ({route}: Props) => {
|
||||
)
|
||||
|
||||
return (
|
||||
<View>
|
||||
<View style={{flex: 1}}>
|
||||
<ViewHeader title={_(msg`Liked By`)} />
|
||||
<PostLikedByComponent uri={uri} />
|
||||
</View>
|
||||
|
||||
@@ -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 ? (
|
||||
<ProfileListScreenLoaded {...props} uri={resolvedUri.uri} list={list} />
|
||||
return resolvedUri && list && moderationOpts ? (
|
||||
<ProfileListScreenLoaded
|
||||
{...props}
|
||||
uri={resolvedUri.uri}
|
||||
list={list}
|
||||
moderationOpts={moderationOpts}
|
||||
/>
|
||||
) : (
|
||||
<LoadingScreen />
|
||||
)
|
||||
@@ -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 (
|
||||
<ScreenHider
|
||||
screenDescription={'list'}
|
||||
modui={moderation.ui('contentView')}>
|
||||
<View style={s.hContentRegion}>
|
||||
<PagerWithHeader
|
||||
items={SECTION_TITLES_CURATE}
|
||||
isHeaderReady={true}
|
||||
renderHeader={renderHeader}
|
||||
onCurrentPageSelected={onCurrentPageSelected}>
|
||||
{({headerHeight, scrollElRef, isFocused}) => (
|
||||
<FeedSection
|
||||
ref={feedSectionRef}
|
||||
feed={`list|${uri}`}
|
||||
scrollElRef={scrollElRef as ListRef}
|
||||
headerHeight={headerHeight}
|
||||
isFocused={isScreenFocused && isFocused}
|
||||
/>
|
||||
)}
|
||||
{({headerHeight, scrollElRef}) => (
|
||||
<AboutSection
|
||||
ref={aboutSectionRef}
|
||||
scrollElRef={scrollElRef as ListRef}
|
||||
list={list}
|
||||
onPressAddUser={onPressAddUser}
|
||||
headerHeight={headerHeight}
|
||||
/>
|
||||
)}
|
||||
</PagerWithHeader>
|
||||
<FAB
|
||||
testID="composeFAB"
|
||||
onPress={() => openComposer({})}
|
||||
icon={
|
||||
<ComposeIcon2
|
||||
strokeWidth={1.5}
|
||||
size={29}
|
||||
style={{color: 'white'}}
|
||||
/>
|
||||
}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`New post`)}
|
||||
accessibilityHint=""
|
||||
/>
|
||||
</View>
|
||||
</ScreenHider>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<ScreenHider
|
||||
screenDescription={_(msg`list`)}
|
||||
modui={moderation.ui('contentView')}>
|
||||
<View style={s.hContentRegion}>
|
||||
<PagerWithHeader
|
||||
items={SECTION_TITLES_CURATE}
|
||||
items={SECTION_TITLES_MOD}
|
||||
isHeaderReady={true}
|
||||
renderHeader={renderHeader}
|
||||
onCurrentPageSelected={onCurrentPageSelected}>
|
||||
{({headerHeight, scrollElRef, isFocused}) => (
|
||||
<FeedSection
|
||||
ref={feedSectionRef}
|
||||
feed={`list|${uri}`}
|
||||
scrollElRef={scrollElRef as ListRef}
|
||||
headerHeight={headerHeight}
|
||||
isFocused={isScreenFocused && isFocused}
|
||||
/>
|
||||
)}
|
||||
renderHeader={renderHeader}>
|
||||
{({headerHeight, scrollElRef}) => (
|
||||
<AboutSection
|
||||
ref={aboutSectionRef}
|
||||
scrollElRef={scrollElRef as ListRef}
|
||||
list={list}
|
||||
scrollElRef={scrollElRef as ListRef}
|
||||
onPressAddUser={onPressAddUser}
|
||||
headerHeight={headerHeight}
|
||||
/>
|
||||
@@ -200,34 +262,7 @@ function ProfileListScreenLoaded({
|
||||
accessibilityHint=""
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<View style={s.hContentRegion}>
|
||||
<PagerWithHeader
|
||||
items={SECTION_TITLES_MOD}
|
||||
isHeaderReady={true}
|
||||
renderHeader={renderHeader}>
|
||||
{({headerHeight, scrollElRef}) => (
|
||||
<AboutSection
|
||||
list={list}
|
||||
scrollElRef={scrollElRef as ListRef}
|
||||
onPressAddUser={onPressAddUser}
|
||||
headerHeight={headerHeight}
|
||||
/>
|
||||
)}
|
||||
</PagerWithHeader>
|
||||
<FAB
|
||||
testID="composeFAB"
|
||||
onPress={() => openComposer({})}
|
||||
icon={
|
||||
<ComposeIcon2 strokeWidth={1.5} size={29} style={{color: 'white'}} />
|
||||
}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`New post`)}
|
||||
accessibilityHint=""
|
||||
/>
|
||||
</View>
|
||||
</ScreenHider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<CommonNavigatorParams, 'Settings'>
|
||||
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) {
|
||||
|
||||
<View style={styles.spacer20} />
|
||||
|
||||
<Text type="xl-bold" style={[pal.text, styles.heading]}>
|
||||
<Trans>Appearance</Trans>
|
||||
</Text>
|
||||
<View>
|
||||
<View style={[styles.linkCard, pal.view, styles.selectableBtns]}>
|
||||
<SelectableBtn
|
||||
selected={colorMode === 'system'}
|
||||
label={_(msg`System`)}
|
||||
left
|
||||
onSelect={() => setColorMode('system')}
|
||||
accessibilityHint={_(msg`Sets color theme to system setting`)}
|
||||
/>
|
||||
<SelectableBtn
|
||||
selected={colorMode === 'light'}
|
||||
label={_(msg`Light`)}
|
||||
onSelect={() => setColorMode('light')}
|
||||
accessibilityHint={_(msg`Sets color theme to light`)}
|
||||
/>
|
||||
<SelectableBtn
|
||||
selected={colorMode === 'dark'}
|
||||
label={_(msg`Dark`)}
|
||||
right
|
||||
onSelect={() => setColorMode('dark')}
|
||||
accessibilityHint={_(msg`Sets color theme to dark`)}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.spacer20} />
|
||||
|
||||
{colorMode !== 'light' && (
|
||||
<>
|
||||
<Text type="xl-bold" style={[pal.text, styles.heading]}>
|
||||
<Trans>Dark Theme</Trans>
|
||||
</Text>
|
||||
<View>
|
||||
<View style={[styles.linkCard, pal.view, styles.selectableBtns]}>
|
||||
<SelectableBtn
|
||||
selected={!darkTheme || darkTheme === 'dim'}
|
||||
label={_(msg`Dim`)}
|
||||
left
|
||||
onSelect={() => setDarkTheme('dim')}
|
||||
accessibilityHint={_(msg`Sets dark theme to the dim theme`)}
|
||||
/>
|
||||
<SelectableBtn
|
||||
selected={darkTheme === 'dark'}
|
||||
label={_(msg`Dark`)}
|
||||
right
|
||||
onSelect={() => setDarkTheme('dark')}
|
||||
accessibilityHint={_(msg`Sets dark theme to the dark theme`)}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.spacer20} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Text type="xl-bold" style={[pal.text, styles.heading]}>
|
||||
<Trans>Basics</Trans>
|
||||
</Text>
|
||||
@@ -519,6 +457,27 @@ export function SettingsScreen({}: Props) {
|
||||
<Trans>Accessibility</Trans>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
testID="appearanceSettingsBtn"
|
||||
style={[
|
||||
styles.linkCard,
|
||||
pal.view,
|
||||
isSwitchingAccounts && styles.dimmed,
|
||||
]}
|
||||
onPress={isSwitchingAccounts ? undefined : onPressAppearanceSettings}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Appearance settings`)}
|
||||
accessibilityHint={_(msg`Opens appearance settings`)}>
|
||||
<View style={[styles.iconContainer, pal.btn]}>
|
||||
<FontAwesomeIcon
|
||||
icon="paint-roller"
|
||||
style={pal.text as FontAwesomeIconStyle}
|
||||
/>
|
||||
</View>
|
||||
<Text type="lg" style={pal.text}>
|
||||
<Trans>Appearance</Trans>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
testID="languageSettingsBtn"
|
||||
style={[
|
||||
|
||||
@@ -34,10 +34,10 @@
|
||||
jsonpointer "^5.0.0"
|
||||
leven "^3.1.0"
|
||||
|
||||
"@atproto/api@0.12.25":
|
||||
version "0.12.25"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.25.tgz#9eeb51484106a5e07f89f124e505674a3574f93b"
|
||||
integrity sha512-IV3vGPnDw9bmyP/JOd8YKbm8fOpRAgJpEUVnIZNVb/Vo8v+WOroOjrJxtzdHOcXTL9IEcTTyXSCc7yE7kwhN2A==
|
||||
"@atproto/api@^0.12.26":
|
||||
version "0.12.26"
|
||||
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.26.tgz#940888466522cc9ff8c03d8164dc39221b29d9ca"
|
||||
integrity sha512-RH0ymOGbDfT8IL8eNzzY+hwtyTgknHfkzUVqRd0sstNblvTf8WGpDR2FSTveiiMR3OpVO6zG8fRYVzBfmY1+pA==
|
||||
dependencies:
|
||||
"@atproto/common-web" "^0.3.0"
|
||||
"@atproto/lexicon" "^0.4.0"
|
||||
|
||||
Reference in New Issue
Block a user