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"},
|
EnvVars: []string{"ATP_APPVIEW_HOST", "ATP_PDS_HOST"},
|
||||||
},
|
},
|
||||||
&cli.StringFlag{
|
&cli.StringFlag{
|
||||||
Name: "ogcard-host",
|
Name: "ogcard-host",
|
||||||
Usage: "scheme, hostname, and port of ogcard service",
|
Usage: "scheme, hostname, and port of ogcard service",
|
||||||
Required: false,
|
Required: false,
|
||||||
EnvVars: []string{"OGCARD_HOST"},
|
EnvVars: []string{"OGCARD_HOST"},
|
||||||
},
|
},
|
||||||
&cli.StringFlag{
|
&cli.StringFlag{
|
||||||
Name: "http-address",
|
Name: "http-address",
|
||||||
@@ -67,6 +67,13 @@ func run(args []string) {
|
|||||||
Required: false,
|
Required: false,
|
||||||
EnvVars: []string{"DEBUG"},
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/subtle"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
@@ -48,6 +49,7 @@ func serve(cctx *cli.Context) error {
|
|||||||
appviewHost := cctx.String("appview-host")
|
appviewHost := cctx.String("appview-host")
|
||||||
ogcardHost := cctx.String("ogcard-host")
|
ogcardHost := cctx.String("ogcard-host")
|
||||||
linkHost := cctx.String("link-host")
|
linkHost := cctx.String("link-host")
|
||||||
|
basicAuthPassword := cctx.String("basic-auth-password")
|
||||||
|
|
||||||
// Echo
|
// Echo
|
||||||
e := echo.New()
|
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.
|
// redirect trailing slash to non-trailing slash.
|
||||||
// all of our current endpoints have no trailing slash.
|
// all of our current endpoints have no trailing slash.
|
||||||
e.Use(middleware.RemoveTrailingSlashWithConfig(middleware.TrailingSlashConfig{
|
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/threads", server.WebGeneric)
|
||||||
e.GET("/settings/external-embeds", server.WebGeneric)
|
e.GET("/settings/external-embeds", server.WebGeneric)
|
||||||
e.GET("/settings/accessibility", server.WebGeneric)
|
e.GET("/settings/accessibility", server.WebGeneric)
|
||||||
|
e.GET("/settings/appearance", server.WebGeneric)
|
||||||
e.GET("/sys/debug", server.WebGeneric)
|
e.GET("/sys/debug", server.WebGeneric)
|
||||||
e.GET("/sys/debug-mod", server.WebGeneric)
|
e.GET("/sys/debug-mod", server.WebGeneric)
|
||||||
e.GET("/sys/log", server.WebGeneric)
|
e.GET("/sys/log", server.WebGeneric)
|
||||||
|
|||||||
+1
-1
@@ -52,7 +52,7 @@
|
|||||||
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
|
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@atproto/api": "0.12.25",
|
"@atproto/api": "^0.12.26",
|
||||||
"@bam.tech/react-native-image-resizer": "^3.0.10",
|
"@bam.tech/react-native-image-resizer": "^3.0.10",
|
||||||
"@braintree/sanitize-url": "^6.0.2",
|
"@braintree/sanitize-url": "^6.0.2",
|
||||||
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
||||||
|
|||||||
@@ -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 {ModerationScreen} from '#/screens/Moderation'
|
||||||
import {ProfileKnownFollowersScreen} from '#/screens/Profile/KnownFollowers'
|
import {ProfileKnownFollowersScreen} from '#/screens/Profile/KnownFollowers'
|
||||||
import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy'
|
import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy'
|
||||||
|
import {AppearanceSettingsScreen} from '#/screens/Settings/AppearanceSettings'
|
||||||
import {
|
import {
|
||||||
StarterPackScreen,
|
StarterPackScreen,
|
||||||
StarterPackScreenShort,
|
StarterPackScreenShort,
|
||||||
@@ -310,6 +311,14 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
|
|||||||
requireAuth: true,
|
requireAuth: true,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="AppearanceSettings"
|
||||||
|
getComponent={() => AppearanceSettingsScreen}
|
||||||
|
options={{
|
||||||
|
title: title(msg`Appearance Settings`),
|
||||||
|
requireAuth: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="Hashtag"
|
name="Hashtag"
|
||||||
getComponent={() => HashtagScreen}
|
getComponent={() => HashtagScreen}
|
||||||
|
|||||||
@@ -122,8 +122,16 @@ export function ListHeaderDesktop({
|
|||||||
if (!gtTablet) return null
|
if (!gtTablet) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[a.w_full, a.py_lg, a.px_xl, a.gap_xs]}>
|
<View
|
||||||
<Text style={[a.text_3xl, a.font_bold]}>{title}</Text>
|
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 ? (
|
{subtitle ? (
|
||||||
<Text style={[a.text_md, t.atoms.text_contrast_medium]}>
|
<Text style={[a.text_md, t.atoms.text_contrast_medium]}>
|
||||||
{subtitle}
|
{subtitle}
|
||||||
|
|||||||
@@ -1,27 +1,27 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import {useNavigation} from '@react-navigation/native'
|
|
||||||
import {useLingui} from '@lingui/react'
|
|
||||||
import {msg, Trans} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
import {useNavigation} from '@react-navigation/native'
|
||||||
|
|
||||||
import {atoms as a, native, useTheme} from '#/alf'
|
|
||||||
import * as Dialog from '#/components/Dialog'
|
|
||||||
import {Text} from '#/components/Typography'
|
|
||||||
import {Button, ButtonText} from '#/components/Button'
|
|
||||||
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
|
|
||||||
import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person'
|
|
||||||
import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
|
|
||||||
import {Divider} from '#/components/Divider'
|
|
||||||
import {Link} from '#/components/Link'
|
|
||||||
import {makeSearchLink} from '#/lib/routes/links'
|
import {makeSearchLink} from '#/lib/routes/links'
|
||||||
import {NavigationProp} from '#/lib/routes/types'
|
import {NavigationProp} from '#/lib/routes/types'
|
||||||
|
import {isInvalidHandle} from '#/lib/strings/handles'
|
||||||
import {
|
import {
|
||||||
usePreferencesQuery,
|
usePreferencesQuery,
|
||||||
|
useRemoveMutedWordsMutation,
|
||||||
useUpsertMutedWordsMutation,
|
useUpsertMutedWordsMutation,
|
||||||
useRemoveMutedWordMutation,
|
|
||||||
} from '#/state/queries/preferences'
|
} from '#/state/queries/preferences'
|
||||||
|
import {atoms as a, native, useTheme} from '#/alf'
|
||||||
|
import {Button, ButtonText} from '#/components/Button'
|
||||||
|
import * as Dialog from '#/components/Dialog'
|
||||||
|
import {Divider} from '#/components/Divider'
|
||||||
|
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
|
||||||
|
import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
|
||||||
|
import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person'
|
||||||
|
import {Link} from '#/components/Link'
|
||||||
import {Loader} from '#/components/Loader'
|
import {Loader} from '#/components/Loader'
|
||||||
import {isInvalidHandle} from '#/lib/strings/handles'
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
export function useTagMenuControl() {
|
export function useTagMenuControl() {
|
||||||
return Dialog.useDialogControl()
|
return Dialog.useDialogControl()
|
||||||
@@ -52,10 +52,10 @@ export function TagMenu({
|
|||||||
reset: resetUpsert,
|
reset: resetUpsert,
|
||||||
} = useUpsertMutedWordsMutation()
|
} = useUpsertMutedWordsMutation()
|
||||||
const {
|
const {
|
||||||
mutateAsync: removeMutedWord,
|
mutateAsync: removeMutedWords,
|
||||||
variables: optimisticRemove,
|
variables: optimisticRemove,
|
||||||
reset: resetRemove,
|
reset: resetRemove,
|
||||||
} = useRemoveMutedWordMutation()
|
} = useRemoveMutedWordsMutation()
|
||||||
const displayTag = '#' + tag
|
const displayTag = '#' + tag
|
||||||
|
|
||||||
const isMuted = Boolean(
|
const isMuted = Boolean(
|
||||||
@@ -65,9 +65,20 @@ export function TagMenu({
|
|||||||
optimisticUpsert?.find(
|
optimisticUpsert?.find(
|
||||||
m => m.value === tag && m.targets.includes('tag'),
|
m => m.value === tag && m.targets.includes('tag'),
|
||||||
)) &&
|
)) &&
|
||||||
!(optimisticRemove?.value === tag),
|
!optimisticRemove?.find(m => m?.value === tag),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Mute word records that exactly match the tag in question.
|
||||||
|
*/
|
||||||
|
const removeableMuteWords = React.useMemo(() => {
|
||||||
|
return (
|
||||||
|
preferences?.moderationPrefs.mutedWords?.filter(word => {
|
||||||
|
return word.value === tag
|
||||||
|
}) || []
|
||||||
|
)
|
||||||
|
}, [tag, preferences?.moderationPrefs?.mutedWords])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{children}
|
{children}
|
||||||
@@ -212,13 +223,16 @@ export function TagMenu({
|
|||||||
control.close(() => {
|
control.close(() => {
|
||||||
if (isMuted) {
|
if (isMuted) {
|
||||||
resetUpsert()
|
resetUpsert()
|
||||||
removeMutedWord({
|
removeMutedWords(removeableMuteWords)
|
||||||
value: tag,
|
|
||||||
targets: ['tag'],
|
|
||||||
})
|
|
||||||
} else {
|
} else {
|
||||||
resetRemove()
|
resetRemove()
|
||||||
upsertMutedWord([{value: tag, targets: ['tag']}])
|
upsertMutedWord([
|
||||||
|
{
|
||||||
|
value: tag,
|
||||||
|
targets: ['tag'],
|
||||||
|
actorTarget: 'all',
|
||||||
|
},
|
||||||
|
])
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}}>
|
}}>
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
import React from 'react'
|
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 {EventStopper} from '#/view/com/util/EventStopper'
|
||||||
|
// import {NativeDropdown} from '#/view/com/util/forms/NativeDropdown'
|
||||||
|
// import {web} from '#/alf'
|
||||||
import * as Dialog from '#/components/Dialog'
|
import * as Dialog from '#/components/Dialog'
|
||||||
|
|
||||||
// @TODO Fabric
|
// @TODO Fabric
|
||||||
@@ -32,8 +42,8 @@ export function TagMenu({}: React.PropsWithChildren<{
|
|||||||
// const {data: preferences} = usePreferencesQuery()
|
// const {data: preferences} = usePreferencesQuery()
|
||||||
// const {mutateAsync: upsertMutedWord, variables: optimisticUpsert} =
|
// const {mutateAsync: upsertMutedWord, variables: optimisticUpsert} =
|
||||||
// useUpsertMutedWordsMutation()
|
// useUpsertMutedWordsMutation()
|
||||||
// const {mutateAsync: removeMutedWord, variables: optimisticRemove} =
|
// const {mutateAsync: removeMutedWords, variables: optimisticRemove} =
|
||||||
// useRemoveMutedWordMutation()
|
// useRemoveMutedWordsMutation()
|
||||||
// const isMuted = Boolean(
|
// const isMuted = Boolean(
|
||||||
// (preferences?.moderationPrefs.mutedWords?.find(
|
// (preferences?.moderationPrefs.mutedWords?.find(
|
||||||
// m => m.value === tag && m.targets.includes('tag'),
|
// m => m.value === tag && m.targets.includes('tag'),
|
||||||
@@ -41,10 +51,21 @@ export function TagMenu({}: React.PropsWithChildren<{
|
|||||||
// optimisticUpsert?.find(
|
// optimisticUpsert?.find(
|
||||||
// m => m.value === tag && m.targets.includes('tag'),
|
// m => m.value === tag && m.targets.includes('tag'),
|
||||||
// )) &&
|
// )) &&
|
||||||
// !(optimisticRemove?.value === tag),
|
// !optimisticRemove?.find(m => m?.value === tag),
|
||||||
// )
|
// )
|
||||||
// const truncatedTag = '#' + enforceLen(tag, 15, true, 'middle')
|
// const truncatedTag = '#' + enforceLen(tag, 15, true, 'middle')
|
||||||
|
//
|
||||||
|
// /*
|
||||||
|
// * Mute word records that exactly match the tag in question.
|
||||||
|
// */
|
||||||
|
// const removeableMuteWords = React.useMemo(() => {
|
||||||
|
// return (
|
||||||
|
// preferences?.moderationPrefs.mutedWords?.filter(word => {
|
||||||
|
// return word.value === tag
|
||||||
|
// }) || []
|
||||||
|
// )
|
||||||
|
// }, [tag, preferences?.moderationPrefs?.mutedWords])
|
||||||
|
//
|
||||||
// const dropdownItems = React.useMemo(() => {
|
// const dropdownItems = React.useMemo(() => {
|
||||||
// return [
|
// return [
|
||||||
// {
|
// {
|
||||||
@@ -90,9 +111,11 @@ export function TagMenu({}: React.PropsWithChildren<{
|
|||||||
// : _(msg`Mute ${truncatedTag}`),
|
// : _(msg`Mute ${truncatedTag}`),
|
||||||
// onPress() {
|
// onPress() {
|
||||||
// if (isMuted) {
|
// if (isMuted) {
|
||||||
// removeMutedWord({value: tag, targets: ['tag']})
|
// removeMutedWords(removeableMuteWords)
|
||||||
// } else {
|
// } else {
|
||||||
// upsertMutedWord([{value: tag, targets: ['tag']}])
|
// upsertMutedWord([
|
||||||
|
// {value: tag, targets: ['tag'], actorTarget: 'all'},
|
||||||
|
// ])
|
||||||
// }
|
// }
|
||||||
// },
|
// },
|
||||||
// testID: 'tagMenuMute',
|
// testID: 'tagMenuMute',
|
||||||
@@ -114,7 +137,8 @@ export function TagMenu({}: React.PropsWithChildren<{
|
|||||||
// tag,
|
// tag,
|
||||||
// truncatedTag,
|
// truncatedTag,
|
||||||
// upsertMutedWord,
|
// upsertMutedWord,
|
||||||
// removeMutedWord,
|
// removeMutedWords,
|
||||||
|
// removeableMuteWords,
|
||||||
// ])
|
// ])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {Keyboard, View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import {AppBskyActorDefs, sanitizeMutedWordValue} from '@atproto/api'
|
import {AppBskyActorDefs, sanitizeMutedWordValue} from '@atproto/api'
|
||||||
import {msg, Trans} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
@@ -24,6 +24,7 @@ import * as Dialog from '#/components/Dialog'
|
|||||||
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
|
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
|
||||||
import {Divider} from '#/components/Divider'
|
import {Divider} from '#/components/Divider'
|
||||||
import * as Toggle from '#/components/forms/Toggle'
|
import * as Toggle from '#/components/forms/Toggle'
|
||||||
|
import {useFormatDistance} from '#/components/hooks/dates'
|
||||||
import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
|
import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
|
||||||
import {PageText_Stroke2_Corner0_Rounded as PageText} from '#/components/icons/PageText'
|
import {PageText_Stroke2_Corner0_Rounded as PageText} from '#/components/icons/PageText'
|
||||||
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
||||||
@@ -32,6 +33,8 @@ import {Loader} from '#/components/Loader'
|
|||||||
import * as Prompt from '#/components/Prompt'
|
import * as Prompt from '#/components/Prompt'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
const ONE_DAY = 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
export function MutedWordsDialog() {
|
export function MutedWordsDialog() {
|
||||||
const {mutedWordsDialogControl: control} = useGlobalDialogsControlContext()
|
const {mutedWordsDialogControl: control} = useGlobalDialogsControlContext()
|
||||||
return (
|
return (
|
||||||
@@ -53,16 +56,32 @@ function MutedWordsInner() {
|
|||||||
} = usePreferencesQuery()
|
} = usePreferencesQuery()
|
||||||
const {isPending, mutateAsync: addMutedWord} = useUpsertMutedWordsMutation()
|
const {isPending, mutateAsync: addMutedWord} = useUpsertMutedWordsMutation()
|
||||||
const [field, setField] = React.useState('')
|
const [field, setField] = React.useState('')
|
||||||
const [options, setOptions] = React.useState(['content'])
|
const [targets, setTargets] = React.useState(['content'])
|
||||||
const [error, setError] = React.useState('')
|
const [error, setError] = React.useState('')
|
||||||
|
const [durations, setDurations] = React.useState(['forever'])
|
||||||
|
const [excludeFollowing, setExcludeFollowing] = React.useState(false)
|
||||||
|
|
||||||
const submit = React.useCallback(async () => {
|
const submit = React.useCallback(async () => {
|
||||||
const sanitizedValue = sanitizeMutedWordValue(field)
|
const sanitizedValue = sanitizeMutedWordValue(field)
|
||||||
const targets = ['tag', options.includes('content') && 'content'].filter(
|
const surfaces = ['tag', targets.includes('content') && 'content'].filter(
|
||||||
Boolean,
|
Boolean,
|
||||||
) as AppBskyActorDefs.MutedWord['targets']
|
) as AppBskyActorDefs.MutedWord['targets']
|
||||||
|
const actorTarget = excludeFollowing ? 'exclude-following' : 'all'
|
||||||
|
|
||||||
if (!sanitizedValue || !targets.length) {
|
const now = Date.now()
|
||||||
|
const rawDuration = durations.at(0)
|
||||||
|
// undefined evaluates to 'forever'
|
||||||
|
let duration: string | undefined
|
||||||
|
|
||||||
|
if (rawDuration === '24_hours') {
|
||||||
|
duration = new Date(now + ONE_DAY).toISOString()
|
||||||
|
} else if (rawDuration === '7_days') {
|
||||||
|
duration = new Date(now + 7 * ONE_DAY).toISOString()
|
||||||
|
} else if (rawDuration === '30_days') {
|
||||||
|
duration = new Date(now + 30 * ONE_DAY).toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sanitizedValue || !surfaces.length) {
|
||||||
setField('')
|
setField('')
|
||||||
setError(_(msg`Please enter a valid word, tag, or phrase to mute`))
|
setError(_(msg`Please enter a valid word, tag, or phrase to mute`))
|
||||||
return
|
return
|
||||||
@@ -70,28 +89,37 @@ function MutedWordsInner() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// send raw value and rely on SDK as sanitization source of truth
|
// send raw value and rely on SDK as sanitization source of truth
|
||||||
await addMutedWord([{value: field, targets}])
|
await addMutedWord([
|
||||||
|
{
|
||||||
|
value: field,
|
||||||
|
targets: surfaces,
|
||||||
|
actorTarget,
|
||||||
|
expiresAt: duration,
|
||||||
|
},
|
||||||
|
])
|
||||||
setField('')
|
setField('')
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
logger.error(`Failed to save muted word`, {message: e.message})
|
logger.error(`Failed to save muted word`, {message: e.message})
|
||||||
setError(e.message)
|
setError(e.message)
|
||||||
}
|
}
|
||||||
}, [_, field, options, addMutedWord, setField])
|
}, [_, field, targets, addMutedWord, setField, durations, excludeFollowing])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog.ScrollableInner label={_(msg`Manage your muted words and tags`)}>
|
<Dialog.ScrollableInner label={_(msg`Manage your muted words and tags`)}>
|
||||||
<View onTouchStart={Keyboard.dismiss}>
|
<View>
|
||||||
<Text
|
<Text
|
||||||
style={[a.text_md, a.font_bold, a.pb_sm, t.atoms.text_contrast_high]}>
|
style={[a.text_md, a.font_bold, a.pb_sm, t.atoms.text_contrast_high]}>
|
||||||
<Trans>Add muted words and tags</Trans>
|
<Trans>Add muted words and tags</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[a.pb_lg, a.leading_snug, t.atoms.text_contrast_medium]}>
|
<Text style={[a.pb_lg, a.leading_snug, t.atoms.text_contrast_medium]}>
|
||||||
<Trans>
|
<Trans>
|
||||||
Posts can be muted based on their text, their tags, or both.
|
Posts can be muted based on their text, their tags, or both. We
|
||||||
|
recommend avoiding common words that appear in many posts, since it
|
||||||
|
can result in no posts being shown.
|
||||||
</Trans>
|
</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<View style={[a.pb_lg]}>
|
<View style={[a.pb_sm]}>
|
||||||
<Dialog.Input
|
<Dialog.Input
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
@@ -107,30 +135,135 @@ function MutedWordsInner() {
|
|||||||
}}
|
}}
|
||||||
onSubmitEditing={submit}
|
onSubmitEditing={submit}
|
||||||
/>
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={[a.pb_xl, a.gap_sm]}>
|
||||||
<Toggle.Group
|
<Toggle.Group
|
||||||
label={_(msg`Toggle between muted word options.`)}
|
label={_(msg`Select how long to mute this word for.`)}
|
||||||
type="radio"
|
type="radio"
|
||||||
values={options}
|
values={durations}
|
||||||
onChange={setOptions}>
|
onChange={setDurations}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.pb_xs,
|
||||||
|
a.text_sm,
|
||||||
|
a.font_bold,
|
||||||
|
t.atoms.text_contrast_medium,
|
||||||
|
]}>
|
||||||
|
<Trans>Duration:</Trans>
|
||||||
|
</Text>
|
||||||
|
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
a.pt_sm,
|
gtMobile && [a.flex_row, a.align_center, a.justify_start],
|
||||||
a.py_sm,
|
|
||||||
a.flex_row,
|
|
||||||
a.align_center,
|
|
||||||
a.gap_sm,
|
a.gap_sm,
|
||||||
a.flex_wrap,
|
|
||||||
]}>
|
]}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.flex_1,
|
||||||
|
a.flex_row,
|
||||||
|
a.justify_start,
|
||||||
|
a.align_center,
|
||||||
|
a.gap_sm,
|
||||||
|
]}>
|
||||||
|
<Toggle.Item
|
||||||
|
label={_(msg`Mute this word until you unmute it`)}
|
||||||
|
name="forever"
|
||||||
|
style={[a.flex_1]}>
|
||||||
|
<TargetToggle>
|
||||||
|
<View
|
||||||
|
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
|
||||||
|
<Toggle.Radio />
|
||||||
|
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
|
||||||
|
<Trans>Forever</Trans>
|
||||||
|
</Toggle.LabelText>
|
||||||
|
</View>
|
||||||
|
</TargetToggle>
|
||||||
|
</Toggle.Item>
|
||||||
|
|
||||||
|
<Toggle.Item
|
||||||
|
label={_(msg`Mute this word for 24 hours`)}
|
||||||
|
name="24_hours"
|
||||||
|
style={[a.flex_1]}>
|
||||||
|
<TargetToggle>
|
||||||
|
<View
|
||||||
|
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
|
||||||
|
<Toggle.Radio />
|
||||||
|
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
|
||||||
|
<Trans>24 hours</Trans>
|
||||||
|
</Toggle.LabelText>
|
||||||
|
</View>
|
||||||
|
</TargetToggle>
|
||||||
|
</Toggle.Item>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.flex_1,
|
||||||
|
a.flex_row,
|
||||||
|
a.justify_start,
|
||||||
|
a.align_center,
|
||||||
|
a.gap_sm,
|
||||||
|
]}>
|
||||||
|
<Toggle.Item
|
||||||
|
label={_(msg`Mute this word for 7 days`)}
|
||||||
|
name="7_days"
|
||||||
|
style={[a.flex_1]}>
|
||||||
|
<TargetToggle>
|
||||||
|
<View
|
||||||
|
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
|
||||||
|
<Toggle.Radio />
|
||||||
|
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
|
||||||
|
<Trans>7 days</Trans>
|
||||||
|
</Toggle.LabelText>
|
||||||
|
</View>
|
||||||
|
</TargetToggle>
|
||||||
|
</Toggle.Item>
|
||||||
|
|
||||||
|
<Toggle.Item
|
||||||
|
label={_(msg`Mute this word for 30 days`)}
|
||||||
|
name="30_days"
|
||||||
|
style={[a.flex_1]}>
|
||||||
|
<TargetToggle>
|
||||||
|
<View
|
||||||
|
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
|
||||||
|
<Toggle.Radio />
|
||||||
|
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
|
||||||
|
<Trans>30 days</Trans>
|
||||||
|
</Toggle.LabelText>
|
||||||
|
</View>
|
||||||
|
</TargetToggle>
|
||||||
|
</Toggle.Item>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Toggle.Group>
|
||||||
|
|
||||||
|
<Toggle.Group
|
||||||
|
label={_(msg`Select what content this mute word should apply to.`)}
|
||||||
|
type="radio"
|
||||||
|
values={targets}
|
||||||
|
onChange={setTargets}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.pb_xs,
|
||||||
|
a.text_sm,
|
||||||
|
a.font_bold,
|
||||||
|
t.atoms.text_contrast_medium,
|
||||||
|
]}>
|
||||||
|
<Trans>Mute in:</Trans>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={[a.flex_row, a.align_center, a.gap_sm, a.flex_wrap]}>
|
||||||
<Toggle.Item
|
<Toggle.Item
|
||||||
label={_(msg`Mute this word in post text and tags`)}
|
label={_(msg`Mute this word in post text and tags`)}
|
||||||
name="content"
|
name="content"
|
||||||
style={[a.flex_1, !gtMobile && [a.w_full, a.flex_0]]}>
|
style={[a.flex_1]}>
|
||||||
<TargetToggle>
|
<TargetToggle>
|
||||||
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
|
<View
|
||||||
|
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
|
||||||
<Toggle.Radio />
|
<Toggle.Radio />
|
||||||
<Toggle.LabelText>
|
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
|
||||||
<Trans>Mute in text & tags</Trans>
|
<Trans>Text & tags</Trans>
|
||||||
</Toggle.LabelText>
|
</Toggle.LabelText>
|
||||||
</View>
|
</View>
|
||||||
<PageText size="sm" />
|
<PageText size="sm" />
|
||||||
@@ -140,34 +273,64 @@ function MutedWordsInner() {
|
|||||||
<Toggle.Item
|
<Toggle.Item
|
||||||
label={_(msg`Mute this word in tags only`)}
|
label={_(msg`Mute this word in tags only`)}
|
||||||
name="tag"
|
name="tag"
|
||||||
style={[a.flex_1, !gtMobile && [a.w_full, a.flex_0]]}>
|
style={[a.flex_1]}>
|
||||||
<TargetToggle>
|
<TargetToggle>
|
||||||
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
|
<View
|
||||||
|
style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
|
||||||
<Toggle.Radio />
|
<Toggle.Radio />
|
||||||
<Toggle.LabelText>
|
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
|
||||||
<Trans>Mute in tags only</Trans>
|
<Trans>Tags only</Trans>
|
||||||
</Toggle.LabelText>
|
</Toggle.LabelText>
|
||||||
</View>
|
</View>
|
||||||
<Hashtag size="sm" />
|
<Hashtag size="sm" />
|
||||||
</TargetToggle>
|
</TargetToggle>
|
||||||
</Toggle.Item>
|
</Toggle.Item>
|
||||||
|
|
||||||
<Button
|
|
||||||
disabled={isPending || !field}
|
|
||||||
label={_(msg`Add mute word for configured settings`)}
|
|
||||||
size="small"
|
|
||||||
color="primary"
|
|
||||||
variant="solid"
|
|
||||||
style={[!gtMobile && [a.w_full, a.flex_0]]}
|
|
||||||
onPress={submit}>
|
|
||||||
<ButtonText>
|
|
||||||
<Trans>Add</Trans>
|
|
||||||
</ButtonText>
|
|
||||||
<ButtonIcon icon={isPending ? Loader : Plus} />
|
|
||||||
</Button>
|
|
||||||
</View>
|
</View>
|
||||||
</Toggle.Group>
|
</Toggle.Group>
|
||||||
|
|
||||||
|
<View>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.pb_xs,
|
||||||
|
a.text_sm,
|
||||||
|
a.font_bold,
|
||||||
|
t.atoms.text_contrast_medium,
|
||||||
|
]}>
|
||||||
|
<Trans>Options:</Trans>
|
||||||
|
</Text>
|
||||||
|
<Toggle.Item
|
||||||
|
label={_(msg`Do not apply this mute word to users you follow`)}
|
||||||
|
name="exclude_following"
|
||||||
|
style={[a.flex_row, a.justify_between]}
|
||||||
|
value={excludeFollowing}
|
||||||
|
onChange={setExcludeFollowing}>
|
||||||
|
<TargetToggle>
|
||||||
|
<View style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
|
||||||
|
<Toggle.Checkbox />
|
||||||
|
<Toggle.LabelText style={[a.flex_1, a.leading_tight]}>
|
||||||
|
<Trans>Exclude users you follow</Trans>
|
||||||
|
</Toggle.LabelText>
|
||||||
|
</View>
|
||||||
|
</TargetToggle>
|
||||||
|
</Toggle.Item>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={[a.pt_xs]}>
|
||||||
|
<Button
|
||||||
|
disabled={isPending || !field}
|
||||||
|
label={_(msg`Add mute word for configured settings`)}
|
||||||
|
size="medium"
|
||||||
|
color="primary"
|
||||||
|
variant="solid"
|
||||||
|
style={[]}
|
||||||
|
onPress={submit}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Add</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
<ButtonIcon icon={isPending ? Loader : Plus} position="right" />
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
@@ -191,20 +354,6 @@ function MutedWordsInner() {
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
a.pt_xs,
|
|
||||||
a.text_sm,
|
|
||||||
a.italic,
|
|
||||||
a.leading_snug,
|
|
||||||
t.atoms.text_contrast_medium,
|
|
||||||
]}>
|
|
||||||
<Trans>
|
|
||||||
We recommend avoiding common words that appear in many posts,
|
|
||||||
since it can result in no posts being shown.
|
|
||||||
</Trans>
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
@@ -268,6 +417,9 @@ function MutedWordRow({
|
|||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const {isPending, mutateAsync: removeMutedWord} = useRemoveMutedWordMutation()
|
const {isPending, mutateAsync: removeMutedWord} = useRemoveMutedWordMutation()
|
||||||
const control = Prompt.usePromptControl()
|
const control = Prompt.usePromptControl()
|
||||||
|
const expiryDate = word.expiresAt ? new Date(word.expiresAt) : undefined
|
||||||
|
const isExpired = expiryDate && expiryDate < new Date()
|
||||||
|
const formatDistance = useFormatDistance()
|
||||||
|
|
||||||
const remove = React.useCallback(async () => {
|
const remove = React.useCallback(async () => {
|
||||||
control.close()
|
control.close()
|
||||||
@@ -280,7 +432,7 @@ function MutedWordRow({
|
|||||||
control={control}
|
control={control}
|
||||||
title={_(msg`Are you sure?`)}
|
title={_(msg`Are you sure?`)}
|
||||||
description={_(
|
description={_(
|
||||||
msg`This will delete ${word.value} from your muted words. You can always add it back later.`,
|
msg`This will delete "${word.value}" from your muted words. You can always add it back later.`,
|
||||||
)}
|
)}
|
||||||
onConfirm={remove}
|
onConfirm={remove}
|
||||||
confirmButtonCta={_(msg`Remove`)}
|
confirmButtonCta={_(msg`Remove`)}
|
||||||
@@ -289,53 +441,94 @@ function MutedWordRow({
|
|||||||
|
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
|
a.flex_row,
|
||||||
|
a.justify_between,
|
||||||
a.py_md,
|
a.py_md,
|
||||||
a.px_lg,
|
a.px_lg,
|
||||||
a.flex_row,
|
|
||||||
a.align_center,
|
|
||||||
a.justify_between,
|
|
||||||
a.rounded_md,
|
a.rounded_md,
|
||||||
a.gap_md,
|
a.gap_md,
|
||||||
style,
|
style,
|
||||||
]}>
|
]}>
|
||||||
<Text
|
<View style={[a.flex_1, a.gap_xs]}>
|
||||||
style={[
|
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
|
||||||
a.flex_1,
|
<Text
|
||||||
a.leading_snug,
|
style={[
|
||||||
a.w_full,
|
a.flex_1,
|
||||||
a.font_bold,
|
a.leading_snug,
|
||||||
t.atoms.text_contrast_high,
|
a.font_bold,
|
||||||
web({
|
web({
|
||||||
overflowWrap: 'break-word',
|
overflowWrap: 'break-word',
|
||||||
wordBreak: 'break-word',
|
wordBreak: 'break-word',
|
||||||
}),
|
}),
|
||||||
]}>
|
]}>
|
||||||
{word.value}
|
{word.targets.find(t => t === 'content') ? (
|
||||||
</Text>
|
<Trans comment="Pattern: {wordValue} in text, tags">
|
||||||
|
{word.value}{' '}
|
||||||
|
<Text style={[a.font_normal, t.atoms.text_contrast_medium]}>
|
||||||
|
in{' '}
|
||||||
|
<Text style={[a.font_bold, t.atoms.text_contrast_medium]}>
|
||||||
|
text & tags
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans comment="Pattern: {wordValue} in tags">
|
||||||
|
{word.value}{' '}
|
||||||
|
<Text style={[a.font_normal, t.atoms.text_contrast_medium]}>
|
||||||
|
in{' '}
|
||||||
|
<Text style={[a.font_bold, t.atoms.text_contrast_medium]}>
|
||||||
|
tags
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
</Trans>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
<View style={[a.flex_row, a.align_center, a.justify_end, a.gap_sm]}>
|
{(expiryDate || word.actorTarget === 'exclude-following') && (
|
||||||
{word.targets.map(target => (
|
<View style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
|
||||||
<View
|
|
||||||
key={target}
|
|
||||||
style={[a.py_xs, a.px_sm, a.rounded_sm, t.atoms.bg_contrast_100]}>
|
|
||||||
<Text
|
<Text
|
||||||
style={[a.text_xs, a.font_bold, t.atoms.text_contrast_medium]}>
|
style={[
|
||||||
{target === 'content' ? _(msg`text`) : _(msg`tag`)}
|
a.flex_1,
|
||||||
|
a.text_xs,
|
||||||
|
a.leading_snug,
|
||||||
|
t.atoms.text_contrast_medium,
|
||||||
|
]}>
|
||||||
|
{expiryDate && (
|
||||||
|
<>
|
||||||
|
{isExpired ? (
|
||||||
|
<Trans>Expired</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>
|
||||||
|
Expires{' '}
|
||||||
|
{formatDistance(expiryDate, new Date(), {
|
||||||
|
addSuffix: true,
|
||||||
|
})}
|
||||||
|
</Trans>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{word.actorTarget === 'exclude-following' && (
|
||||||
|
<>
|
||||||
|
{' • '}
|
||||||
|
<Trans>Excludes users you follow</Trans>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
))}
|
)}
|
||||||
|
|
||||||
<Button
|
|
||||||
label={_(msg`Remove mute word from your list`)}
|
|
||||||
size="tiny"
|
|
||||||
shape="round"
|
|
||||||
variant="ghost"
|
|
||||||
color="secondary"
|
|
||||||
onPress={() => control.open()}
|
|
||||||
style={[a.ml_sm]}>
|
|
||||||
<ButtonIcon icon={isPending ? Loader : X} />
|
|
||||||
</Button>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
label={_(msg`Remove mute word from your list`)}
|
||||||
|
size="tiny"
|
||||||
|
shape="round"
|
||||||
|
variant="outline"
|
||||||
|
color="secondary"
|
||||||
|
onPress={() => control.open()}
|
||||||
|
style={[a.ml_sm]}>
|
||||||
|
<ButtonIcon icon={isPending ? Loader : X} />
|
||||||
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -23,10 +23,10 @@ export function Group({children, multiple, ...props}: GroupProps) {
|
|||||||
style={[
|
style={[
|
||||||
a.w_full,
|
a.w_full,
|
||||||
a.flex_row,
|
a.flex_row,
|
||||||
a.border,
|
|
||||||
a.rounded_sm,
|
a.rounded_sm,
|
||||||
a.overflow_hidden,
|
a.overflow_hidden,
|
||||||
t.atoms.border_contrast_low,
|
t.atoms.border_contrast_low,
|
||||||
|
{borderWidth: 1},
|
||||||
]}>
|
]}>
|
||||||
{children}
|
{children}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* Hooks for date-fns localized formatters.
|
||||||
|
*
|
||||||
|
* Our app supports some languages that are not included in date-fns by
|
||||||
|
* default, in which case it will fall back to English.
|
||||||
|
*
|
||||||
|
* {@link https://github.com/date-fns/date-fns/blob/main/docs/i18n.md}
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import {formatDistance, Locale} from 'date-fns'
|
||||||
|
import {
|
||||||
|
ca,
|
||||||
|
de,
|
||||||
|
es,
|
||||||
|
fi,
|
||||||
|
fr,
|
||||||
|
hi,
|
||||||
|
id,
|
||||||
|
it,
|
||||||
|
ja,
|
||||||
|
ko,
|
||||||
|
ptBR,
|
||||||
|
tr,
|
||||||
|
uk,
|
||||||
|
zhCN,
|
||||||
|
zhTW,
|
||||||
|
} from 'date-fns/locale'
|
||||||
|
|
||||||
|
import {AppLanguage} from '#/locale/languages'
|
||||||
|
import {useLanguagePrefs} from '#/state/preferences'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link AppLanguage}
|
||||||
|
*/
|
||||||
|
const locales: Record<AppLanguage, Locale | undefined> = {
|
||||||
|
en: undefined,
|
||||||
|
ca,
|
||||||
|
de,
|
||||||
|
es,
|
||||||
|
fi,
|
||||||
|
fr,
|
||||||
|
ga: undefined,
|
||||||
|
hi,
|
||||||
|
id,
|
||||||
|
it,
|
||||||
|
ja,
|
||||||
|
ko,
|
||||||
|
['pt-BR']: ptBR,
|
||||||
|
tr,
|
||||||
|
uk,
|
||||||
|
['zh-CN']: zhCN,
|
||||||
|
['zh-TW']: zhTW,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a localized `formatDistance` function.
|
||||||
|
* {@link formatDistance}
|
||||||
|
*/
|
||||||
|
export function useFormatDistance() {
|
||||||
|
const {appLanguage} = useLanguagePrefs()
|
||||||
|
return React.useCallback<typeof formatDistance>(
|
||||||
|
(date, baseDate, options) => {
|
||||||
|
const locale = locales[appLanguage as AppLanguage]
|
||||||
|
return formatDistance(date, baseDate, {...options, locale: locale})
|
||||||
|
},
|
||||||
|
[appLanguage],
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import {createSinglePathSVG} from './TEMPLATE'
|
||||||
|
|
||||||
|
export const Moon_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||||
|
path: 'M12.097 2.53a1 1 0 0 1-.041 1.07 6 6 0 0 0 8.345 8.344 1 1 0 0 1 1.563.908c-.434 5.122-4.728 9.144-9.962 9.144-5.522 0-9.998-4.476-9.998-9.998 0-5.234 4.021-9.528 9.144-9.962a1 1 0 0 1 .949.494ZM9.424 4.424a7.998 7.998 0 1 0 10.152 10.152A8 8 0 0 1 9.424 4.424Z',
|
||||||
|
})
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import {createSinglePathSVG} from './TEMPLATE'
|
||||||
|
|
||||||
|
export const Phone_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||||
|
path: 'M5 4a3 3 0 0 1 3-3h8a3 3 0 0 1 3 3v16a3 3 0 0 1-3 3H8a3 3 0 0 1-3-3V4Zm3-1a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1H8Zm2 2a1 1 0 0 1 1-1h2a1 1 0 1 1 0 2h-2a1 1 0 0 1-1-1Z',
|
||||||
|
})
|
||||||
@@ -14,7 +14,7 @@ import {useModerationCauseDescription} from '#/lib/moderation/useModerationCause
|
|||||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||||
import {NavigationProp} from 'lib/routes/types'
|
import {NavigationProp} from 'lib/routes/types'
|
||||||
import {CenteredView} from '#/view/com/util/Views'
|
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 {Button, ButtonText} from '#/components/Button'
|
||||||
import {
|
import {
|
||||||
ModerationDetailsDialog,
|
ModerationDetailsDialog,
|
||||||
@@ -105,6 +105,7 @@ export function ScreenHider({
|
|||||||
a.mb_md,
|
a.mb_md,
|
||||||
a.px_lg,
|
a.px_lg,
|
||||||
a.text_center,
|
a.text_center,
|
||||||
|
a.leading_snug,
|
||||||
t.atoms.text_contrast_medium,
|
t.atoms.text_contrast_medium,
|
||||||
]}>
|
]}>
|
||||||
{isNoPwi ? (
|
{isNoPwi ? (
|
||||||
@@ -113,8 +114,15 @@ export function ScreenHider({
|
|||||||
</Trans>
|
</Trans>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Trans>This {screenDescription} has been flagged:</Trans>
|
<Trans>This {screenDescription} has been flagged:</Trans>{' '}
|
||||||
<Text style={[a.text_lg, a.font_semibold, t.atoms.text, a.ml_xs]}>
|
<Text
|
||||||
|
style={[
|
||||||
|
a.text_lg,
|
||||||
|
a.font_semibold,
|
||||||
|
a.leading_snug,
|
||||||
|
t.atoms.text,
|
||||||
|
a.ml_xs,
|
||||||
|
]}>
|
||||||
{desc.name}.{' '}
|
{desc.name}.{' '}
|
||||||
</Text>
|
</Text>
|
||||||
<TouchableWithoutFeedback
|
<TouchableWithoutFeedback
|
||||||
@@ -127,16 +135,17 @@ export function ScreenHider({
|
|||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
a.text_lg,
|
a.text_lg,
|
||||||
|
a.leading_snug,
|
||||||
{
|
{
|
||||||
color: t.palette.primary_500,
|
color: t.palette.primary_500,
|
||||||
// @ts-ignore web only -prf
|
|
||||||
cursor: 'pointer',
|
|
||||||
},
|
},
|
||||||
|
web({
|
||||||
|
cursor: 'pointer',
|
||||||
|
}),
|
||||||
]}>
|
]}>
|
||||||
<Trans>Learn More</Trans>
|
<Trans>Learn More</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableWithoutFeedback>
|
</TouchableWithoutFeedback>
|
||||||
|
|
||||||
<ModerationDetailsDialog control={control} modcause={blur} />
|
<ModerationDetailsDialog control={control} modcause={blur} />
|
||||||
</>
|
</>
|
||||||
)}{' '}
|
)}{' '}
|
||||||
|
|||||||
+18
-48
@@ -82,10 +82,6 @@ export class FeedViewPostsSlice {
|
|||||||
return AppBskyFeedDefs.isReasonRepost(reason)
|
return AppBskyFeedDefs.isReasonRepost(reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
get includesThreadRoot() {
|
|
||||||
return !this.items[0].reply
|
|
||||||
}
|
|
||||||
|
|
||||||
get likeCount() {
|
get likeCount() {
|
||||||
return this._feedPost.post.likeCount ?? 0
|
return this._feedPost.post.likeCount ?? 0
|
||||||
}
|
}
|
||||||
@@ -119,30 +115,19 @@ export class FeedViewPostsSlice {
|
|||||||
|
|
||||||
isFollowingAllAuthors(userDid: string) {
|
isFollowingAllAuthors(userDid: string) {
|
||||||
const feedPost = this._feedPost
|
const feedPost = this._feedPost
|
||||||
if (feedPost.post.author.did === userDid) {
|
const authors = [feedPost.post.author]
|
||||||
return true
|
if (feedPost.reply) {
|
||||||
}
|
if (AppBskyFeedDefs.isPostView(feedPost.reply.parent)) {
|
||||||
if (AppBskyFeedDefs.isPostView(feedPost.reply?.parent)) {
|
authors.push(feedPost.reply.parent.author)
|
||||||
const parent = feedPost.reply?.parent
|
}
|
||||||
if (parent?.author.did === userDid) {
|
if (feedPost.reply.grandparentAuthor) {
|
||||||
return true
|
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
|
return authors.every(a => a.did === userDid || a.viewer?.following)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class NoopFeedTuner {
|
|
||||||
reset() {}
|
|
||||||
tune(
|
|
||||||
feed: FeedViewPost[],
|
|
||||||
_opts?: {dryRun: boolean; maintainOrder: boolean},
|
|
||||||
): FeedViewPostsSlice[] {
|
|
||||||
return feed.map(item => new FeedViewPostsSlice(item))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,34 +294,19 @@ export class FeedTuner {
|
|||||||
return slices
|
return slices
|
||||||
}
|
}
|
||||||
|
|
||||||
static thresholdRepliesOnly({
|
static followedRepliesOnly({userDid}: {userDid: string}) {
|
||||||
userDid,
|
|
||||||
minLikes,
|
|
||||||
followedOnly,
|
|
||||||
}: {
|
|
||||||
userDid: string
|
|
||||||
minLikes: number
|
|
||||||
followedOnly: boolean
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
tuner: FeedTuner,
|
tuner: FeedTuner,
|
||||||
slices: FeedViewPostsSlice[],
|
slices: FeedViewPostsSlice[],
|
||||||
): FeedViewPostsSlice[] => {
|
): FeedViewPostsSlice[] => {
|
||||||
// remove any replies without at least minLikes likes
|
|
||||||
for (let i = slices.length - 1; i >= 0; i--) {
|
for (let i = slices.length - 1; i >= 0; i--) {
|
||||||
const slice = slices[i]
|
const slice = slices[i]
|
||||||
if (slice.isReply) {
|
if (
|
||||||
if (slice.isThread && slice.includesThreadRoot) {
|
slice.isReply &&
|
||||||
continue
|
!slice.isRepost &&
|
||||||
}
|
!slice.isFollowingAllAuthors(userDid)
|
||||||
if (slice.isRepost) {
|
) {
|
||||||
continue
|
slices.splice(i, 1)
|
||||||
}
|
|
||||||
if (slice.likeCount < minLikes) {
|
|
||||||
slices.splice(i, 1)
|
|
||||||
} else if (followedOnly && !slice.isFollowingAllAuthors(userDid)) {
|
|
||||||
slices.splice(i, 1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return slices
|
return slices
|
||||||
|
|||||||
@@ -54,6 +54,10 @@ interface PostOpts {
|
|||||||
uri: string
|
uri: string
|
||||||
cid: string
|
cid: string
|
||||||
}
|
}
|
||||||
|
video?: {
|
||||||
|
uri: string
|
||||||
|
cid: string
|
||||||
|
}
|
||||||
extLink?: ExternalEmbedDraft
|
extLink?: ExternalEmbedDraft
|
||||||
images?: ImageModel[]
|
images?: ImageModel[]
|
||||||
labels?: string[]
|
labels?: string[]
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import React from 'react'
|
|||||||
export const useDedupe = () => {
|
export const useDedupe = () => {
|
||||||
const canDo = React.useRef(true)
|
const canDo = React.useRef(true)
|
||||||
|
|
||||||
return React.useRef((cb: () => unknown) => {
|
return React.useCallback((cb: () => unknown) => {
|
||||||
if (canDo.current) {
|
if (canDo.current) {
|
||||||
canDo.current = false
|
canDo.current = false
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -13,5 +13,5 @@ export const useDedupe = () => {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
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
|
PreferencesThreads: undefined
|
||||||
PreferencesExternalEmbeds: undefined
|
PreferencesExternalEmbeds: undefined
|
||||||
AccessibilitySettings: undefined
|
AccessibilitySettings: undefined
|
||||||
|
AppearanceSettings: undefined
|
||||||
Search: {q?: string}
|
Search: {q?: string}
|
||||||
Hashtag: {tag: string; author?: string}
|
Hashtag: {tag: string; author?: string}
|
||||||
MessagesConversation: {conversation: string; embed?: string}
|
MessagesConversation: {conversation: string; embed?: string}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export const router = new Router({
|
|||||||
PreferencesThreads: '/settings/threads',
|
PreferencesThreads: '/settings/threads',
|
||||||
PreferencesExternalEmbeds: '/settings/external-embeds',
|
PreferencesExternalEmbeds: '/settings/external-embeds',
|
||||||
AccessibilitySettings: '/settings/accessibility',
|
AccessibilitySettings: '/settings/accessibility',
|
||||||
|
AppearanceSettings: '/settings/appearance',
|
||||||
SavedFeeds: '/settings/saved-feeds',
|
SavedFeeds: '/settings/saved-feeds',
|
||||||
Support: '/support',
|
Support: '/support',
|
||||||
PrivacyPolicy: '/support/privacy',
|
PrivacyPolicy: '/support/privacy',
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import React, {useCallback} from 'react'
|
||||||
|
import {View} from 'react-native'
|
||||||
|
import Animated, {
|
||||||
|
FadeInDown,
|
||||||
|
FadeOutDown,
|
||||||
|
LayoutAnimationConfig,
|
||||||
|
} from 'react-native-reanimated'
|
||||||
|
import {msg, Trans} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||||
|
import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
|
||||||
|
import {s} from '#/lib/styles'
|
||||||
|
import {useSetThemePrefs, useThemePrefs} from '#/state/shell'
|
||||||
|
import {SimpleViewHeader} from '#/view/com/util/SimpleViewHeader'
|
||||||
|
import {ScrollView} from '#/view/com/util/Views'
|
||||||
|
import {atoms as a, native, useTheme} from '#/alf'
|
||||||
|
import * as ToggleButton from '#/components/forms/ToggleButton'
|
||||||
|
import {Moon_Stroke2_Corner0_Rounded as MoonIcon} from '#/components/icons/Moon'
|
||||||
|
import {Phone_Stroke2_Corner0_Rounded as PhoneIcon} from '#/components/icons/Phone'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
type Props = NativeStackScreenProps<CommonNavigatorParams, 'AppearanceSettings'>
|
||||||
|
export function AppearanceSettingsScreen({}: Props) {
|
||||||
|
const {_} = useLingui()
|
||||||
|
const t = useTheme()
|
||||||
|
const {isTabletOrMobile} = useWebMediaQueries()
|
||||||
|
|
||||||
|
const {colorMode, darkTheme} = useThemePrefs()
|
||||||
|
const {setColorMode, setDarkTheme} = useSetThemePrefs()
|
||||||
|
|
||||||
|
const onChangeAppearance = useCallback(
|
||||||
|
(keys: string[]) => {
|
||||||
|
const appearance = keys.find(key => key !== colorMode) as
|
||||||
|
| 'system'
|
||||||
|
| 'light'
|
||||||
|
| 'dark'
|
||||||
|
| undefined
|
||||||
|
if (!appearance) return
|
||||||
|
setColorMode(appearance)
|
||||||
|
},
|
||||||
|
[setColorMode, colorMode],
|
||||||
|
)
|
||||||
|
|
||||||
|
const onChangeDarkTheme = useCallback(
|
||||||
|
(keys: string[]) => {
|
||||||
|
const theme = keys.find(key => key !== darkTheme) as
|
||||||
|
| 'dim'
|
||||||
|
| 'dark'
|
||||||
|
| undefined
|
||||||
|
if (!theme) return
|
||||||
|
setDarkTheme(theme)
|
||||||
|
},
|
||||||
|
[setDarkTheme, darkTheme],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LayoutAnimationConfig skipExiting skipEntering>
|
||||||
|
<View testID="preferencesThreadsScreen" style={s.hContentRegion}>
|
||||||
|
<ScrollView
|
||||||
|
// @ts-ignore web only -prf
|
||||||
|
dataSet={{'stable-gutters': 1}}
|
||||||
|
contentContainerStyle={{paddingBottom: 75}}>
|
||||||
|
<SimpleViewHeader
|
||||||
|
showBackButton={isTabletOrMobile}
|
||||||
|
style={[t.atoms.border_contrast_medium, a.border_b]}>
|
||||||
|
<View style={a.flex_1}>
|
||||||
|
<Text style={[a.text_2xl, a.font_bold]}>
|
||||||
|
<Trans>Appearance</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</SimpleViewHeader>
|
||||||
|
|
||||||
|
<View style={[a.p_xl, a.gap_lg]}>
|
||||||
|
<View style={[a.flex_row, a.align_center, a.gap_md]}>
|
||||||
|
<PhoneIcon style={t.atoms.text} />
|
||||||
|
<Text style={a.text_md}>
|
||||||
|
<Trans>Mode</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<ToggleButton.Group
|
||||||
|
label={_(msg`Dark mode`)}
|
||||||
|
values={[colorMode]}
|
||||||
|
onChange={onChangeAppearance}>
|
||||||
|
<ToggleButton.Button label={_(msg`System`)} name="system">
|
||||||
|
<ToggleButton.ButtonText>
|
||||||
|
<Trans>System</Trans>
|
||||||
|
</ToggleButton.ButtonText>
|
||||||
|
</ToggleButton.Button>
|
||||||
|
<ToggleButton.Button label={_(msg`Light`)} name="light">
|
||||||
|
<ToggleButton.ButtonText>
|
||||||
|
<Trans>Light</Trans>
|
||||||
|
</ToggleButton.ButtonText>
|
||||||
|
</ToggleButton.Button>
|
||||||
|
<ToggleButton.Button label={_(msg`Dark`)} name="dark">
|
||||||
|
<ToggleButton.ButtonText>
|
||||||
|
<Trans>Dark</Trans>
|
||||||
|
</ToggleButton.ButtonText>
|
||||||
|
</ToggleButton.Button>
|
||||||
|
</ToggleButton.Group>
|
||||||
|
{colorMode !== 'light' && (
|
||||||
|
<Animated.View
|
||||||
|
entering={native(FadeInDown)}
|
||||||
|
exiting={native(FadeOutDown)}
|
||||||
|
style={[a.mt_md, a.gap_lg]}>
|
||||||
|
<View style={[a.flex_row, a.align_center, a.gap_md]}>
|
||||||
|
<MoonIcon style={t.atoms.text} />
|
||||||
|
<Text style={a.text_md}>
|
||||||
|
<Trans>Dark theme</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ToggleButton.Group
|
||||||
|
label={_(msg`Dark theme`)}
|
||||||
|
values={[darkTheme ?? 'dim']}
|
||||||
|
onChange={onChangeDarkTheme}>
|
||||||
|
<ToggleButton.Button label={_(msg`Dim`)} name="dim">
|
||||||
|
<ToggleButton.ButtonText>
|
||||||
|
<Trans>Dim</Trans>
|
||||||
|
</ToggleButton.ButtonText>
|
||||||
|
</ToggleButton.Button>
|
||||||
|
<ToggleButton.Button label={_(msg`Dark`)} name="dark">
|
||||||
|
<ToggleButton.ButtonText>
|
||||||
|
<Trans>Dark</Trans>
|
||||||
|
</ToggleButton.ButtonText>
|
||||||
|
</ToggleButton.Button>
|
||||||
|
</ToggleButton.Group>
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</LayoutAnimationConfig>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -8,8 +8,8 @@ import {useA11y} from '#/state/a11y'
|
|||||||
import {DISCOVER_FEED_URI} from 'lib/constants'
|
import {DISCOVER_FEED_URI} from 'lib/constants'
|
||||||
import {
|
import {
|
||||||
useGetPopularFeedsQuery,
|
useGetPopularFeedsQuery,
|
||||||
|
usePopularFeedsSearch,
|
||||||
useSavedFeeds,
|
useSavedFeeds,
|
||||||
useSearchPopularFeedsQuery,
|
|
||||||
} from 'state/queries/feed'
|
} from 'state/queries/feed'
|
||||||
import {SearchInput} from 'view/com/util/forms/SearchInput'
|
import {SearchInput} from 'view/com/util/forms/SearchInput'
|
||||||
import {List} from 'view/com/util/List'
|
import {List} from 'view/com/util/List'
|
||||||
@@ -59,7 +59,7 @@ export function StepFeeds({moderationOpts}: {moderationOpts: ModerationOpts}) {
|
|||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
const {data: searchedFeeds, isFetching: isFetchingSearchedFeeds} =
|
const {data: searchedFeeds, isFetching: isFetchingSearchedFeeds} =
|
||||||
useSearchPopularFeedsQuery({q: throttledQuery})
|
usePopularFeedsSearch({query: throttledQuery})
|
||||||
|
|
||||||
const isLoading =
|
const isLoading =
|
||||||
!isFetchedSavedFeeds || isLoadingPopularFeeds || isFetchingSearchedFeeds
|
!isFetchedSavedFeeds || isLoadingPopularFeeds || isFetchingSearchedFeeds
|
||||||
|
|||||||
@@ -38,11 +38,8 @@ export function useFeedTuners(feedDesc: FeedDescriptor) {
|
|||||||
feedTuners.push(FeedTuner.removeReplies)
|
feedTuners.push(FeedTuner.removeReplies)
|
||||||
} else {
|
} else {
|
||||||
feedTuners.push(
|
feedTuners.push(
|
||||||
FeedTuner.thresholdRepliesOnly({
|
FeedTuner.followedRepliesOnly({
|
||||||
userDid: currentAccount?.did || '',
|
userDid: currentAccount?.did || '',
|
||||||
minLikes: preferences?.feedViewPrefs.hideRepliesByLikeCount || 0,
|
|
||||||
followedOnly:
|
|
||||||
!!preferences?.feedViewPrefs.hideRepliesByUnfollowed,
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -66,10 +63,8 @@ export function useFeedTuners(feedDesc: FeedDescriptor) {
|
|||||||
feedTuners.push(FeedTuner.removeReplies)
|
feedTuners.push(FeedTuner.removeReplies)
|
||||||
} else {
|
} else {
|
||||||
feedTuners.push(
|
feedTuners.push(
|
||||||
FeedTuner.thresholdRepliesOnly({
|
FeedTuner.followedRepliesOnly({
|
||||||
userDid: currentAccount?.did || '',
|
userDid: currentAccount?.did || '',
|
||||||
minLikes: preferences?.feedViewPrefs.hideRepliesByLikeCount || 0,
|
|
||||||
followedOnly: !!preferences?.feedViewPrefs.hideRepliesByUnfollowed,
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-20
@@ -5,6 +5,7 @@ import {
|
|||||||
AppBskyGraphDefs,
|
AppBskyGraphDefs,
|
||||||
AppBskyUnspeccedGetPopularFeedGenerators,
|
AppBskyUnspeccedGetPopularFeedGenerators,
|
||||||
AtUri,
|
AtUri,
|
||||||
|
moderateFeedGenerator,
|
||||||
RichText,
|
RichText,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
import {
|
import {
|
||||||
@@ -26,6 +27,7 @@ import {RQKEY as listQueryKey} from '#/state/queries/list'
|
|||||||
import {usePreferencesQuery} from '#/state/queries/preferences'
|
import {usePreferencesQuery} from '#/state/queries/preferences'
|
||||||
import {useAgent, useSession} from '#/state/session'
|
import {useAgent, useSession} from '#/state/session'
|
||||||
import {router} from '#/routes'
|
import {router} from '#/routes'
|
||||||
|
import {useModerationOpts} from '../preferences/moderation-opts'
|
||||||
import {FeedDescriptor} from './post-feed'
|
import {FeedDescriptor} from './post-feed'
|
||||||
import {precacheResolvedUri} from './resolve-uri'
|
import {precacheResolvedUri} from './resolve-uri'
|
||||||
|
|
||||||
@@ -207,14 +209,16 @@ export function useGetPopularFeedsQuery(options?: GetPopularFeedsOptions) {
|
|||||||
const limit = options?.limit || 10
|
const limit = options?.limit || 10
|
||||||
const {data: preferences} = usePreferencesQuery()
|
const {data: preferences} = usePreferencesQuery()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
const moderationOpts = useModerationOpts()
|
||||||
|
|
||||||
// Make sure this doesn't invalidate unless really needed.
|
// Make sure this doesn't invalidate unless really needed.
|
||||||
const selectArgs = useMemo(
|
const selectArgs = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
hasSession,
|
hasSession,
|
||||||
savedFeeds: preferences?.savedFeeds || [],
|
savedFeeds: preferences?.savedFeeds || [],
|
||||||
|
moderationOpts,
|
||||||
}),
|
}),
|
||||||
[hasSession, preferences?.savedFeeds],
|
[hasSession, preferences?.savedFeeds, moderationOpts],
|
||||||
)
|
)
|
||||||
const lastPageCountRef = useRef(0)
|
const lastPageCountRef = useRef(0)
|
||||||
|
|
||||||
@@ -225,6 +229,7 @@ export function useGetPopularFeedsQuery(options?: GetPopularFeedsOptions) {
|
|||||||
QueryKey,
|
QueryKey,
|
||||||
string | undefined
|
string | undefined
|
||||||
>({
|
>({
|
||||||
|
enabled: Boolean(moderationOpts),
|
||||||
queryKey: createGetPopularFeedsQueryKey(options),
|
queryKey: createGetPopularFeedsQueryKey(options),
|
||||||
queryFn: async ({pageParam}) => {
|
queryFn: async ({pageParam}) => {
|
||||||
const res = await agent.app.bsky.unspecced.getPopularFeedGenerators({
|
const res = await agent.app.bsky.unspecced.getPopularFeedGenerators({
|
||||||
@@ -246,7 +251,11 @@ export function useGetPopularFeedsQuery(options?: GetPopularFeedsOptions) {
|
|||||||
(
|
(
|
||||||
data: InfiniteData<AppBskyUnspeccedGetPopularFeedGenerators.OutputSchema>,
|
data: InfiniteData<AppBskyUnspeccedGetPopularFeedGenerators.OutputSchema>,
|
||||||
) => {
|
) => {
|
||||||
const {savedFeeds, hasSession: hasSessionInner} = selectArgs
|
const {
|
||||||
|
savedFeeds,
|
||||||
|
hasSession: hasSessionInner,
|
||||||
|
moderationOpts,
|
||||||
|
} = selectArgs
|
||||||
return {
|
return {
|
||||||
...data,
|
...data,
|
||||||
pages: data.pages.map(page => {
|
pages: data.pages.map(page => {
|
||||||
@@ -264,7 +273,8 @@ export function useGetPopularFeedsQuery(options?: GetPopularFeedsOptions) {
|
|||||||
return f.value === feed.uri
|
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() {
|
export function useSearchPopularFeedsMutation() {
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
|
const moderationOpts = useModerationOpts()
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (query: string) => {
|
mutationFn: async (query: string) => {
|
||||||
const res = await agent.app.bsky.unspecced.getPopularFeedGenerators({
|
const res = await agent.app.bsky.unspecced.getPopularFeedGenerators({
|
||||||
@@ -311,24 +323,15 @@ export function useSearchPopularFeedsMutation() {
|
|||||||
query: query,
|
query: query,
|
||||||
})
|
})
|
||||||
|
|
||||||
return res.data.feeds
|
if (moderationOpts) {
|
||||||
},
|
return res.data.feeds.filter(feed => {
|
||||||
})
|
const decision = moderateFeedGenerator(feed, moderationOpts)
|
||||||
}
|
return !decision.ui('contentList').filter
|
||||||
|
})
|
||||||
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,
|
|
||||||
})
|
|
||||||
|
|
||||||
return res.data.feeds
|
return res.data.feeds
|
||||||
},
|
},
|
||||||
placeholderData: keepPreviousData,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,17 +349,27 @@ export function usePopularFeedsSearch({
|
|||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
}) {
|
}) {
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
|
const moderationOpts = useModerationOpts()
|
||||||
|
const enabledInner = enabled ?? Boolean(moderationOpts)
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
enabled,
|
enabled: enabledInner,
|
||||||
queryKey: createPopularFeedsSearchQueryKey(query),
|
queryKey: createPopularFeedsSearchQueryKey(query),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await agent.app.bsky.unspecced.getPopularFeedGenerators({
|
const res = await agent.app.bsky.unspecced.getPopularFeedGenerators({
|
||||||
limit: 10,
|
limit: 15,
|
||||||
query: query,
|
query: query,
|
||||||
})
|
})
|
||||||
|
|
||||||
return res.data.feeds
|
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 moderationOpts = useModerationOpts()
|
||||||
const unreads = useUnreadNotificationsApi()
|
const unreads = useUnreadNotificationsApi()
|
||||||
const enabled = opts?.enabled !== false
|
const enabled = opts?.enabled !== false
|
||||||
const lastPageCountRef = useRef(0)
|
|
||||||
const gate = useGate()
|
const gate = useGate()
|
||||||
|
|
||||||
// false: force showing all notifications
|
// false: force showing all notifications
|
||||||
@@ -121,28 +120,52 @@ export function useNotificationFeedQuery(opts?: {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// The server may end up returning an empty page, a page with too few items,
|
||||||
|
// or a page with items that end up getting filtered out. When we fetch pages,
|
||||||
|
// we'll keep track of how many items we actually hope to see. If the server
|
||||||
|
// doesn't return enough items, we're going to continue asking for more items.
|
||||||
|
const lastItemCount = useRef(0)
|
||||||
|
const wantedItemCount = useRef(0)
|
||||||
|
const autoPaginationAttemptCount = useRef(0)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const {isFetching, hasNextPage, data} = query
|
const {data, isLoading, isRefetching, isFetchingNextPage, hasNextPage} =
|
||||||
if (isFetching || !hasNextPage) {
|
query
|
||||||
return
|
// Count the items that we already have.
|
||||||
}
|
let itemCount = 0
|
||||||
|
|
||||||
// avoid double-fires of fetchNextPage()
|
|
||||||
if (
|
|
||||||
lastPageCountRef.current !== 0 &&
|
|
||||||
lastPageCountRef.current === data?.pages?.length
|
|
||||||
) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// fetch next page if we haven't gotten a full page of content
|
|
||||||
let count = 0
|
|
||||||
for (const page of data?.pages || []) {
|
for (const page of data?.pages || []) {
|
||||||
count += page.items.length
|
itemCount += page.items.length
|
||||||
}
|
}
|
||||||
if (count < PAGE_SIZE && (data?.pages.length || 0) < 6) {
|
|
||||||
query.fetchNextPage()
|
// If items got truncated, reset the state we're tracking below.
|
||||||
lastPageCountRef.current = data?.pages?.length || 0
|
if (itemCount !== lastItemCount.current) {
|
||||||
|
if (itemCount < lastItemCount.current) {
|
||||||
|
wantedItemCount.current = itemCount
|
||||||
|
}
|
||||||
|
lastItemCount.current = itemCount
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now track how many items we really want, and fetch more if needed.
|
||||||
|
if (isLoading || isRefetching) {
|
||||||
|
// During the initial fetch, we want to get an entire page's worth of items.
|
||||||
|
wantedItemCount.current = PAGE_SIZE
|
||||||
|
} else if (isFetchingNextPage) {
|
||||||
|
if (itemCount > wantedItemCount.current) {
|
||||||
|
// We have more items than wantedItemCount, so wantedItemCount must be out of date.
|
||||||
|
// Some other code must have called fetchNextPage(), for example, from onEndReached.
|
||||||
|
// Adjust the wantedItemCount to reflect that we want one more full page of items.
|
||||||
|
wantedItemCount.current = itemCount + PAGE_SIZE
|
||||||
|
}
|
||||||
|
} else if (hasNextPage) {
|
||||||
|
// At this point we're not fetching anymore, so it's time to make a decision.
|
||||||
|
// If we didn't receive enough items from the server, paginate again until we do.
|
||||||
|
if (itemCount < wantedItemCount.current) {
|
||||||
|
autoPaginationAttemptCount.current++
|
||||||
|
if (autoPaginationAttemptCount.current < 50 /* failsafe */) {
|
||||||
|
query.fetchNextPage()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
autoPaginationAttemptCount.current = 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [query])
|
}, [query])
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import {LikesFeedAPI} from 'lib/api/feed/likes'
|
|||||||
import {ListFeedAPI} from 'lib/api/feed/list'
|
import {ListFeedAPI} from 'lib/api/feed/list'
|
||||||
import {MergeFeedAPI} from 'lib/api/feed/merge'
|
import {MergeFeedAPI} from 'lib/api/feed/merge'
|
||||||
import {FeedAPI, ReasonFeedSource} from 'lib/api/feed/types'
|
import {FeedAPI, ReasonFeedSource} from 'lib/api/feed/types'
|
||||||
import {FeedTuner, FeedTunerFn, NoopFeedTuner} from 'lib/api/feed-manip'
|
import {FeedTuner, FeedTunerFn} from 'lib/api/feed-manip'
|
||||||
import {BSKY_FEED_OWNER_DIDS} from 'lib/constants'
|
import {BSKY_FEED_OWNER_DIDS} from 'lib/constants'
|
||||||
import {KnownError} from '#/view/com/posts/FeedErrorMessage'
|
import {KnownError} from '#/view/com/posts/FeedErrorMessage'
|
||||||
import {useFeedTuners} from '../preferences/feed-tuners'
|
import {useFeedTuners} from '../preferences/feed-tuners'
|
||||||
@@ -61,7 +61,6 @@ export type FeedDescriptor =
|
|||||||
| `list|${ListUri}`
|
| `list|${ListUri}`
|
||||||
| `list|${ListUri}|${ListFilter}`
|
| `list|${ListUri}|${ListFilter}`
|
||||||
export interface FeedParams {
|
export interface FeedParams {
|
||||||
disableTuner?: boolean
|
|
||||||
mergeFeedEnabled?: boolean
|
mergeFeedEnabled?: boolean
|
||||||
mergeFeedSources?: string[]
|
mergeFeedSources?: string[]
|
||||||
}
|
}
|
||||||
@@ -105,7 +104,7 @@ export interface FeedPageUnselected {
|
|||||||
|
|
||||||
export interface FeedPage {
|
export interface FeedPage {
|
||||||
api: FeedAPI
|
api: FeedAPI
|
||||||
tuner: FeedTuner | NoopFeedTuner
|
tuner: FeedTuner
|
||||||
cursor: string | undefined
|
cursor: string | undefined
|
||||||
slices: FeedPostSlice[]
|
slices: FeedPostSlice[]
|
||||||
fetchedAt: number
|
fetchedAt: number
|
||||||
@@ -135,25 +134,17 @@ export function usePostFeedQuery(
|
|||||||
args: typeof selectArgs
|
args: typeof selectArgs
|
||||||
result: InfiniteData<FeedPage>
|
result: InfiniteData<FeedPage>
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
const lastPageCountRef = useRef(0)
|
|
||||||
const isDiscover = feedDesc.includes(DISCOVER_FEED_URI)
|
const isDiscover = feedDesc.includes(DISCOVER_FEED_URI)
|
||||||
|
|
||||||
// Make sure this doesn't invalidate unless really needed.
|
// Make sure this doesn't invalidate unless really needed.
|
||||||
const selectArgs = React.useMemo(
|
const selectArgs = React.useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
feedTuners,
|
feedTuners,
|
||||||
disableTuner: params?.disableTuner,
|
|
||||||
moderationOpts,
|
moderationOpts,
|
||||||
ignoreFilterFor: opts?.ignoreFilterFor,
|
ignoreFilterFor: opts?.ignoreFilterFor,
|
||||||
isDiscover,
|
isDiscover,
|
||||||
}),
|
}),
|
||||||
[
|
[feedTuners, moderationOpts, opts?.ignoreFilterFor, isDiscover],
|
||||||
feedTuners,
|
|
||||||
params?.disableTuner,
|
|
||||||
moderationOpts,
|
|
||||||
opts?.ignoreFilterFor,
|
|
||||||
isDiscover,
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const query = useInfiniteQuery<
|
const query = useInfiniteQuery<
|
||||||
@@ -232,17 +223,10 @@ export function usePostFeedQuery(
|
|||||||
(data: InfiniteData<FeedPageUnselected, RQPageParam>) => {
|
(data: InfiniteData<FeedPageUnselected, RQPageParam>) => {
|
||||||
// If the selection depends on some data, that data should
|
// If the selection depends on some data, that data should
|
||||||
// be included in the selectArgs object and read here.
|
// be included in the selectArgs object and read here.
|
||||||
const {
|
const {feedTuners, moderationOpts, ignoreFilterFor, isDiscover} =
|
||||||
feedTuners,
|
selectArgs
|
||||||
disableTuner,
|
|
||||||
moderationOpts,
|
|
||||||
ignoreFilterFor,
|
|
||||||
isDiscover,
|
|
||||||
} = selectArgs
|
|
||||||
|
|
||||||
const tuner = disableTuner
|
const tuner = new FeedTuner(feedTuners)
|
||||||
? new NoopFeedTuner()
|
|
||||||
: new FeedTuner(feedTuners)
|
|
||||||
|
|
||||||
// Keep track of the last run and whether we can reuse
|
// Keep track of the last run and whether we can reuse
|
||||||
// some already selected pages from there.
|
// some already selected pages from there.
|
||||||
@@ -391,30 +375,54 @@ export function usePostFeedQuery(
|
|||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// The server may end up returning an empty page, a page with too few items,
|
||||||
|
// or a page with items that end up getting filtered out. When we fetch pages,
|
||||||
|
// we'll keep track of how many items we actually hope to see. If the server
|
||||||
|
// doesn't return enough items, we're going to continue asking for more items.
|
||||||
|
const lastItemCount = useRef(0)
|
||||||
|
const wantedItemCount = useRef(0)
|
||||||
|
const autoPaginationAttemptCount = useRef(0)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const {isFetching, hasNextPage, data} = query
|
const {data, isLoading, isRefetching, isFetchingNextPage, hasNextPage} =
|
||||||
if (isFetching || !hasNextPage) {
|
query
|
||||||
return
|
// Count the items that we already have.
|
||||||
}
|
let itemCount = 0
|
||||||
|
|
||||||
// avoid double-fires of fetchNextPage()
|
|
||||||
if (
|
|
||||||
lastPageCountRef.current !== 0 &&
|
|
||||||
lastPageCountRef.current === data?.pages?.length
|
|
||||||
) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// fetch next page if we haven't gotten a full page of content
|
|
||||||
let count = 0
|
|
||||||
for (const page of data?.pages || []) {
|
for (const page of data?.pages || []) {
|
||||||
for (const slice of page.slices) {
|
for (const slice of page.slices) {
|
||||||
count += slice.items.length
|
itemCount += slice.items.length
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (count < PAGE_SIZE && (data?.pages.length || 0) < 6) {
|
|
||||||
query.fetchNextPage()
|
// If items got truncated, reset the state we're tracking below.
|
||||||
lastPageCountRef.current = data?.pages?.length || 0
|
if (itemCount !== lastItemCount.current) {
|
||||||
|
if (itemCount < lastItemCount.current) {
|
||||||
|
wantedItemCount.current = itemCount
|
||||||
|
}
|
||||||
|
lastItemCount.current = itemCount
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now track how many items we really want, and fetch more if needed.
|
||||||
|
if (isLoading || isRefetching) {
|
||||||
|
// During the initial fetch, we want to get an entire page's worth of items.
|
||||||
|
wantedItemCount.current = PAGE_SIZE
|
||||||
|
} else if (isFetchingNextPage) {
|
||||||
|
if (itemCount > wantedItemCount.current) {
|
||||||
|
// We have more items than wantedItemCount, so wantedItemCount must be out of date.
|
||||||
|
// Some other code must have called fetchNextPage(), for example, from onEndReached.
|
||||||
|
// Adjust the wantedItemCount to reflect that we want one more full page of items.
|
||||||
|
wantedItemCount.current = itemCount + PAGE_SIZE
|
||||||
|
}
|
||||||
|
} else if (hasNextPage) {
|
||||||
|
// At this point we're not fetching anymore, so it's time to make a decision.
|
||||||
|
// If we didn't receive enough items from the server, paginate again until we do.
|
||||||
|
if (itemCount < wantedItemCount.current) {
|
||||||
|
autoPaginationAttemptCount.current++
|
||||||
|
if (autoPaginationAttemptCount.current < 50 /* failsafe */) {
|
||||||
|
query.fetchNextPage()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
autoPaginationAttemptCount.current = 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [query])
|
}, [query])
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import {
|
|||||||
export const DEFAULT_HOME_FEED_PREFS: UsePreferencesQueryResponse['feedViewPrefs'] =
|
export const DEFAULT_HOME_FEED_PREFS: UsePreferencesQueryResponse['feedViewPrefs'] =
|
||||||
{
|
{
|
||||||
hideReplies: false,
|
hideReplies: false,
|
||||||
hideRepliesByUnfollowed: true,
|
hideRepliesByUnfollowed: true, // Legacy, ignored
|
||||||
hideRepliesByLikeCount: 0,
|
hideRepliesByLikeCount: 0, // Legacy, ignored
|
||||||
hideReposts: false,
|
hideReposts: false,
|
||||||
hideQuotePosts: false,
|
hideQuotePosts: false,
|
||||||
lab_mergeFeedEnabled: false, // experimental
|
lab_mergeFeedEnabled: false, // experimental
|
||||||
|
|||||||
@@ -343,6 +343,21 @@ export function useRemoveMutedWordMutation() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useRemoveMutedWordsMutation() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const agent = useAgent()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (mutedWords: AppBskyActorDefs.MutedWord[]) => {
|
||||||
|
await agent.removeMutedWords(mutedWords)
|
||||||
|
// triggers a refetch
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: preferencesQueryKey,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function useQueueNudgesMutation() {
|
export function useQueueNudgesMutation() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import {AppBskyFeedGetActorFeeds} from '@atproto/api'
|
import {AppBskyFeedGetActorFeeds, moderateFeedGenerator} from '@atproto/api'
|
||||||
import {InfiniteData, QueryKey, useInfiniteQuery} from '@tanstack/react-query'
|
import {InfiniteData, QueryKey, useInfiniteQuery} from '@tanstack/react-query'
|
||||||
|
|
||||||
import {useAgent} from '#/state/session'
|
import {useAgent} from '#/state/session'
|
||||||
|
import {useModerationOpts} from '../preferences/moderation-opts'
|
||||||
|
|
||||||
const PAGE_SIZE = 50
|
const PAGE_SIZE = 50
|
||||||
type RQPageParam = string | undefined
|
type RQPageParam = string | undefined
|
||||||
@@ -14,7 +15,8 @@ export function useProfileFeedgensQuery(
|
|||||||
did: string,
|
did: string,
|
||||||
opts?: {enabled?: boolean},
|
opts?: {enabled?: boolean},
|
||||||
) {
|
) {
|
||||||
const enabled = opts?.enabled !== false
|
const moderationOpts = useModerationOpts()
|
||||||
|
const enabled = opts?.enabled !== false && Boolean(moderationOpts)
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
return useInfiniteQuery<
|
return useInfiniteQuery<
|
||||||
AppBskyFeedGetActorFeeds.OutputSchema,
|
AppBskyFeedGetActorFeeds.OutputSchema,
|
||||||
@@ -38,5 +40,21 @@ export function useProfileFeedgensQuery(
|
|||||||
initialPageParam: undefined,
|
initialPageParam: undefined,
|
||||||
getNextPageParam: lastPage => lastPage.cursor,
|
getNextPageParam: lastPage => lastPage.cursor,
|
||||||
enabled,
|
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 {InfiniteData, QueryKey, useInfiniteQuery} from '@tanstack/react-query'
|
||||||
|
|
||||||
import {useAgent} from '#/state/session'
|
import {useAgent} from '#/state/session'
|
||||||
|
import {useModerationOpts} from '../preferences/moderation-opts'
|
||||||
|
|
||||||
const PAGE_SIZE = 30
|
const PAGE_SIZE = 30
|
||||||
type RQPageParam = string | undefined
|
type RQPageParam = string | undefined
|
||||||
@@ -10,7 +11,8 @@ const RQKEY_ROOT = 'profile-lists'
|
|||||||
export const RQKEY = (did: string) => [RQKEY_ROOT, did]
|
export const RQKEY = (did: string) => [RQKEY_ROOT, did]
|
||||||
|
|
||||||
export function useProfileListsQuery(did: string, opts?: {enabled?: boolean}) {
|
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()
|
const agent = useAgent()
|
||||||
return useInfiniteQuery<
|
return useInfiniteQuery<
|
||||||
AppBskyGraphGetLists.OutputSchema,
|
AppBskyGraphGetLists.OutputSchema,
|
||||||
@@ -27,17 +29,32 @@ export function useProfileListsQuery(did: string, opts?: {enabled?: boolean}) {
|
|||||||
cursor: pageParam,
|
cursor: pageParam,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Starter packs use a reference list, which we do not want to show on profiles. At some point we could probably
|
return res.data
|
||||||
// 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',
|
|
||||||
),
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
initialPageParam: undefined,
|
initialPageParam: undefined,
|
||||||
getNextPageParam: lastPage => lastPage.cursor,
|
getNextPageParam: lastPage => lastPage.cursor,
|
||||||
enabled,
|
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,
|
Keyboard,
|
||||||
KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
LayoutChangeEvent,
|
LayoutChangeEvent,
|
||||||
|
StyleProp,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
View,
|
View,
|
||||||
|
ViewStyle,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
|
// @ts-expect-error no type definition
|
||||||
|
import ProgressCircle from 'react-native-progress/Circle'
|
||||||
import Animated, {
|
import Animated, {
|
||||||
|
FadeIn,
|
||||||
|
FadeOut,
|
||||||
interpolateColor,
|
interpolateColor,
|
||||||
useAnimatedStyle,
|
useAnimatedStyle,
|
||||||
useSharedValue,
|
useSharedValue,
|
||||||
@@ -55,6 +61,7 @@ import {
|
|||||||
import {useProfileQuery} from '#/state/queries/profile'
|
import {useProfileQuery} from '#/state/queries/profile'
|
||||||
import {Gif} from '#/state/queries/tenor'
|
import {Gif} from '#/state/queries/tenor'
|
||||||
import {ThreadgateSetting} from '#/state/queries/threadgate'
|
import {ThreadgateSetting} from '#/state/queries/threadgate'
|
||||||
|
import {useUploadVideo} from '#/state/queries/video/video'
|
||||||
import {useAgent, useSession} from '#/state/session'
|
import {useAgent, useSession} from '#/state/session'
|
||||||
import {useComposerControls} from '#/state/shell/composer'
|
import {useComposerControls} from '#/state/shell/composer'
|
||||||
import {useAnalytics} from 'lib/analytics/analytics'
|
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 {isAndroid, isIOS, isNative, isWeb} from 'platform/detection'
|
||||||
import {useDialogStateControlContext} from 'state/dialogs'
|
import {useDialogStateControlContext} from 'state/dialogs'
|
||||||
import {GalleryModel} from 'state/models/media/gallery'
|
import {GalleryModel} from 'state/models/media/gallery'
|
||||||
|
import {State as VideoUploadState} from 'state/queries/video/video'
|
||||||
import {ComposerOpts} from 'state/shell/composer'
|
import {ComposerOpts} from 'state/shell/composer'
|
||||||
import {ComposerReplyTo} from 'view/com/composer/ComposerReplyTo'
|
import {ComposerReplyTo} from 'view/com/composer/ComposerReplyTo'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
@@ -96,7 +104,6 @@ import {TextInput, TextInputRef} from './text-input/TextInput'
|
|||||||
import {ThreadgateBtn} from './threadgate/ThreadgateBtn'
|
import {ThreadgateBtn} from './threadgate/ThreadgateBtn'
|
||||||
import {useExternalLinkFetch} from './useExternalLinkFetch'
|
import {useExternalLinkFetch} from './useExternalLinkFetch'
|
||||||
import {SelectVideoBtn} from './videos/SelectVideoBtn'
|
import {SelectVideoBtn} from './videos/SelectVideoBtn'
|
||||||
import {useVideoState} from './videos/state'
|
|
||||||
import {VideoPreview} from './videos/VideoPreview'
|
import {VideoPreview} from './videos/VideoPreview'
|
||||||
import {VideoTranscodeProgress} from './videos/VideoTranscodeProgress'
|
import {VideoTranscodeProgress} from './videos/VideoTranscodeProgress'
|
||||||
|
|
||||||
@@ -159,14 +166,21 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
const [quote, setQuote] = useState<ComposerOpts['quote'] | undefined>(
|
const [quote, setQuote] = useState<ComposerOpts['quote'] | undefined>(
|
||||||
initQuote,
|
initQuote,
|
||||||
)
|
)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
video,
|
selectVideo,
|
||||||
onSelectVideo,
|
|
||||||
videoPending,
|
|
||||||
videoProcessingData,
|
|
||||||
clearVideo,
|
clearVideo,
|
||||||
videoProcessingProgress,
|
state: videoUploadState,
|
||||||
} = useVideoState({setError})
|
} = useUploadVideo({
|
||||||
|
setStatus: (status: string) => setProcessingState(status),
|
||||||
|
onSuccess: () => {
|
||||||
|
if (publishOnUpload) {
|
||||||
|
onPressPublish(true)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const [publishOnUpload, setPublishOnUpload] = useState(false)
|
||||||
|
|
||||||
const {extLink, setExtLink} = useExternalLinkFetch({setQuote})
|
const {extLink, setExtLink} = useExternalLinkFetch({setQuote})
|
||||||
const [extGif, setExtGif] = useState<Gif>()
|
const [extGif, setExtGif] = useState<Gif>()
|
||||||
const [labels, setLabels] = useState<string[]>([])
|
const [labels, setLabels] = useState<string[]>([])
|
||||||
@@ -274,7 +288,7 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
return false
|
return false
|
||||||
}, [gallery.needsAltText, extLink, extGif, requireAltTextEnabled])
|
}, [gallery.needsAltText, extLink, extGif, requireAltTextEnabled])
|
||||||
|
|
||||||
const onPressPublish = async () => {
|
const onPressPublish = async (finishedUploading?: boolean) => {
|
||||||
if (isProcessing || graphemeLength > MAX_GRAPHEME_LENGTH) {
|
if (isProcessing || graphemeLength > MAX_GRAPHEME_LENGTH) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -283,6 +297,15 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!finishedUploading &&
|
||||||
|
videoUploadState.status !== 'idle' &&
|
||||||
|
videoUploadState.asset
|
||||||
|
) {
|
||||||
|
setPublishOnUpload(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setError('')
|
setError('')
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -387,8 +410,12 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
: _(msg`What's up?`)
|
: _(msg`What's up?`)
|
||||||
|
|
||||||
const canSelectImages =
|
const canSelectImages =
|
||||||
gallery.size < 4 && !extLink && !video && !videoPending
|
gallery.size < 4 &&
|
||||||
const hasMedia = gallery.size > 0 || Boolean(extLink) || Boolean(video)
|
!extLink &&
|
||||||
|
videoUploadState.status === 'idle' &&
|
||||||
|
!videoUploadState.video
|
||||||
|
const hasMedia =
|
||||||
|
gallery.size > 0 || Boolean(extLink) || Boolean(videoUploadState.video)
|
||||||
|
|
||||||
const onEmojiButtonPress = useCallback(() => {
|
const onEmojiButtonPress = useCallback(() => {
|
||||||
openPicker?.(textInput.current?.getCursorPosition())
|
openPicker?.(textInput.current?.getCursorPosition())
|
||||||
@@ -500,7 +527,10 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
shape="default"
|
shape="default"
|
||||||
size="small"
|
size="small"
|
||||||
style={[a.rounded_full, a.py_sm]}
|
style={[a.rounded_full, a.py_sm]}
|
||||||
onPress={onPressPublish}>
|
onPress={() => onPressPublish()}
|
||||||
|
disabled={
|
||||||
|
videoUploadState.status !== 'idle' && publishOnUpload
|
||||||
|
}>
|
||||||
<ButtonText style={[a.text_md]}>
|
<ButtonText style={[a.text_md]}>
|
||||||
{replyTo ? (
|
{replyTo ? (
|
||||||
<Trans context="action">Reply</Trans>
|
<Trans context="action">Reply</Trans>
|
||||||
@@ -572,7 +602,7 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
autoFocus
|
autoFocus
|
||||||
setRichText={setRichText}
|
setRichText={setRichText}
|
||||||
onPhotoPasted={onPhotoPasted}
|
onPhotoPasted={onPhotoPasted}
|
||||||
onPressPublish={onPressPublish}
|
onPressPublish={() => onPressPublish()}
|
||||||
onNewLink={onNewLink}
|
onNewLink={onNewLink}
|
||||||
onError={setError}
|
onError={setError}
|
||||||
accessible={true}
|
accessible={true}
|
||||||
@@ -602,29 +632,33 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{quote ? (
|
<View style={[a.mt_md]}>
|
||||||
<View style={[s.mt5, s.mb2, isWeb && s.mb10]}>
|
{quote ? (
|
||||||
<View style={{pointerEvents: 'none'}}>
|
<View style={[s.mt5, s.mb2, isWeb && s.mb10]}>
|
||||||
<QuoteEmbed quote={quote} />
|
<View style={{pointerEvents: 'none'}}>
|
||||||
|
<QuoteEmbed quote={quote} />
|
||||||
|
</View>
|
||||||
|
{quote.uri !== initQuote?.uri && (
|
||||||
|
<QuoteX onRemove={() => setQuote(undefined)} />
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
{quote.uri !== initQuote?.uri && (
|
) : null}
|
||||||
<QuoteX onRemove={() => setQuote(undefined)} />
|
{videoUploadState.status === 'compressing' &&
|
||||||
)}
|
videoUploadState.asset ? (
|
||||||
</View>
|
<VideoTranscodeProgress
|
||||||
) : null}
|
asset={videoUploadState.asset}
|
||||||
{videoPending && videoProcessingData ? (
|
progress={videoUploadState.progress}
|
||||||
<VideoTranscodeProgress
|
/>
|
||||||
input={videoProcessingData}
|
) : videoUploadState.video ? (
|
||||||
progress={videoProcessingProgress}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
video && (
|
|
||||||
// remove suspense when we get rid of lazy
|
// remove suspense when we get rid of lazy
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<VideoPreview video={video} clear={clearVideo} />
|
<VideoPreview
|
||||||
|
video={videoUploadState.video}
|
||||||
|
clear={clearVideo}
|
||||||
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
)
|
) : null}
|
||||||
)}
|
</View>
|
||||||
</Animated.ScrollView>
|
</Animated.ScrollView>
|
||||||
<SuggestedLanguage text={richtext.text} />
|
<SuggestedLanguage text={richtext.text} />
|
||||||
|
|
||||||
@@ -641,33 +675,37 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
t.atoms.border_contrast_medium,
|
t.atoms.border_contrast_medium,
|
||||||
styles.bottomBar,
|
styles.bottomBar,
|
||||||
]}>
|
]}>
|
||||||
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
|
{videoUploadState.status !== 'idle' ? (
|
||||||
<SelectPhotoBtn gallery={gallery} disabled={!canSelectImages} />
|
<VideoUploadToolbar state={videoUploadState} />
|
||||||
{gate('videos') && (
|
) : (
|
||||||
<SelectVideoBtn
|
<ToolbarWrapper style={[a.flex_row, a.align_center, a.gap_xs]}>
|
||||||
onSelectVideo={onSelectVideo}
|
<SelectPhotoBtn gallery={gallery} disabled={!canSelectImages} />
|
||||||
disabled={!canSelectImages}
|
{gate('videos') && (
|
||||||
|
<SelectVideoBtn
|
||||||
|
onSelectVideo={selectVideo}
|
||||||
|
disabled={!canSelectImages}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<OpenCameraBtn gallery={gallery} disabled={!canSelectImages} />
|
||||||
|
<SelectGifBtn
|
||||||
|
onClose={focusTextInput}
|
||||||
|
onSelectGif={onSelectGif}
|
||||||
|
disabled={hasMedia}
|
||||||
/>
|
/>
|
||||||
)}
|
{!isMobile ? (
|
||||||
<OpenCameraBtn gallery={gallery} disabled={!canSelectImages} />
|
<Button
|
||||||
<SelectGifBtn
|
onPress={onEmojiButtonPress}
|
||||||
onClose={focusTextInput}
|
style={a.p_sm}
|
||||||
onSelectGif={onSelectGif}
|
label={_(msg`Open emoji picker`)}
|
||||||
disabled={hasMedia}
|
accessibilityHint={_(msg`Open emoji picker`)}
|
||||||
/>
|
variant="ghost"
|
||||||
{!isMobile ? (
|
shape="round"
|
||||||
<Button
|
color="primary">
|
||||||
onPress={onEmojiButtonPress}
|
<EmojiSmile size="lg" />
|
||||||
style={a.p_sm}
|
</Button>
|
||||||
label={_(msg`Open emoji picker`)}
|
) : null}
|
||||||
accessibilityHint={_(msg`Open emoji picker`)}
|
</ToolbarWrapper>
|
||||||
variant="ghost"
|
)}
|
||||||
shape="round"
|
|
||||||
color="primary">
|
|
||||||
<EmojiSmile size="lg" />
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
<View style={a.flex_1} />
|
<View style={a.flex_1} />
|
||||||
<SelectLangBtn />
|
<SelectLangBtn />
|
||||||
<CharProgress count={graphemeLength} />
|
<CharProgress count={graphemeLength} />
|
||||||
@@ -893,3 +931,44 @@ const styles = StyleSheet.create({
|
|||||||
borderTopWidth: StyleSheet.hairlineWidth,
|
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 => {
|
const player = useVideoPlayer(video.uri, player => {
|
||||||
player.loop = true
|
player.loop = true
|
||||||
player.play()
|
player.play()
|
||||||
|
player.volume = 0
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -9,15 +9,15 @@ import {Text} from '#/components/Typography'
|
|||||||
import {VideoTranscodeBackdrop} from './VideoTranscodeBackdrop'
|
import {VideoTranscodeBackdrop} from './VideoTranscodeBackdrop'
|
||||||
|
|
||||||
export function VideoTranscodeProgress({
|
export function VideoTranscodeProgress({
|
||||||
input,
|
asset,
|
||||||
progress,
|
progress,
|
||||||
}: {
|
}: {
|
||||||
input: ImagePickerAsset
|
asset: ImagePickerAsset
|
||||||
progress: number
|
progress: number
|
||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
|
|
||||||
const aspectRatio = input.width / input.height
|
const aspectRatio = asset.width / asset.height
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
@@ -29,7 +29,7 @@ export function VideoTranscodeProgress({
|
|||||||
a.overflow_hidden,
|
a.overflow_hidden,
|
||||||
{aspectRatio: isNaN(aspectRatio) ? 16 / 9 : aspectRatio},
|
{aspectRatio: isNaN(aspectRatio) ? 16 / 9 : aspectRatio},
|
||||||
]}>
|
]}>
|
||||||
<VideoTranscodeBackdrop uri={input.uri} />
|
<VideoTranscodeBackdrop uri={asset.uri} />
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
a.flex_1,
|
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
|
// rendering
|
||||||
// =
|
// =
|
||||||
|
|
||||||
const renderItem = ({item, index}: ListRenderItemInfo<any>) => {
|
const renderItem = React.useCallback(
|
||||||
if (item === EMPTY) {
|
({item, index}: ListRenderItemInfo<any>) => {
|
||||||
return (
|
if (item === EMPTY) {
|
||||||
<EmptyState
|
return (
|
||||||
icon="hashtag"
|
<EmptyState
|
||||||
message={_(msg`You have no feeds.`)}
|
icon="hashtag"
|
||||||
testID="listsEmpty"
|
message={_(msg`You have no feeds.`)}
|
||||||
/>
|
testID="listsEmpty"
|
||||||
)
|
/>
|
||||||
} else if (item === ERROR_ITEM) {
|
)
|
||||||
return (
|
} else if (item === ERROR_ITEM) {
|
||||||
<ErrorMessage message={cleanError(error)} onPressTryAgain={refetch} />
|
return (
|
||||||
)
|
<ErrorMessage message={cleanError(error)} onPressTryAgain={refetch} />
|
||||||
} else if (item === LOAD_MORE_ERROR_ITEM) {
|
)
|
||||||
return (
|
} else if (item === LOAD_MORE_ERROR_ITEM) {
|
||||||
<LoadMoreRetryBtn
|
return (
|
||||||
label={_(
|
<LoadMoreRetryBtn
|
||||||
msg`There was an issue fetching your lists. Tap here to try again.`,
|
label={_(
|
||||||
)}
|
msg`There was an issue fetching your lists. Tap here to try again.`,
|
||||||
onPress={onPressRetryLoadMore}
|
)}
|
||||||
/>
|
onPress={onPressRetryLoadMore}
|
||||||
)
|
/>
|
||||||
} else if (item === LOADING) {
|
)
|
||||||
return <FeedLoadingPlaceholder />
|
} else if (item === LOADING) {
|
||||||
}
|
return <FeedLoadingPlaceholder />
|
||||||
if (preferences) {
|
}
|
||||||
return (
|
if (preferences) {
|
||||||
<View
|
return (
|
||||||
style={[
|
<View
|
||||||
(index !== 0 || isWeb) && a.border_t,
|
style={[
|
||||||
t.atoms.border_contrast_low,
|
(index !== 0 || isWeb) && a.border_t,
|
||||||
a.px_lg,
|
t.atoms.border_contrast_low,
|
||||||
a.py_lg,
|
a.px_lg,
|
||||||
]}>
|
a.py_lg,
|
||||||
<FeedCard.Default view={item} />
|
]}>
|
||||||
</View>
|
<FeedCard.Default view={item} />
|
||||||
)
|
</View>
|
||||||
}
|
)
|
||||||
return null
|
}
|
||||||
}
|
return null
|
||||||
|
},
|
||||||
|
[_, t, error, refetch, onPressRetryLoadMore, preferences],
|
||||||
|
)
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (enabled && scrollElRef.current) {
|
if (enabled && scrollElRef.current) {
|
||||||
|
|||||||
@@ -75,12 +75,7 @@ export const ProfileLists = React.forwardRef<SectionRef, ProfileListsProps>(
|
|||||||
items = items.concat([EMPTY])
|
items = items.concat([EMPTY])
|
||||||
} else if (data?.pages) {
|
} else if (data?.pages) {
|
||||||
for (const page of data?.pages) {
|
for (const page of data?.pages) {
|
||||||
items = items.concat(
|
items = items.concat(page.lists)
|
||||||
page.lists.map(l => ({
|
|
||||||
...l,
|
|
||||||
_reactKey: l.uri,
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (isError && !isEmpty) {
|
if (isError && !isEmpty) {
|
||||||
@@ -192,7 +187,7 @@ export const ProfileLists = React.forwardRef<SectionRef, ProfileListsProps>(
|
|||||||
testID={testID ? `${testID}-flatlist` : undefined}
|
testID={testID ? `${testID}-flatlist` : undefined}
|
||||||
ref={scrollElRef}
|
ref={scrollElRef}
|
||||||
data={items}
|
data={items}
|
||||||
keyExtractor={(item: any) => item._reactKey}
|
keyExtractor={(item: any) => item._reactKey || item.uri}
|
||||||
renderItem={renderItemInner}
|
renderItem={renderItemInner}
|
||||||
refreshing={isPTRing}
|
refreshing={isPTRing}
|
||||||
onRefresh={onRefresh}
|
onRefresh={onRefresh}
|
||||||
|
|||||||
@@ -13,11 +13,13 @@ import {
|
|||||||
AppBskyEmbedRecordWithMedia,
|
AppBskyEmbedRecordWithMedia,
|
||||||
AppBskyFeedDefs,
|
AppBskyFeedDefs,
|
||||||
AppBskyFeedPost,
|
AppBskyFeedPost,
|
||||||
|
AppBskyGraphFollow,
|
||||||
moderateProfile,
|
moderateProfile,
|
||||||
ModerationDecision,
|
ModerationDecision,
|
||||||
ModerationOpts,
|
ModerationOpts,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
import {AtUri} from '@atproto/api'
|
import {AtUri} from '@atproto/api'
|
||||||
|
import {TID} from '@atproto/common-web'
|
||||||
import {msg, plural, Trans} from '@lingui/macro'
|
import {msg, plural, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {useNavigation} from '@react-navigation/native'
|
import {useNavigation} from '@react-navigation/native'
|
||||||
@@ -184,10 +186,28 @@ let FeedItem = ({
|
|||||||
action = _(msg`reposted your post`)
|
action = _(msg`reposted your post`)
|
||||||
icon = <RepostIcon size="xl" style={{color: t.palette.positive_600}} />
|
icon = <RepostIcon size="xl" style={{color: t.palette.positive_600}} />
|
||||||
} else if (item.type === 'follow') {
|
} else if (item.type === 'follow') {
|
||||||
|
let isFollowBack = false
|
||||||
|
|
||||||
if (
|
if (
|
||||||
item.notification.author.viewer?.following &&
|
item.notification.author.viewer?.following &&
|
||||||
gate('ungroup_follow_backs')
|
AppBskyGraphFollow.isRecord(item.notification.record)
|
||||||
) {
|
) {
|
||||||
|
let followingTimestamp
|
||||||
|
try {
|
||||||
|
const rkey = new AtUri(item.notification.author.viewer.following).rkey
|
||||||
|
followingTimestamp = TID.fromStr(rkey).timestamp()
|
||||||
|
} catch (e) {
|
||||||
|
// For some reason the following URI was invalid. Default to it not being a follow back.
|
||||||
|
console.error('Invalid following URI')
|
||||||
|
}
|
||||||
|
if (followingTimestamp) {
|
||||||
|
const followedTimestamp =
|
||||||
|
new Date(item.notification.record.createdAt).getTime() * 1000
|
||||||
|
isFollowBack = followedTimestamp > followingTimestamp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isFollowBack && gate('ungroup_follow_backs')) {
|
||||||
action = _(msg`followed you back`)
|
action = _(msg`followed you back`)
|
||||||
} else {
|
} else {
|
||||||
action = _(msg`followed you`)
|
action = _(msg`followed you`)
|
||||||
|
|||||||
@@ -1,38 +1,57 @@
|
|||||||
import React, {useCallback, useMemo, useState} from 'react'
|
import React, {useCallback, useMemo, useState} from 'react'
|
||||||
import {ActivityIndicator, StyleSheet, View} from 'react-native'
|
|
||||||
import {AppBskyFeedGetLikes as GetLikes} from '@atproto/api'
|
import {AppBskyFeedGetLikes as GetLikes} from '@atproto/api'
|
||||||
import {CenteredView} from '../util/Views'
|
import {msg} from '@lingui/macro'
|
||||||
import {List} from '../util/List'
|
import {useLingui} from '@lingui/react'
|
||||||
import {ErrorMessage} from '../util/error/ErrorMessage'
|
|
||||||
import {ProfileCardWithFollowBtn} from '../profile/ProfileCard'
|
|
||||||
import {logger} from '#/logger'
|
|
||||||
import {LoadingScreen} from '../util/LoadingScreen'
|
|
||||||
import {useResolveUriQuery} from '#/state/queries/resolve-uri'
|
|
||||||
import {useLikedByQuery} from '#/state/queries/post-liked-by'
|
|
||||||
import {cleanError} from '#/lib/strings/errors'
|
import {cleanError} from '#/lib/strings/errors'
|
||||||
|
import {logger} from '#/logger'
|
||||||
|
import {useLikedByQuery} from '#/state/queries/post-liked-by'
|
||||||
|
import {useResolveUriQuery} from '#/state/queries/resolve-uri'
|
||||||
|
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
|
||||||
|
import {
|
||||||
|
ListFooter,
|
||||||
|
ListHeaderDesktop,
|
||||||
|
ListMaybePlaceholder,
|
||||||
|
} from '#/components/Lists'
|
||||||
|
import {ProfileCardWithFollowBtn} from '../profile/ProfileCard'
|
||||||
|
import {List} from '../util/List'
|
||||||
|
|
||||||
|
function renderItem({item}: {item: GetLikes.Like}) {
|
||||||
|
return <ProfileCardWithFollowBtn key={item.actor.did} profile={item.actor} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyExtractor(item: GetLikes.Like) {
|
||||||
|
return item.actor.did
|
||||||
|
}
|
||||||
|
|
||||||
export function PostLikedBy({uri}: {uri: string}) {
|
export function PostLikedBy({uri}: {uri: string}) {
|
||||||
|
const {_} = useLingui()
|
||||||
|
const initialNumToRender = useInitialNumToRender()
|
||||||
|
|
||||||
const [isPTRing, setIsPTRing] = useState(false)
|
const [isPTRing, setIsPTRing] = useState(false)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: resolvedUri,
|
data: resolvedUri,
|
||||||
error: resolveError,
|
error: resolveError,
|
||||||
isFetching: isFetchingResolvedUri,
|
isLoading: isLoadingUri,
|
||||||
} = useResolveUriQuery(uri)
|
} = useResolveUriQuery(uri)
|
||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
isFetching,
|
isLoading: isLoadingLikes,
|
||||||
isFetched,
|
|
||||||
isFetchingNextPage,
|
isFetchingNextPage,
|
||||||
hasNextPage,
|
hasNextPage,
|
||||||
fetchNextPage,
|
fetchNextPage,
|
||||||
isError,
|
|
||||||
error,
|
error,
|
||||||
refetch,
|
refetch,
|
||||||
} = useLikedByQuery(resolvedUri?.uri)
|
} = useLikedByQuery(resolvedUri?.uri)
|
||||||
|
|
||||||
|
const isError = Boolean(resolveError || error)
|
||||||
|
|
||||||
const likes = useMemo(() => {
|
const likes = useMemo(() => {
|
||||||
if (data?.pages) {
|
if (data?.pages) {
|
||||||
return data.pages.flatMap(page => page.likes)
|
return data.pages.flatMap(page => page.likes)
|
||||||
}
|
}
|
||||||
|
return []
|
||||||
}, [data])
|
}, [data])
|
||||||
|
|
||||||
const onRefresh = useCallback(async () => {
|
const onRefresh = useCallback(async () => {
|
||||||
@@ -46,64 +65,44 @@ export function PostLikedBy({uri}: {uri: string}) {
|
|||||||
}, [refetch, setIsPTRing])
|
}, [refetch, setIsPTRing])
|
||||||
|
|
||||||
const onEndReached = useCallback(async () => {
|
const onEndReached = useCallback(async () => {
|
||||||
if (isFetching || !hasNextPage || isError) return
|
if (isFetchingNextPage || !hasNextPage || isError) return
|
||||||
try {
|
try {
|
||||||
await fetchNextPage()
|
await fetchNextPage()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Failed to load more likes', {message: err})
|
logger.error('Failed to load more likes', {message: err})
|
||||||
}
|
}
|
||||||
}, [isFetching, hasNextPage, isError, fetchNextPage])
|
}, [isFetchingNextPage, hasNextPage, isError, fetchNextPage])
|
||||||
|
|
||||||
const renderItem = useCallback(({item}: {item: GetLikes.Like}) => {
|
if (likes.length < 1) {
|
||||||
return (
|
return (
|
||||||
<ProfileCardWithFollowBtn key={item.actor.did} profile={item.actor} />
|
<ListMaybePlaceholder
|
||||||
)
|
isLoading={isLoadingUri || isLoadingLikes}
|
||||||
}, [])
|
isError={isError}
|
||||||
|
/>
|
||||||
if (isFetchingResolvedUri || !isFetched) {
|
|
||||||
return <LoadingScreen />
|
|
||||||
}
|
|
||||||
|
|
||||||
// error
|
|
||||||
// =
|
|
||||||
if (resolveError || isError) {
|
|
||||||
return (
|
|
||||||
<CenteredView>
|
|
||||||
<ErrorMessage
|
|
||||||
message={cleanError(resolveError || error)}
|
|
||||||
onPressTryAgain={onRefresh}
|
|
||||||
/>
|
|
||||||
</CenteredView>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// loaded
|
|
||||||
// =
|
|
||||||
return (
|
return (
|
||||||
<List
|
<List
|
||||||
data={likes}
|
data={likes}
|
||||||
keyExtractor={item => item.actor.did}
|
renderItem={renderItem}
|
||||||
|
keyExtractor={keyExtractor}
|
||||||
refreshing={isPTRing}
|
refreshing={isPTRing}
|
||||||
onRefresh={onRefresh}
|
onRefresh={onRefresh}
|
||||||
onEndReached={onEndReached}
|
onEndReached={onEndReached}
|
||||||
renderItem={renderItem}
|
onEndReachedThreshold={4}
|
||||||
initialNumToRender={15}
|
ListHeaderComponent={<ListHeaderDesktop title={_(msg`Liked By`)} />}
|
||||||
// FIXME(dan)
|
ListFooterComponent={
|
||||||
// eslint-disable-next-line react/no-unstable-nested-components
|
<ListFooter
|
||||||
ListFooterComponent={() => (
|
isFetchingNextPage={isFetchingNextPage}
|
||||||
<View style={styles.footer}>
|
error={cleanError(error)}
|
||||||
{(isFetching || isFetchingNextPage) && <ActivityIndicator />}
|
onRetry={fetchNextPage}
|
||||||
</View>
|
/>
|
||||||
)}
|
}
|
||||||
// @ts-ignore our .web version only -prf
|
// @ts-ignore our .web version only -prf
|
||||||
desktopFixedHeight
|
desktopFixedHeight
|
||||||
|
initialNumToRender={initialNumToRender}
|
||||||
|
windowSize={11}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
footer: {
|
|
||||||
height: 200,
|
|
||||||
paddingTop: 20,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -1,38 +1,57 @@
|
|||||||
import React, {useMemo, useCallback, useState} from 'react'
|
import React, {useCallback, useMemo, useState} from 'react'
|
||||||
import {ActivityIndicator, StyleSheet, View} from 'react-native'
|
|
||||||
import {AppBskyActorDefs as ActorDefs} from '@atproto/api'
|
import {AppBskyActorDefs as ActorDefs} from '@atproto/api'
|
||||||
import {CenteredView} from '../util/Views'
|
import {msg} from '@lingui/macro'
|
||||||
import {List} from '../util/List'
|
import {useLingui} from '@lingui/react'
|
||||||
import {ProfileCardWithFollowBtn} from '../profile/ProfileCard'
|
|
||||||
import {ErrorMessage} from '../util/error/ErrorMessage'
|
|
||||||
import {logger} from '#/logger'
|
|
||||||
import {LoadingScreen} from '../util/LoadingScreen'
|
|
||||||
import {useResolveUriQuery} from '#/state/queries/resolve-uri'
|
|
||||||
import {usePostRepostedByQuery} from '#/state/queries/post-reposted-by'
|
|
||||||
import {cleanError} from '#/lib/strings/errors'
|
import {cleanError} from '#/lib/strings/errors'
|
||||||
|
import {logger} from '#/logger'
|
||||||
|
import {usePostRepostedByQuery} from '#/state/queries/post-reposted-by'
|
||||||
|
import {useResolveUriQuery} from '#/state/queries/resolve-uri'
|
||||||
|
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
|
||||||
|
import {
|
||||||
|
ListFooter,
|
||||||
|
ListHeaderDesktop,
|
||||||
|
ListMaybePlaceholder,
|
||||||
|
} from '#/components/Lists'
|
||||||
|
import {ProfileCardWithFollowBtn} from '../profile/ProfileCard'
|
||||||
|
import {List} from '../util/List'
|
||||||
|
|
||||||
|
function renderItem({item}: {item: ActorDefs.ProfileViewBasic}) {
|
||||||
|
return <ProfileCardWithFollowBtn key={item.did} profile={item} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyExtractor(item: ActorDefs.ProfileViewBasic) {
|
||||||
|
return item.did
|
||||||
|
}
|
||||||
|
|
||||||
export function PostRepostedBy({uri}: {uri: string}) {
|
export function PostRepostedBy({uri}: {uri: string}) {
|
||||||
|
const {_} = useLingui()
|
||||||
|
const initialNumToRender = useInitialNumToRender()
|
||||||
|
|
||||||
const [isPTRing, setIsPTRing] = useState(false)
|
const [isPTRing, setIsPTRing] = useState(false)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: resolvedUri,
|
data: resolvedUri,
|
||||||
error: resolveError,
|
error: resolveError,
|
||||||
isFetching: isFetchingResolvedUri,
|
isLoading: isLoadingUri,
|
||||||
} = useResolveUriQuery(uri)
|
} = useResolveUriQuery(uri)
|
||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
isFetching,
|
isLoading: isLoadingRepostedBy,
|
||||||
isFetched,
|
|
||||||
isFetchingNextPage,
|
isFetchingNextPage,
|
||||||
hasNextPage,
|
hasNextPage,
|
||||||
fetchNextPage,
|
fetchNextPage,
|
||||||
isError,
|
|
||||||
error,
|
error,
|
||||||
refetch,
|
refetch,
|
||||||
} = usePostRepostedByQuery(resolvedUri?.uri)
|
} = usePostRepostedByQuery(resolvedUri?.uri)
|
||||||
|
|
||||||
|
const isError = Boolean(resolveError || error)
|
||||||
|
|
||||||
const repostedBy = useMemo(() => {
|
const repostedBy = useMemo(() => {
|
||||||
if (data?.pages) {
|
if (data?.pages) {
|
||||||
return data.pages.flatMap(page => page.repostedBy)
|
return data.pages.flatMap(page => page.repostedBy)
|
||||||
}
|
}
|
||||||
|
return []
|
||||||
}, [data])
|
}, [data])
|
||||||
|
|
||||||
const onRefresh = useCallback(async () => {
|
const onRefresh = useCallback(async () => {
|
||||||
@@ -46,35 +65,20 @@ export function PostRepostedBy({uri}: {uri: string}) {
|
|||||||
}, [refetch, setIsPTRing])
|
}, [refetch, setIsPTRing])
|
||||||
|
|
||||||
const onEndReached = useCallback(async () => {
|
const onEndReached = useCallback(async () => {
|
||||||
if (isFetching || !hasNextPage || isError) return
|
if (isFetchingNextPage || !hasNextPage || isError) return
|
||||||
try {
|
try {
|
||||||
await fetchNextPage()
|
await fetchNextPage()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Failed to load more reposts', {message: err})
|
logger.error('Failed to load more reposts', {message: err})
|
||||||
}
|
}
|
||||||
}, [isFetching, hasNextPage, isError, fetchNextPage])
|
}, [isFetchingNextPage, hasNextPage, isError, fetchNextPage])
|
||||||
|
|
||||||
const renderItem = useCallback(
|
if (repostedBy.length < 1) {
|
||||||
({item}: {item: ActorDefs.ProfileViewBasic}) => {
|
|
||||||
return <ProfileCardWithFollowBtn key={item.did} profile={item} />
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
|
|
||||||
if (isFetchingResolvedUri || !isFetched) {
|
|
||||||
return <LoadingScreen />
|
|
||||||
}
|
|
||||||
|
|
||||||
// error
|
|
||||||
// =
|
|
||||||
if (resolveError || isError) {
|
|
||||||
return (
|
return (
|
||||||
<CenteredView>
|
<ListMaybePlaceholder
|
||||||
<ErrorMessage
|
isLoading={isLoadingUri || isLoadingRepostedBy}
|
||||||
message={cleanError(resolveError || error)}
|
isError={isError}
|
||||||
onPressTryAgain={onRefresh}
|
/>
|
||||||
/>
|
|
||||||
</CenteredView>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,28 +87,24 @@ export function PostRepostedBy({uri}: {uri: string}) {
|
|||||||
return (
|
return (
|
||||||
<List
|
<List
|
||||||
data={repostedBy}
|
data={repostedBy}
|
||||||
keyExtractor={item => item.did}
|
renderItem={renderItem}
|
||||||
|
keyExtractor={keyExtractor}
|
||||||
refreshing={isPTRing}
|
refreshing={isPTRing}
|
||||||
onRefresh={onRefresh}
|
onRefresh={onRefresh}
|
||||||
onEndReached={onEndReached}
|
onEndReached={onEndReached}
|
||||||
renderItem={renderItem}
|
onEndReachedThreshold={4}
|
||||||
initialNumToRender={15}
|
ListHeaderComponent={<ListHeaderDesktop title={_(msg`Reposted By`)} />}
|
||||||
// FIXME(dan)
|
ListFooterComponent={
|
||||||
// eslint-disable-next-line react/no-unstable-nested-components
|
<ListFooter
|
||||||
ListFooterComponent={() => (
|
isFetchingNextPage={isFetchingNextPage}
|
||||||
<View style={styles.footer}>
|
error={cleanError(error)}
|
||||||
{(isFetching || isFetchingNextPage) && <ActivityIndicator />}
|
onRetry={fetchNextPage}
|
||||||
</View>
|
/>
|
||||||
)}
|
}
|
||||||
// @ts-ignore our .web version only -prf
|
// @ts-ignore our .web version only -prf
|
||||||
desktopFixedHeight
|
desktopFixedHeight
|
||||||
|
initialNumToRender={initialNumToRender}
|
||||||
|
windowSize={11}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
footer: {
|
|
||||||
height: 200,
|
|
||||||
paddingTop: 20,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ let FeedSlice = ({
|
|||||||
hideTopBorder?: boolean
|
hideTopBorder?: boolean
|
||||||
}): React.ReactNode => {
|
}): React.ReactNode => {
|
||||||
if (slice.isThread && slice.items.length > 3) {
|
if (slice.isThread && slice.items.length > 3) {
|
||||||
|
const beforeLast = slice.items.length - 2
|
||||||
const last = slice.items.length - 1
|
const last = slice.items.length - 1
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -36,20 +37,20 @@ let FeedSlice = ({
|
|||||||
hideTopBorder={hideTopBorder}
|
hideTopBorder={hideTopBorder}
|
||||||
isParentBlocked={slice.items[0].isParentBlocked}
|
isParentBlocked={slice.items[0].isParentBlocked}
|
||||||
/>
|
/>
|
||||||
<FeedItem
|
|
||||||
key={slice.items[1]._reactKey}
|
|
||||||
post={slice.items[1].post}
|
|
||||||
record={slice.items[1].record}
|
|
||||||
reason={slice.items[1].reason}
|
|
||||||
feedContext={slice.items[1].feedContext}
|
|
||||||
parentAuthor={slice.items[1].parentAuthor}
|
|
||||||
showReplyTo={false}
|
|
||||||
moderation={slice.items[1].moderation}
|
|
||||||
isThreadParent={isThreadParentAt(slice.items, 1)}
|
|
||||||
isThreadChild={isThreadChildAt(slice.items, 1)}
|
|
||||||
isParentBlocked={slice.items[1].isParentBlocked}
|
|
||||||
/>
|
|
||||||
<ViewFullThread slice={slice} />
|
<ViewFullThread slice={slice} />
|
||||||
|
<FeedItem
|
||||||
|
key={slice.items[beforeLast]._reactKey}
|
||||||
|
post={slice.items[beforeLast].post}
|
||||||
|
record={slice.items[beforeLast].record}
|
||||||
|
reason={slice.items[beforeLast].reason}
|
||||||
|
feedContext={slice.items[beforeLast].feedContext}
|
||||||
|
parentAuthor={slice.items[beforeLast].parentAuthor}
|
||||||
|
showReplyTo={false}
|
||||||
|
moderation={slice.items[beforeLast].moderation}
|
||||||
|
isThreadParent={isThreadParentAt(slice.items, beforeLast)}
|
||||||
|
isThreadChild={isThreadChildAt(slice.items, beforeLast)}
|
||||||
|
isParentBlocked={slice.items[beforeLast].isParentBlocked}
|
||||||
|
/>
|
||||||
<FeedItem
|
<FeedItem
|
||||||
key={slice.items[last]._reactKey}
|
key={slice.items[last]._reactKey}
|
||||||
post={slice.items[last].post}
|
post={slice.items[last].post}
|
||||||
|
|||||||
@@ -344,10 +344,11 @@ function ListImpl<ItemT>(
|
|||||||
style={[styles.aboveTheFoldDetector, {height: headerOffset}]}
|
style={[styles.aboveTheFoldDetector, {height: headerOffset}]}
|
||||||
/>
|
/>
|
||||||
{onStartReached && !isEmpty && (
|
{onStartReached && !isEmpty && (
|
||||||
<Visibility
|
<EdgeVisibility
|
||||||
root={disableFullWindowScroll ? nativeRef : null}
|
root={disableFullWindowScroll ? nativeRef : null}
|
||||||
onVisibleChange={onHeadVisibilityChange}
|
onVisibleChange={onHeadVisibilityChange}
|
||||||
topMargin={(onStartReachedThreshold ?? 0) * 100 + '%'}
|
topMargin={(onStartReachedThreshold ?? 0) * 100 + '%'}
|
||||||
|
containerRef={containerRef}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{headerComponent}
|
{headerComponent}
|
||||||
@@ -368,11 +369,11 @@ function ListImpl<ItemT>(
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
{onEndReached && !isEmpty && (
|
{onEndReached && !isEmpty && (
|
||||||
<Visibility
|
<EdgeVisibility
|
||||||
root={disableFullWindowScroll ? nativeRef : null}
|
root={disableFullWindowScroll ? nativeRef : null}
|
||||||
onVisibleChange={onTailVisibilityChange}
|
onVisibleChange={onTailVisibilityChange}
|
||||||
bottomMargin={(onEndReachedThreshold ?? 0) * 100 + '%'}
|
bottomMargin={(onEndReachedThreshold ?? 0) * 100 + '%'}
|
||||||
key={data?.length}
|
containerRef={containerRef}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{footerComponent}
|
{footerComponent}
|
||||||
@@ -381,6 +382,34 @@ function ListImpl<ItemT>(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function EdgeVisibility({
|
||||||
|
root,
|
||||||
|
topMargin,
|
||||||
|
bottomMargin,
|
||||||
|
containerRef,
|
||||||
|
onVisibleChange,
|
||||||
|
}: {
|
||||||
|
root?: React.RefObject<HTMLDivElement> | null
|
||||||
|
topMargin?: string
|
||||||
|
bottomMargin?: string
|
||||||
|
containerRef: React.RefObject<Element>
|
||||||
|
onVisibleChange: (isVisible: boolean) => void
|
||||||
|
}) {
|
||||||
|
const [containerHeight, setContainerHeight] = React.useState(0)
|
||||||
|
useResizeObserver(containerRef, (w, h) => {
|
||||||
|
setContainerHeight(h)
|
||||||
|
})
|
||||||
|
return (
|
||||||
|
<Visibility
|
||||||
|
key={containerHeight}
|
||||||
|
root={root}
|
||||||
|
topMargin={topMargin}
|
||||||
|
bottomMargin={bottomMargin}
|
||||||
|
onVisibleChange={onVisibleChange}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function useResizeObserver(
|
function useResizeObserver(
|
||||||
ref: React.RefObject<Element>,
|
ref: React.RefObject<Element>,
|
||||||
onResize: undefined | ((w: number, h: number) => void),
|
onResize: undefined | ((w: number, h: number) => void),
|
||||||
|
|||||||
@@ -15,11 +15,14 @@ import {
|
|||||||
AppBskyEmbedRecordWithMedia,
|
AppBskyEmbedRecordWithMedia,
|
||||||
AppBskyFeedDefs,
|
AppBskyFeedDefs,
|
||||||
AppBskyGraphDefs,
|
AppBskyGraphDefs,
|
||||||
|
moderateFeedGenerator,
|
||||||
|
moderateUserList,
|
||||||
ModerationDecision,
|
ModerationDecision,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
|
|
||||||
import {ImagesLightbox, useLightboxControls} from '#/state/lightbox'
|
import {ImagesLightbox, useLightboxControls} from '#/state/lightbox'
|
||||||
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
|
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
|
||||||
|
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||||
import {usePalette} from 'lib/hooks/usePalette'
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
|
import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
|
||||||
import {atoms as a} from '#/alf'
|
import {atoms as a} from '#/alf'
|
||||||
@@ -51,7 +54,6 @@ export function PostEmbeds({
|
|||||||
style?: StyleProp<ViewStyle>
|
style?: StyleProp<ViewStyle>
|
||||||
allowNestedQuotes?: boolean
|
allowNestedQuotes?: boolean
|
||||||
}) {
|
}) {
|
||||||
const pal = usePalette('default')
|
|
||||||
const {openLightbox} = useLightboxControls()
|
const {openLightbox} = useLightboxControls()
|
||||||
const largeAltBadge = useLargeAltBadgeEnabled()
|
const largeAltBadge = useLargeAltBadgeEnabled()
|
||||||
|
|
||||||
@@ -72,22 +74,13 @@ export function PostEmbeds({
|
|||||||
|
|
||||||
if (AppBskyEmbedRecord.isView(embed)) {
|
if (AppBskyEmbedRecord.isView(embed)) {
|
||||||
// custom feed embed (i.e. generator view)
|
// custom feed embed (i.e. generator view)
|
||||||
// =
|
|
||||||
if (AppBskyFeedDefs.isGeneratorView(embed.record)) {
|
if (AppBskyFeedDefs.isGeneratorView(embed.record)) {
|
||||||
// TODO moderation
|
return <MaybeFeedCard view={embed.record} />
|
||||||
return (
|
|
||||||
<FeedSourceCard
|
|
||||||
feedUri={embed.record.uri}
|
|
||||||
style={[pal.view, pal.border, styles.customFeedOuter]}
|
|
||||||
showLikes
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// list embed
|
// list embed
|
||||||
if (AppBskyGraphDefs.isListView(embed.record)) {
|
if (AppBskyGraphDefs.isListView(embed.record)) {
|
||||||
// TODO moderation
|
return <MaybeListCard view={embed.record} />
|
||||||
return <ListEmbed item={embed.record} />
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (AppBskyGraphDefs.isStarterPackViewBasic(embed.record)) {
|
if (AppBskyGraphDefs.isStarterPackViewBasic(embed.record)) {
|
||||||
@@ -185,6 +178,39 @@ export function PostEmbeds({
|
|||||||
return <View />
|
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({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
marginTop: 8,
|
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 {faLock} from '@fortawesome/free-solid-svg-icons/faLock'
|
||||||
import {faMagnifyingGlass} from '@fortawesome/free-solid-svg-icons/faMagnifyingGlass'
|
import {faMagnifyingGlass} from '@fortawesome/free-solid-svg-icons/faMagnifyingGlass'
|
||||||
import {faNoteSticky} from '@fortawesome/free-solid-svg-icons/faNoteSticky'
|
import {faNoteSticky} from '@fortawesome/free-solid-svg-icons/faNoteSticky'
|
||||||
|
import {faPaintRoller} from '@fortawesome/free-solid-svg-icons/faPaintRoller'
|
||||||
import {faPause} from '@fortawesome/free-solid-svg-icons/faPause'
|
import {faPause} from '@fortawesome/free-solid-svg-icons/faPause'
|
||||||
import {faPen} from '@fortawesome/free-solid-svg-icons/faPen'
|
import {faPen} from '@fortawesome/free-solid-svg-icons/faPen'
|
||||||
import {faPenNib} from '@fortawesome/free-solid-svg-icons/faPenNib'
|
import {faPenNib} from '@fortawesome/free-solid-svg-icons/faPenNib'
|
||||||
@@ -178,6 +179,7 @@ library.add(
|
|||||||
faMagnifyingGlass,
|
faMagnifyingGlass,
|
||||||
faMessage,
|
faMessage,
|
||||||
faNoteSticky,
|
faNoteSticky,
|
||||||
|
faPaintRoller,
|
||||||
faPaste,
|
faPaste,
|
||||||
faPause,
|
faPause,
|
||||||
faPen,
|
faPen,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {ToggleButton} from '#/view/com/util/forms/ToggleButton'
|
|||||||
import {SimpleViewHeader} from '#/view/com/util/SimpleViewHeader'
|
import {SimpleViewHeader} from '#/view/com/util/SimpleViewHeader'
|
||||||
import {Text} from '#/view/com/util/text/Text'
|
import {Text} from '#/view/com/util/text/Text'
|
||||||
import {ScrollView} from '#/view/com/util/Views'
|
import {ScrollView} from '#/view/com/util/Views'
|
||||||
|
import {atoms as a} from '#/alf'
|
||||||
|
|
||||||
type Props = NativeStackScreenProps<
|
type Props = NativeStackScreenProps<
|
||||||
CommonNavigatorParams,
|
CommonNavigatorParams,
|
||||||
@@ -61,10 +62,13 @@ export function AccessibilitySettingsScreen({}: Props) {
|
|||||||
showBackButton={isTabletOrMobile}
|
showBackButton={isTabletOrMobile}
|
||||||
style={[
|
style={[
|
||||||
pal.border,
|
pal.border,
|
||||||
{borderBottomWidth: 1},
|
a.border_b,
|
||||||
!isMobile && {borderLeftWidth: 1, borderRightWidth: 1},
|
!isMobile && {
|
||||||
|
borderLeftWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderRightWidth: StyleSheet.hairlineWidth,
|
||||||
|
},
|
||||||
]}>
|
]}>
|
||||||
<View style={{flex: 1}}>
|
<View style={a.flex_1}>
|
||||||
<Text type="title-lg" style={[pal.text, {fontWeight: 'bold'}]}>
|
<Text type="title-lg" style={[pal.text, {fontWeight: 'bold'}]}>
|
||||||
<Trans>Accessibility Settings</Trans>
|
<Trans>Accessibility Settings</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import {useFocusEffect} from '@react-navigation/native'
|
|
||||||
import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
|
|
||||||
import {ViewHeader} from '../com/util/ViewHeader'
|
|
||||||
import {PostLikedBy as PostLikedByComponent} from '../com/post-thread/PostLikedBy'
|
|
||||||
import {makeRecordUri} from 'lib/strings/url-helpers'
|
|
||||||
import {useSetMinimalShellMode} from '#/state/shell'
|
|
||||||
import {msg} from '@lingui/macro'
|
import {msg} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
import {useFocusEffect} from '@react-navigation/native'
|
||||||
|
|
||||||
|
import {useSetMinimalShellMode} from '#/state/shell'
|
||||||
|
import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types'
|
||||||
|
import {makeRecordUri} from 'lib/strings/url-helpers'
|
||||||
|
import {PostLikedBy as PostLikedByComponent} from '../com/post-thread/PostLikedBy'
|
||||||
|
import {ViewHeader} from '../com/util/ViewHeader'
|
||||||
|
|
||||||
type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostLikedBy'>
|
type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostLikedBy'>
|
||||||
export const PostLikedByScreen = ({route}: Props) => {
|
export const PostLikedByScreen = ({route}: Props) => {
|
||||||
@@ -23,7 +24,7 @@ export const PostLikedByScreen = ({route}: Props) => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<View style={{flex: 1}}>
|
||||||
<ViewHeader title={_(msg`Liked By`)} />
|
<ViewHeader title={_(msg`Liked By`)} />
|
||||||
<PostLikedByComponent uri={uri} />
|
<PostLikedByComponent uri={uri} />
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import {useFocusEffect} from '@react-navigation/native'
|
|
||||||
import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
|
|
||||||
import {ViewHeader} from '../com/util/ViewHeader'
|
|
||||||
import {PostRepostedBy as PostRepostedByComponent} from '../com/post-thread/PostRepostedBy'
|
|
||||||
import {makeRecordUri} from 'lib/strings/url-helpers'
|
|
||||||
import {useSetMinimalShellMode} from '#/state/shell'
|
|
||||||
import {useLingui} from '@lingui/react'
|
|
||||||
import {msg} from '@lingui/macro'
|
import {msg} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
import {useFocusEffect} from '@react-navigation/native'
|
||||||
|
|
||||||
|
import {useSetMinimalShellMode} from '#/state/shell'
|
||||||
|
import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types'
|
||||||
|
import {makeRecordUri} from 'lib/strings/url-helpers'
|
||||||
|
import {PostRepostedBy as PostRepostedByComponent} from '../com/post-thread/PostRepostedBy'
|
||||||
|
import {ViewHeader} from '../com/util/ViewHeader'
|
||||||
|
|
||||||
type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostRepostedBy'>
|
type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostRepostedBy'>
|
||||||
export const PostRepostedByScreen = ({route}: Props) => {
|
export const PostRepostedByScreen = ({route}: Props) => {
|
||||||
@@ -23,7 +24,7 @@ export const PostRepostedByScreen = ({route}: Props) => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<View style={{flex: 1}}>
|
||||||
<ViewHeader title={_(msg`Reposted By`)} />
|
<ViewHeader title={_(msg`Reposted By`)} />
|
||||||
<PostRepostedByComponent uri={uri} />
|
<PostRepostedByComponent uri={uri} />
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
useSetExternalEmbedPref,
|
useSetExternalEmbedPref,
|
||||||
} from 'state/preferences'
|
} from 'state/preferences'
|
||||||
import {ToggleButton} from 'view/com/util/forms/ToggleButton'
|
import {ToggleButton} from 'view/com/util/forms/ToggleButton'
|
||||||
|
import {atoms as a} from '#/alf'
|
||||||
import {SimpleViewHeader} from '../com/util/SimpleViewHeader'
|
import {SimpleViewHeader} from '../com/util/SimpleViewHeader'
|
||||||
import {Text} from '../com/util/text/Text'
|
import {Text} from '../com/util/text/Text'
|
||||||
import {ScrollView} from '../com/util/Views'
|
import {ScrollView} from '../com/util/Views'
|
||||||
@@ -47,8 +48,8 @@ export function PreferencesExternalEmbeds({}: Props) {
|
|||||||
contentContainerStyle={[pal.viewLight, {paddingBottom: 75}]}>
|
contentContainerStyle={[pal.viewLight, {paddingBottom: 75}]}>
|
||||||
<SimpleViewHeader
|
<SimpleViewHeader
|
||||||
showBackButton={isTabletOrMobile}
|
showBackButton={isTabletOrMobile}
|
||||||
style={[pal.border, {borderBottomWidth: 1}]}>
|
style={[pal.border, a.border_b]}>
|
||||||
<View style={{flex: 1}}>
|
<View style={a.flex_1}>
|
||||||
<Text type="title-lg" style={[pal.text, {fontWeight: 'bold'}]}>
|
<Text type="title-lg" style={[pal.text, {fontWeight: 'bold'}]}>
|
||||||
<Trans>External Media Preferences</Trans>
|
<Trans>External Media Preferences</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
import React, {useState} from 'react'
|
import React from 'react'
|
||||||
import {StyleSheet, View} from 'react-native'
|
import {StyleSheet, View} from 'react-native'
|
||||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||||
import {msg, Plural, Trans} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {Slider} from '@miblanchard/react-native-slider'
|
|
||||||
import debounce from 'lodash.debounce'
|
|
||||||
|
|
||||||
import {usePalette} from '#/lib/hooks/usePalette'
|
import {usePalette} from '#/lib/hooks/usePalette'
|
||||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||||
import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
|
import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
|
||||||
import {colors, s} from '#/lib/styles'
|
import {colors, s} from '#/lib/styles'
|
||||||
import {isWeb} from '#/platform/detection'
|
|
||||||
import {
|
import {
|
||||||
usePreferencesQuery,
|
usePreferencesQuery,
|
||||||
useSetFeedViewPreferencesMutation,
|
useSetFeedViewPreferencesMutation,
|
||||||
@@ -19,61 +16,7 @@ import {ToggleButton} from '#/view/com/util/forms/ToggleButton'
|
|||||||
import {SimpleViewHeader} from '#/view/com/util/SimpleViewHeader'
|
import {SimpleViewHeader} from '#/view/com/util/SimpleViewHeader'
|
||||||
import {Text} from '#/view/com/util/text/Text'
|
import {Text} from '#/view/com/util/text/Text'
|
||||||
import {ScrollView} from '#/view/com/util/Views'
|
import {ScrollView} from '#/view/com/util/Views'
|
||||||
|
import {atoms as a} from '#/alf'
|
||||||
function RepliesThresholdInput({
|
|
||||||
enabled,
|
|
||||||
initialValue,
|
|
||||||
}: {
|
|
||||||
enabled: boolean
|
|
||||||
initialValue: number
|
|
||||||
}) {
|
|
||||||
const pal = usePalette('default')
|
|
||||||
const [value, setValue] = useState(initialValue)
|
|
||||||
const {mutate: setFeedViewPref} = useSetFeedViewPreferencesMutation()
|
|
||||||
const preValue = React.useRef(initialValue)
|
|
||||||
const save = React.useMemo(
|
|
||||||
() =>
|
|
||||||
debounce(
|
|
||||||
threshold =>
|
|
||||||
setFeedViewPref({
|
|
||||||
hideRepliesByLikeCount: threshold,
|
|
||||||
}),
|
|
||||||
500,
|
|
||||||
), // debouce for 500ms
|
|
||||||
[setFeedViewPref],
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={[!enabled && styles.dimmed]}>
|
|
||||||
<Slider
|
|
||||||
value={value}
|
|
||||||
onValueChange={(v: number | number[]) => {
|
|
||||||
let threshold = Array.isArray(v) ? v[0] : v
|
|
||||||
if (threshold > preValue.current) threshold = Math.floor(threshold)
|
|
||||||
else threshold = Math.ceil(threshold)
|
|
||||||
|
|
||||||
preValue.current = threshold
|
|
||||||
|
|
||||||
setValue(threshold)
|
|
||||||
save(threshold)
|
|
||||||
}}
|
|
||||||
minimumValue={0}
|
|
||||||
maximumValue={25}
|
|
||||||
containerStyle={isWeb ? undefined : s.flex1}
|
|
||||||
disabled={!enabled}
|
|
||||||
thumbTintColor={colors.blue3}
|
|
||||||
/>
|
|
||||||
<Text type="xs" style={pal.text}>
|
|
||||||
<Plural
|
|
||||||
value={value}
|
|
||||||
_0="Show all replies"
|
|
||||||
one="Show replies with at least # like"
|
|
||||||
other="Show replies with at least # likes"
|
|
||||||
/>
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
type Props = NativeStackScreenProps<
|
type Props = NativeStackScreenProps<
|
||||||
CommonNavigatorParams,
|
CommonNavigatorParams,
|
||||||
@@ -99,8 +42,8 @@ export function PreferencesFollowingFeed({}: Props) {
|
|||||||
contentContainerStyle={{paddingBottom: 75}}>
|
contentContainerStyle={{paddingBottom: 75}}>
|
||||||
<SimpleViewHeader
|
<SimpleViewHeader
|
||||||
showBackButton={isTabletOrMobile}
|
showBackButton={isTabletOrMobile}
|
||||||
style={[pal.border, {borderBottomWidth: 1}]}>
|
style={[pal.border, a.border_b]}>
|
||||||
<View style={{flex: 1}}>
|
<View style={a.flex_1}>
|
||||||
<Text type="title-lg" style={[pal.text, {fontWeight: 'bold'}]}>
|
<Text type="title-lg" style={[pal.text, {fontWeight: 'bold'}]}>
|
||||||
<Trans>Following Feed Preferences</Trans>
|
<Trans>Following Feed Preferences</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
@@ -136,51 +79,6 @@ export function PreferencesFollowingFeed({}: Props) {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<View
|
|
||||||
style={[pal.viewLight, styles.card, !showReplies && styles.dimmed]}>
|
|
||||||
<Text type="title-sm" style={[pal.text, s.pb5]}>
|
|
||||||
<Trans>Reply Filters</Trans>
|
|
||||||
</Text>
|
|
||||||
<Text style={[pal.text, s.pb10]}>
|
|
||||||
<Trans>
|
|
||||||
Enable this setting to only see replies between people you
|
|
||||||
follow.
|
|
||||||
</Trans>
|
|
||||||
</Text>
|
|
||||||
<ToggleButton
|
|
||||||
type="default-light"
|
|
||||||
label={_(msg`Followed users only`)}
|
|
||||||
isSelected={Boolean(
|
|
||||||
variables?.hideRepliesByUnfollowed ??
|
|
||||||
preferences?.feedViewPrefs?.hideRepliesByUnfollowed,
|
|
||||||
)}
|
|
||||||
onPress={
|
|
||||||
showReplies
|
|
||||||
? () =>
|
|
||||||
setFeedViewPref({
|
|
||||||
hideRepliesByUnfollowed: !(
|
|
||||||
variables?.hideRepliesByUnfollowed ??
|
|
||||||
preferences?.feedViewPrefs?.hideRepliesByUnfollowed
|
|
||||||
),
|
|
||||||
})
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
style={[s.mb10]}
|
|
||||||
/>
|
|
||||||
<Text style={[pal.text]}>
|
|
||||||
<Trans>
|
|
||||||
Adjust the number of likes a reply must have to be shown in your
|
|
||||||
feed.
|
|
||||||
</Trans>
|
|
||||||
</Text>
|
|
||||||
{preferences && (
|
|
||||||
<RepliesThresholdInput
|
|
||||||
enabled={showReplies}
|
|
||||||
initialValue={preferences.feedViewPrefs.hideRepliesByLikeCount}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={[pal.viewLight, styles.card]}>
|
<View style={[pal.viewLight, styles.card]}>
|
||||||
<Text type="title-sm" style={[pal.text, s.pb5]}>
|
<Text type="title-sm" style={[pal.text, s.pb5]}>
|
||||||
<Trans>Show Reposts</Trans>
|
<Trans>Show Reposts</Trans>
|
||||||
|
|||||||
@@ -45,8 +45,8 @@ export function PreferencesThreads({}: Props) {
|
|||||||
contentContainerStyle={{paddingBottom: 75}}>
|
contentContainerStyle={{paddingBottom: 75}}>
|
||||||
<SimpleViewHeader
|
<SimpleViewHeader
|
||||||
showBackButton={isTabletOrMobile}
|
showBackButton={isTabletOrMobile}
|
||||||
style={[pal.border, {borderBottomWidth: 1}]}>
|
style={[pal.border, a.border_b]}>
|
||||||
<View style={{flex: 1}}>
|
<View style={a.flex_1}>
|
||||||
<Text type="title-lg" style={[pal.text, {fontWeight: 'bold'}]}>
|
<Text type="title-lg" style={[pal.text, {fontWeight: 'bold'}]}>
|
||||||
<Trans>Thread Preferences</Trans>
|
<Trans>Thread Preferences</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import {useFocusEffect} from '@react-navigation/native'
|
|
||||||
import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
|
|
||||||
import {ViewHeader} from '../com/util/ViewHeader'
|
|
||||||
import {PostLikedBy as PostLikedByComponent} from '../com/post-thread/PostLikedBy'
|
|
||||||
import {makeRecordUri} from 'lib/strings/url-helpers'
|
|
||||||
import {useSetMinimalShellMode} from '#/state/shell'
|
|
||||||
import {useLingui} from '@lingui/react'
|
|
||||||
import {msg} from '@lingui/macro'
|
import {msg} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
import {useFocusEffect} from '@react-navigation/native'
|
||||||
|
|
||||||
|
import {useSetMinimalShellMode} from '#/state/shell'
|
||||||
|
import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types'
|
||||||
|
import {makeRecordUri} from 'lib/strings/url-helpers'
|
||||||
|
import {PostLikedBy as PostLikedByComponent} from '../com/post-thread/PostLikedBy'
|
||||||
|
import {ViewHeader} from '../com/util/ViewHeader'
|
||||||
|
|
||||||
type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileFeedLikedBy'>
|
type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileFeedLikedBy'>
|
||||||
export const ProfileFeedLikedByScreen = ({route}: Props) => {
|
export const ProfileFeedLikedByScreen = ({route}: Props) => {
|
||||||
@@ -23,7 +24,7 @@ export const ProfileFeedLikedByScreen = ({route}: Props) => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<View style={{flex: 1}}>
|
||||||
<ViewHeader title={_(msg`Liked By`)} />
|
<ViewHeader title={_(msg`Liked By`)} />
|
||||||
<PostLikedByComponent uri={uri} />
|
<PostLikedByComponent uri={uri} />
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import React, {useCallback, useMemo} from 'react'
|
import React, {useCallback, useMemo} from 'react'
|
||||||
import {Pressable, StyleSheet, View} from 'react-native'
|
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 {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||||
import {msg, Trans} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
@@ -14,6 +20,7 @@ import {logger} from '#/logger'
|
|||||||
import {isNative} from '#/platform/detection'
|
import {isNative} from '#/platform/detection'
|
||||||
import {listenSoftReset} from '#/state/events'
|
import {listenSoftReset} from '#/state/events'
|
||||||
import {useModalControls} from '#/state/modals'
|
import {useModalControls} from '#/state/modals'
|
||||||
|
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||||
import {
|
import {
|
||||||
useListBlockMutation,
|
useListBlockMutation,
|
||||||
useListDeleteMutation,
|
useListDeleteMutation,
|
||||||
@@ -59,6 +66,7 @@ import * as Toast from 'view/com/util/Toast'
|
|||||||
import {CenteredView} from 'view/com/util/Views'
|
import {CenteredView} from 'view/com/util/Views'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {useDialogControl} from '#/components/Dialog'
|
import {useDialogControl} from '#/components/Dialog'
|
||||||
|
import {ScreenHider} from '#/components/moderation/ScreenHider'
|
||||||
import * as Prompt from '#/components/Prompt'
|
import * as Prompt from '#/components/Prompt'
|
||||||
import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog'
|
import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog'
|
||||||
import {RichText} from '#/components/RichText'
|
import {RichText} from '#/components/RichText'
|
||||||
@@ -80,6 +88,7 @@ export function ProfileListScreen(props: Props) {
|
|||||||
AtUri.make(handleOrDid, 'app.bsky.graph.list', rkey).toString(),
|
AtUri.make(handleOrDid, 'app.bsky.graph.list', rkey).toString(),
|
||||||
)
|
)
|
||||||
const {data: list, error: listError} = useListQuery(resolvedUri?.uri)
|
const {data: list, error: listError} = useListQuery(resolvedUri?.uri)
|
||||||
|
const moderationOpts = useModerationOpts()
|
||||||
|
|
||||||
if (resolveError) {
|
if (resolveError) {
|
||||||
return (
|
return (
|
||||||
@@ -100,8 +109,13 @@ export function ProfileListScreen(props: Props) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return resolvedUri && list ? (
|
return resolvedUri && list && moderationOpts ? (
|
||||||
<ProfileListScreenLoaded {...props} uri={resolvedUri.uri} list={list} />
|
<ProfileListScreenLoaded
|
||||||
|
{...props}
|
||||||
|
uri={resolvedUri.uri}
|
||||||
|
list={list}
|
||||||
|
moderationOpts={moderationOpts}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<LoadingScreen />
|
<LoadingScreen />
|
||||||
)
|
)
|
||||||
@@ -111,7 +125,12 @@ function ProfileListScreenLoaded({
|
|||||||
route,
|
route,
|
||||||
uri,
|
uri,
|
||||||
list,
|
list,
|
||||||
}: Props & {uri: string; list: AppBskyGraphDefs.ListView}) {
|
moderationOpts,
|
||||||
|
}: Props & {
|
||||||
|
uri: string
|
||||||
|
list: AppBskyGraphDefs.ListView
|
||||||
|
moderationOpts: ModerationOpts
|
||||||
|
}) {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const {openComposer} = useComposerControls()
|
const {openComposer} = useComposerControls()
|
||||||
@@ -123,6 +142,10 @@ function ProfileListScreenLoaded({
|
|||||||
const isCurateList = list.purpose === 'app.bsky.graph.defs#curatelist'
|
const isCurateList = list.purpose === 'app.bsky.graph.defs#curatelist'
|
||||||
const isScreenFocused = useIsFocused()
|
const isScreenFocused = useIsFocused()
|
||||||
|
|
||||||
|
const moderation = React.useMemo(() => {
|
||||||
|
return moderateUserList(list, moderationOpts)
|
||||||
|
}, [list, moderationOpts])
|
||||||
|
|
||||||
useSetTitle(list.name)
|
useSetTitle(list.name)
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
@@ -160,26 +183,65 @@ function ProfileListScreenLoaded({
|
|||||||
|
|
||||||
if (isCurateList) {
|
if (isCurateList) {
|
||||||
return (
|
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}>
|
<View style={s.hContentRegion}>
|
||||||
<PagerWithHeader
|
<PagerWithHeader
|
||||||
items={SECTION_TITLES_CURATE}
|
items={SECTION_TITLES_MOD}
|
||||||
isHeaderReady={true}
|
isHeaderReady={true}
|
||||||
renderHeader={renderHeader}
|
renderHeader={renderHeader}>
|
||||||
onCurrentPageSelected={onCurrentPageSelected}>
|
|
||||||
{({headerHeight, scrollElRef, isFocused}) => (
|
|
||||||
<FeedSection
|
|
||||||
ref={feedSectionRef}
|
|
||||||
feed={`list|${uri}`}
|
|
||||||
scrollElRef={scrollElRef as ListRef}
|
|
||||||
headerHeight={headerHeight}
|
|
||||||
isFocused={isScreenFocused && isFocused}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{({headerHeight, scrollElRef}) => (
|
{({headerHeight, scrollElRef}) => (
|
||||||
<AboutSection
|
<AboutSection
|
||||||
ref={aboutSectionRef}
|
|
||||||
scrollElRef={scrollElRef as ListRef}
|
|
||||||
list={list}
|
list={list}
|
||||||
|
scrollElRef={scrollElRef as ListRef}
|
||||||
onPressAddUser={onPressAddUser}
|
onPressAddUser={onPressAddUser}
|
||||||
headerHeight={headerHeight}
|
headerHeight={headerHeight}
|
||||||
/>
|
/>
|
||||||
@@ -200,34 +262,7 @@ function ProfileListScreenLoaded({
|
|||||||
accessibilityHint=""
|
accessibilityHint=""
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)
|
</ScreenHider>
|
||||||
}
|
|
||||||
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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,12 +31,7 @@ import {useClearPreferencesMutation} from '#/state/queries/preferences'
|
|||||||
import {RQKEY as RQKEY_PROFILE} from '#/state/queries/profile'
|
import {RQKEY as RQKEY_PROFILE} from '#/state/queries/profile'
|
||||||
import {useProfileQuery} from '#/state/queries/profile'
|
import {useProfileQuery} from '#/state/queries/profile'
|
||||||
import {SessionAccount, useSession, useSessionApi} from '#/state/session'
|
import {SessionAccount, useSession, useSessionApi} from '#/state/session'
|
||||||
import {
|
import {useOnboardingDispatch, useSetMinimalShellMode} from '#/state/shell'
|
||||||
useOnboardingDispatch,
|
|
||||||
useSetMinimalShellMode,
|
|
||||||
useSetThemePrefs,
|
|
||||||
useThemePrefs,
|
|
||||||
} from '#/state/shell'
|
|
||||||
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
|
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
|
||||||
import {useCloseAllActiveElements} from '#/state/util'
|
import {useCloseAllActiveElements} from '#/state/util'
|
||||||
import {useAnalytics} from 'lib/analytics/analytics'
|
import {useAnalytics} from 'lib/analytics/analytics'
|
||||||
@@ -52,7 +47,6 @@ import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types'
|
|||||||
import {NavigationProp} from 'lib/routes/types'
|
import {NavigationProp} from 'lib/routes/types'
|
||||||
import {colors, s} from 'lib/styles'
|
import {colors, s} from 'lib/styles'
|
||||||
import {AccountDropdownBtn} from 'view/com/util/AccountDropdownBtn'
|
import {AccountDropdownBtn} from 'view/com/util/AccountDropdownBtn'
|
||||||
import {SelectableBtn} from 'view/com/util/forms/SelectableBtn'
|
|
||||||
import {ToggleButton} from 'view/com/util/forms/ToggleButton'
|
import {ToggleButton} from 'view/com/util/forms/ToggleButton'
|
||||||
import {Link, TextLink} from 'view/com/util/Link'
|
import {Link, TextLink} from 'view/com/util/Link'
|
||||||
import {SimpleViewHeader} from 'view/com/util/SimpleViewHeader'
|
import {SimpleViewHeader} from 'view/com/util/SimpleViewHeader'
|
||||||
@@ -61,8 +55,7 @@ import * as Toast from 'view/com/util/Toast'
|
|||||||
import {UserAvatar} from 'view/com/util/UserAvatar'
|
import {UserAvatar} from 'view/com/util/UserAvatar'
|
||||||
import {ScrollView} from 'view/com/util/Views'
|
import {ScrollView} from 'view/com/util/Views'
|
||||||
import {DeactivateAccountDialog} from '#/screens/Settings/components/DeactivateAccountDialog'
|
import {DeactivateAccountDialog} from '#/screens/Settings/components/DeactivateAccountDialog'
|
||||||
import {useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {atoms as a} from '#/alf'
|
|
||||||
import {useDialogControl} from '#/components/Dialog'
|
import {useDialogControl} from '#/components/Dialog'
|
||||||
import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings'
|
import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings'
|
||||||
import {navigate, resetToTab} from '#/Navigation'
|
import {navigate, resetToTab} from '#/Navigation'
|
||||||
@@ -168,8 +161,6 @@ function SettingsAccountCard({
|
|||||||
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Settings'>
|
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Settings'>
|
||||||
export function SettingsScreen({}: Props) {
|
export function SettingsScreen({}: Props) {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const {colorMode, darkTheme} = useThemePrefs()
|
|
||||||
const {setColorMode, setDarkTheme} = useSetThemePrefs()
|
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const setMinimalShellMode = useSetMinimalShellMode()
|
const setMinimalShellMode = useSetMinimalShellMode()
|
||||||
@@ -296,6 +287,10 @@ export function SettingsScreen({}: Props) {
|
|||||||
navigation.navigate('AccessibilitySettings')
|
navigation.navigate('AccessibilitySettings')
|
||||||
}, [navigation])
|
}, [navigation])
|
||||||
|
|
||||||
|
const onPressAppearanceSettings = React.useCallback(() => {
|
||||||
|
navigation.navigate('AppearanceSettings')
|
||||||
|
}, [navigation])
|
||||||
|
|
||||||
const onPressBirthday = React.useCallback(() => {
|
const onPressBirthday = React.useCallback(() => {
|
||||||
birthdayControl.open()
|
birthdayControl.open()
|
||||||
}, [birthdayControl])
|
}, [birthdayControl])
|
||||||
@@ -436,63 +431,6 @@ export function SettingsScreen({}: Props) {
|
|||||||
|
|
||||||
<View style={styles.spacer20} />
|
<View style={styles.spacer20} />
|
||||||
|
|
||||||
<Text type="xl-bold" style={[pal.text, styles.heading]}>
|
|
||||||
<Trans>Appearance</Trans>
|
|
||||||
</Text>
|
|
||||||
<View>
|
|
||||||
<View style={[styles.linkCard, pal.view, styles.selectableBtns]}>
|
|
||||||
<SelectableBtn
|
|
||||||
selected={colorMode === 'system'}
|
|
||||||
label={_(msg`System`)}
|
|
||||||
left
|
|
||||||
onSelect={() => setColorMode('system')}
|
|
||||||
accessibilityHint={_(msg`Sets color theme to system setting`)}
|
|
||||||
/>
|
|
||||||
<SelectableBtn
|
|
||||||
selected={colorMode === 'light'}
|
|
||||||
label={_(msg`Light`)}
|
|
||||||
onSelect={() => setColorMode('light')}
|
|
||||||
accessibilityHint={_(msg`Sets color theme to light`)}
|
|
||||||
/>
|
|
||||||
<SelectableBtn
|
|
||||||
selected={colorMode === 'dark'}
|
|
||||||
label={_(msg`Dark`)}
|
|
||||||
right
|
|
||||||
onSelect={() => setColorMode('dark')}
|
|
||||||
accessibilityHint={_(msg`Sets color theme to dark`)}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.spacer20} />
|
|
||||||
|
|
||||||
{colorMode !== 'light' && (
|
|
||||||
<>
|
|
||||||
<Text type="xl-bold" style={[pal.text, styles.heading]}>
|
|
||||||
<Trans>Dark Theme</Trans>
|
|
||||||
</Text>
|
|
||||||
<View>
|
|
||||||
<View style={[styles.linkCard, pal.view, styles.selectableBtns]}>
|
|
||||||
<SelectableBtn
|
|
||||||
selected={!darkTheme || darkTheme === 'dim'}
|
|
||||||
label={_(msg`Dim`)}
|
|
||||||
left
|
|
||||||
onSelect={() => setDarkTheme('dim')}
|
|
||||||
accessibilityHint={_(msg`Sets dark theme to the dim theme`)}
|
|
||||||
/>
|
|
||||||
<SelectableBtn
|
|
||||||
selected={darkTheme === 'dark'}
|
|
||||||
label={_(msg`Dark`)}
|
|
||||||
right
|
|
||||||
onSelect={() => setDarkTheme('dark')}
|
|
||||||
accessibilityHint={_(msg`Sets dark theme to the dark theme`)}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<View style={styles.spacer20} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Text type="xl-bold" style={[pal.text, styles.heading]}>
|
<Text type="xl-bold" style={[pal.text, styles.heading]}>
|
||||||
<Trans>Basics</Trans>
|
<Trans>Basics</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
@@ -519,6 +457,27 @@ export function SettingsScreen({}: Props) {
|
|||||||
<Trans>Accessibility</Trans>
|
<Trans>Accessibility</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
testID="appearanceSettingsBtn"
|
||||||
|
style={[
|
||||||
|
styles.linkCard,
|
||||||
|
pal.view,
|
||||||
|
isSwitchingAccounts && styles.dimmed,
|
||||||
|
]}
|
||||||
|
onPress={isSwitchingAccounts ? undefined : onPressAppearanceSettings}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={_(msg`Appearance settings`)}
|
||||||
|
accessibilityHint={_(msg`Opens appearance settings`)}>
|
||||||
|
<View style={[styles.iconContainer, pal.btn]}>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon="paint-roller"
|
||||||
|
style={pal.text as FontAwesomeIconStyle}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text type="lg" style={pal.text}>
|
||||||
|
<Trans>Appearance</Trans>
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
testID="languageSettingsBtn"
|
testID="languageSettingsBtn"
|
||||||
style={[
|
style={[
|
||||||
|
|||||||
@@ -34,10 +34,10 @@
|
|||||||
jsonpointer "^5.0.0"
|
jsonpointer "^5.0.0"
|
||||||
leven "^3.1.0"
|
leven "^3.1.0"
|
||||||
|
|
||||||
"@atproto/api@0.12.25":
|
"@atproto/api@^0.12.26":
|
||||||
version "0.12.25"
|
version "0.12.26"
|
||||||
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.25.tgz#9eeb51484106a5e07f89f124e505674a3574f93b"
|
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.26.tgz#940888466522cc9ff8c03d8164dc39221b29d9ca"
|
||||||
integrity sha512-IV3vGPnDw9bmyP/JOd8YKbm8fOpRAgJpEUVnIZNVb/Vo8v+WOroOjrJxtzdHOcXTL9IEcTTyXSCc7yE7kwhN2A==
|
integrity sha512-RH0ymOGbDfT8IL8eNzzY+hwtyTgknHfkzUVqRd0sstNblvTf8WGpDR2FSTveiiMR3OpVO6zG8fRYVzBfmY1+pA==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@atproto/common-web" "^0.3.0"
|
"@atproto/common-web" "^0.3.0"
|
||||||
"@atproto/lexicon" "^0.4.0"
|
"@atproto/lexicon" "^0.4.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user