Merge remote-tracking branch 'origin/main' into fast-follow
* origin/main: (50 commits) Add `list hidden` screen (#4958) Expose more methods, support disabled items (#4954) Expose more props from button (#4953) Fix orphaned feed slices, handle blocks (#4944) Tweak `expo-modules-core` hack patch (#4955) [Experiment] Always show bottom bar (#4946) Revert "[Video] Download videos" (#4945) Move global "Sign out" out of the current account row (#4941) Hack patch for testing OTA update crash behavior (#4942) [Video] Download videos (#4886) swap control files (#4936) [Embed] Starter pack embed embed (#4935) [Video] set audio category to ambient every time a new player is made (#4934) Add `/live/` to supported YouTube embed URLs (#4932) [Video] Try/catch video play/pause (#4930) Don't kick to login screen on network error (#4911) Remove .withProxy() calls (#4929) [Video] Audio duck off main thread (#4926) subclass agent to add setPersistSessionHandler (#4928) [Video] Fix crash when switching tabs (#4925) ...
This commit is contained in:
@@ -61,6 +61,7 @@ import {Provider as PortalProvider} from '#/components/Portal'
|
||||
import {Splash} from '#/Splash'
|
||||
import {Provider as TourProvider} from '#/tours'
|
||||
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
||||
import {AudioCategory, PlatformInfo} from '../modules/expo-bluesky-swiss-army'
|
||||
|
||||
SplashScreen.preventAutoHideAsync()
|
||||
|
||||
@@ -157,6 +158,8 @@ function App() {
|
||||
const [isReady, setReady] = useState(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
PlatformInfo.setAudioCategory(AudioCategory.Ambient)
|
||||
PlatformInfo.setAudioActive(false)
|
||||
initPersistedState().then(() => setReady(true))
|
||||
}, [])
|
||||
|
||||
|
||||
+14
-14
@@ -186,19 +186,19 @@ export function createThemes({
|
||||
white: color.gray_0,
|
||||
black: color.trueBlack,
|
||||
|
||||
contrast_25: color.gray_1000,
|
||||
contrast_50: color.gray_975,
|
||||
contrast_100: color.gray_950,
|
||||
contrast_200: color.gray_900,
|
||||
contrast_300: color.gray_800,
|
||||
contrast_400: color.gray_700,
|
||||
contrast_500: color.gray_600,
|
||||
contrast_600: color.gray_500,
|
||||
contrast_700: color.gray_400,
|
||||
contrast_800: color.gray_300,
|
||||
contrast_900: color.gray_200,
|
||||
contrast_950: color.gray_100,
|
||||
contrast_975: color.gray_50,
|
||||
contrast_25: color.gray_975,
|
||||
contrast_50: color.gray_950,
|
||||
contrast_100: color.gray_900,
|
||||
contrast_200: color.gray_800,
|
||||
contrast_300: color.gray_700,
|
||||
contrast_400: color.gray_600,
|
||||
contrast_500: color.gray_500,
|
||||
contrast_600: color.gray_400,
|
||||
contrast_700: color.gray_300,
|
||||
contrast_800: color.gray_200,
|
||||
contrast_900: color.gray_100,
|
||||
contrast_950: color.gray_50,
|
||||
contrast_975: color.gray_25,
|
||||
|
||||
primary_25: color.primary_975,
|
||||
primary_50: color.primary_950,
|
||||
@@ -400,7 +400,7 @@ export function createThemes({
|
||||
color: darkPalette.contrast_400,
|
||||
},
|
||||
text_contrast_medium: {
|
||||
color: darkPalette.contrast_700,
|
||||
color: darkPalette.contrast_600,
|
||||
},
|
||||
text_contrast_high: {
|
||||
color: darkPalette.contrast_900,
|
||||
|
||||
+58
-46
@@ -1,6 +1,8 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
AccessibilityProps,
|
||||
GestureResponderEvent,
|
||||
MouseEvent,
|
||||
Pressable,
|
||||
PressableProps,
|
||||
StyleProp,
|
||||
@@ -65,7 +67,15 @@ type NonTextElements =
|
||||
|
||||
export type ButtonProps = Pick<
|
||||
PressableProps,
|
||||
'disabled' | 'onPress' | 'testID' | 'onLongPress' | 'hitSlop'
|
||||
| 'disabled'
|
||||
| 'onPress'
|
||||
| 'testID'
|
||||
| 'onLongPress'
|
||||
| 'hitSlop'
|
||||
| 'onHoverIn'
|
||||
| 'onHoverOut'
|
||||
| 'onPressIn'
|
||||
| 'onPressOut'
|
||||
> &
|
||||
AccessibilityProps &
|
||||
VariantProps & {
|
||||
@@ -115,30 +125,50 @@ export const Button = React.forwardRef<View, ButtonProps>(
|
||||
focused: false,
|
||||
})
|
||||
|
||||
const onPressIn = React.useCallback(() => {
|
||||
setState(s => ({
|
||||
...s,
|
||||
pressed: true,
|
||||
}))
|
||||
}, [setState])
|
||||
const onPressOut = React.useCallback(() => {
|
||||
setState(s => ({
|
||||
...s,
|
||||
pressed: false,
|
||||
}))
|
||||
}, [setState])
|
||||
const onHoverIn = React.useCallback(() => {
|
||||
setState(s => ({
|
||||
...s,
|
||||
hovered: true,
|
||||
}))
|
||||
}, [setState])
|
||||
const onHoverOut = React.useCallback(() => {
|
||||
setState(s => ({
|
||||
...s,
|
||||
hovered: false,
|
||||
}))
|
||||
}, [setState])
|
||||
const onPressInOuter = rest.onPressIn
|
||||
const onPressIn = React.useCallback(
|
||||
(e: GestureResponderEvent) => {
|
||||
setState(s => ({
|
||||
...s,
|
||||
pressed: true,
|
||||
}))
|
||||
onPressInOuter?.(e)
|
||||
},
|
||||
[setState, onPressInOuter],
|
||||
)
|
||||
const onPressOutOuter = rest.onPressOut
|
||||
const onPressOut = React.useCallback(
|
||||
(e: GestureResponderEvent) => {
|
||||
setState(s => ({
|
||||
...s,
|
||||
pressed: false,
|
||||
}))
|
||||
onPressOutOuter?.(e)
|
||||
},
|
||||
[setState, onPressOutOuter],
|
||||
)
|
||||
const onHoverInOuter = rest.onHoverIn
|
||||
const onHoverIn = React.useCallback(
|
||||
(e: MouseEvent) => {
|
||||
setState(s => ({
|
||||
...s,
|
||||
hovered: true,
|
||||
}))
|
||||
onHoverInOuter?.(e)
|
||||
},
|
||||
[setState, onHoverInOuter],
|
||||
)
|
||||
const onHoverOutOuter = rest.onHoverOut
|
||||
const onHoverOut = React.useCallback(
|
||||
(e: MouseEvent) => {
|
||||
setState(s => ({
|
||||
...s,
|
||||
hovered: false,
|
||||
}))
|
||||
onHoverOutOuter?.(e)
|
||||
},
|
||||
[setState, onHoverOutOuter],
|
||||
)
|
||||
const onFocus = React.useCallback(() => {
|
||||
setState(s => ({
|
||||
...s,
|
||||
@@ -202,28 +232,10 @@ export const Button = React.forwardRef<View, ButtonProps>(
|
||||
} else if (color === 'secondary') {
|
||||
if (variant === 'solid') {
|
||||
if (!disabled) {
|
||||
baseStyles.push({
|
||||
backgroundColor: select(t.name, {
|
||||
light: t.palette.contrast_25,
|
||||
dim: t.palette.contrast_100,
|
||||
dark: t.palette.contrast_100,
|
||||
}),
|
||||
})
|
||||
hoverStyles.push({
|
||||
backgroundColor: select(t.name, {
|
||||
light: t.palette.contrast_50,
|
||||
dim: t.palette.contrast_200,
|
||||
dark: t.palette.contrast_200,
|
||||
}),
|
||||
})
|
||||
baseStyles.push(t.atoms.bg_contrast_25)
|
||||
hoverStyles.push(t.atoms.bg_contrast_50)
|
||||
} else {
|
||||
baseStyles.push({
|
||||
backgroundColor: select(t.name, {
|
||||
light: t.palette.contrast_100,
|
||||
dim: t.palette.contrast_25,
|
||||
dark: t.palette.contrast_25,
|
||||
}),
|
||||
})
|
||||
baseStyles.push(t.atoms.bg_contrast_100)
|
||||
}
|
||||
} else if (variant === 'outline') {
|
||||
baseStyles.push(a.border, t.atoms.bg, {
|
||||
|
||||
@@ -2,21 +2,18 @@ import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useNavigation} from '@react-navigation/core'
|
||||
import {StackActions} from '@react-navigation/native'
|
||||
|
||||
import {NavigationProp} from 'lib/routes/types'
|
||||
import {useGoBack} from 'lib/hooks/useGoBack'
|
||||
import {CenteredView} from 'view/com/util/Views'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {router} from '#/routes'
|
||||
|
||||
export function Error({
|
||||
title,
|
||||
message,
|
||||
onRetry,
|
||||
onGoBack: onGoBackProp,
|
||||
onGoBack,
|
||||
hideBackButton,
|
||||
sideBorders = true,
|
||||
}: {
|
||||
@@ -27,31 +24,10 @@ export function Error({
|
||||
hideBackButton?: boolean
|
||||
sideBorders?: boolean
|
||||
}) {
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
|
||||
const canGoBack = navigation.canGoBack()
|
||||
const onGoBack = React.useCallback(() => {
|
||||
if (onGoBackProp) {
|
||||
onGoBackProp()
|
||||
return
|
||||
}
|
||||
if (canGoBack) {
|
||||
navigation.goBack()
|
||||
} else {
|
||||
navigation.navigate('HomeTab')
|
||||
|
||||
// Checking the state for routes ensures that web doesn't encounter errors while going back
|
||||
if (navigation.getState()?.routes) {
|
||||
navigation.dispatch(StackActions.push(...router.matchPath('/')))
|
||||
} else {
|
||||
navigation.navigate('HomeTab')
|
||||
navigation.dispatch(StackActions.popToTop())
|
||||
}
|
||||
}
|
||||
}, [navigation, canGoBack, onGoBackProp])
|
||||
const goBack = useGoBack(onGoBack)
|
||||
|
||||
return (
|
||||
<CenteredView
|
||||
@@ -96,7 +72,7 @@ export function Error({
|
||||
variant="solid"
|
||||
color={onRetry ? 'secondary' : 'primary'}
|
||||
label={_(msg`Return to previous page`)}
|
||||
onPress={onGoBack}
|
||||
onPress={goBack}
|
||||
size="large"
|
||||
style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]}>
|
||||
<ButtonText>
|
||||
|
||||
@@ -133,16 +133,26 @@ function useExperimentalSuggestedUsersQuery() {
|
||||
const {currentAccount} = useSession()
|
||||
const userActionSnapshot = userActionHistory.useActionHistorySnapshot()
|
||||
const dids = React.useMemo(() => {
|
||||
const {likes, follows, seen} = userActionSnapshot
|
||||
const {likes, follows, followSuggestions, seen} = userActionSnapshot
|
||||
const likeDids = likes
|
||||
.map(l => new AtUri(l))
|
||||
.map(uri => uri.host)
|
||||
.filter(did => !follows.includes(did))
|
||||
let suggestedDids: string[] = []
|
||||
if (followSuggestions.length > 0) {
|
||||
suggestedDids = [
|
||||
// It's ok if these will pick the same item (weighed by its frequency)
|
||||
followSuggestions[Math.floor(Math.random() * followSuggestions.length)],
|
||||
followSuggestions[Math.floor(Math.random() * followSuggestions.length)],
|
||||
followSuggestions[Math.floor(Math.random() * followSuggestions.length)],
|
||||
followSuggestions[Math.floor(Math.random() * followSuggestions.length)],
|
||||
]
|
||||
}
|
||||
const seenDids = seen
|
||||
.sort(sortSeenPosts)
|
||||
.map(l => new AtUri(l.uri))
|
||||
.map(uri => uri.host)
|
||||
return [...new Set([...likeDids, ...seenDids])].filter(
|
||||
return [...new Set([...suggestedDids, ...likeDids, ...seenDids])].filter(
|
||||
did => did !== currentAccount?.did,
|
||||
)
|
||||
}, [userActionSnapshot, currentAccount])
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {AppBskyActorDefs, AppBskyGraphDefs, AtUri} from '@atproto/api'
|
||||
import {
|
||||
AppBskyActorDefs,
|
||||
AppBskyGraphDefs,
|
||||
AtUri,
|
||||
moderateUserList,
|
||||
ModerationUI,
|
||||
} from '@atproto/api'
|
||||
import {Trans} from '@lingui/macro'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {sanitizeHandle} from 'lib/strings/handles'
|
||||
import {useModerationOpts} from 'state/preferences/moderation-opts'
|
||||
import {precacheList} from 'state/queries/feed'
|
||||
import {useTheme} from '#/alf'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {useSession} from 'state/session'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {
|
||||
Avatar,
|
||||
Description,
|
||||
@@ -16,6 +23,7 @@ import {
|
||||
SaveButton,
|
||||
} from '#/components/FeedCard'
|
||||
import {Link as InternalLink, LinkProps} from '#/components/Link'
|
||||
import * as Hider from '#/components/moderation/Hider'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
/*
|
||||
@@ -43,6 +51,11 @@ type Props = {
|
||||
|
||||
export function Default(props: Props) {
|
||||
const {view, showPinButton} = props
|
||||
const moderationOpts = useModerationOpts()
|
||||
const moderation = moderationOpts
|
||||
? moderateUserList(view, moderationOpts)
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<Link {...props}>
|
||||
<Outer>
|
||||
@@ -52,6 +65,7 @@ export function Default(props: Props) {
|
||||
title={view.name}
|
||||
creator={view.creator}
|
||||
purpose={view.purpose}
|
||||
modUi={moderation?.ui('contentView')}
|
||||
/>
|
||||
{showPinButton && view.purpose === CURATELIST && (
|
||||
<SaveButton view={view} pin />
|
||||
@@ -89,18 +103,40 @@ export function TitleAndByline({
|
||||
title,
|
||||
creator,
|
||||
purpose = CURATELIST,
|
||||
modUi,
|
||||
}: {
|
||||
title: string
|
||||
creator?: AppBskyActorDefs.ProfileViewBasic
|
||||
purpose?: AppBskyGraphDefs.ListView['purpose']
|
||||
modUi?: ModerationUI
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {currentAccount} = useSession()
|
||||
|
||||
return (
|
||||
<View style={[a.flex_1]}>
|
||||
<Text style={[a.text_md, a.font_bold, a.leading_snug]} numberOfLines={1}>
|
||||
{title}
|
||||
</Text>
|
||||
<Hider.Outer
|
||||
modui={modUi}
|
||||
isContentVisibleInitialState={
|
||||
creator && currentAccount?.did === creator.did
|
||||
}
|
||||
allowOverride={creator && currentAccount?.did === creator.did}>
|
||||
<Hider.Mask>
|
||||
<Text
|
||||
style={[a.text_md, a.font_bold, a.leading_snug, a.italic]}
|
||||
numberOfLines={1}>
|
||||
<Trans>Hidden list</Trans>
|
||||
</Text>
|
||||
</Hider.Mask>
|
||||
<Hider.Content>
|
||||
<Text
|
||||
style={[a.text_md, a.font_bold, a.leading_snug]}
|
||||
numberOfLines={1}>
|
||||
{title}
|
||||
</Text>
|
||||
</Hider.Content>
|
||||
</Hider.Outer>
|
||||
|
||||
{creator && (
|
||||
<Text
|
||||
style={[a.leading_snug, t.atoms.text_contrast_medium]}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import React from 'react'
|
||||
|
||||
import type {ContextType} from '#/components/Menu/types'
|
||||
import type {ContextType, ItemContextType} from '#/components/Menu/types'
|
||||
|
||||
export const Context = React.createContext<ContextType>({
|
||||
// @ts-ignore
|
||||
control: null,
|
||||
})
|
||||
|
||||
export const ItemContext = React.createContext<ItemContextType>({
|
||||
disabled: false,
|
||||
})
|
||||
|
||||
@@ -9,7 +9,7 @@ import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {Context} from '#/components/Menu/context'
|
||||
import {Context, ItemContext} from '#/components/Menu/context'
|
||||
import {
|
||||
ContextType,
|
||||
GroupProps,
|
||||
@@ -125,8 +125,14 @@ export function Item({children, label, style, onPress, ...rest}: ItemProps) {
|
||||
}}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
onPressIn={onPressIn}
|
||||
onPressOut={onPressOut}
|
||||
onPressIn={e => {
|
||||
onPressIn()
|
||||
rest.onPressIn?.(e)
|
||||
}}
|
||||
onPressOut={e => {
|
||||
onPressOut()
|
||||
rest.onPressOut?.(e)
|
||||
}}
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
@@ -138,15 +144,18 @@ export function Item({children, label, style, onPress, ...rest}: ItemProps) {
|
||||
t.atoms.border_contrast_low,
|
||||
{minHeight: 44, paddingVertical: 10},
|
||||
style,
|
||||
(focused || pressed) && [t.atoms.bg_contrast_50],
|
||||
(focused || pressed) && !rest.disabled && [t.atoms.bg_contrast_50],
|
||||
]}>
|
||||
{children}
|
||||
<ItemContext.Provider value={{disabled: Boolean(rest.disabled)}}>
|
||||
{children}
|
||||
</ItemContext.Provider>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
export function ItemText({children, style}: ItemTextProps) {
|
||||
const t = useTheme()
|
||||
const {disabled} = React.useContext(ItemContext)
|
||||
return (
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
@@ -155,9 +164,10 @@ export function ItemText({children, style}: ItemTextProps) {
|
||||
a.flex_1,
|
||||
a.text_md,
|
||||
a.font_bold,
|
||||
t.atoms.text_contrast_medium,
|
||||
t.atoms.text_contrast_high,
|
||||
{paddingTop: 3},
|
||||
style,
|
||||
disabled && t.atoms.text_contrast_low,
|
||||
]}>
|
||||
{children}
|
||||
</Text>
|
||||
@@ -166,7 +176,17 @@ export function ItemText({children, style}: ItemTextProps) {
|
||||
|
||||
export function ItemIcon({icon: Comp}: ItemIconProps) {
|
||||
const t = useTheme()
|
||||
return <Comp size="lg" fill={t.atoms.text_contrast_medium.color} />
|
||||
const {disabled} = React.useContext(ItemContext)
|
||||
return (
|
||||
<Comp
|
||||
size="lg"
|
||||
fill={
|
||||
disabled
|
||||
? t.atoms.text_contrast_low.color
|
||||
: t.atoms.text_contrast_medium.color
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function Group({children, style}: GroupProps) {
|
||||
|
||||
@@ -9,7 +9,7 @@ import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
||||
import {atoms as a, flatten, useTheme, web} from '#/alf'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {Context} from '#/components/Menu/context'
|
||||
import {Context, ItemContext} from '#/components/Menu/context'
|
||||
import {
|
||||
ContextType,
|
||||
GroupProps,
|
||||
@@ -239,18 +239,21 @@ export function Item({children, label, onPress, ...rest}: ItemProps) {
|
||||
a.rounded_xs,
|
||||
{minHeight: 32, paddingHorizontal: 10},
|
||||
web({outline: 0}),
|
||||
(hovered || focused) && [
|
||||
web({outline: '0 !important'}),
|
||||
t.name === 'light'
|
||||
? t.atoms.bg_contrast_25
|
||||
: t.atoms.bg_contrast_50,
|
||||
],
|
||||
(hovered || focused) &&
|
||||
!rest.disabled && [
|
||||
web({outline: '0 !important'}),
|
||||
t.name === 'light'
|
||||
? t.atoms.bg_contrast_25
|
||||
: t.atoms.bg_contrast_50,
|
||||
],
|
||||
])}
|
||||
{...web({
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
})}>
|
||||
{children}
|
||||
<ItemContext.Provider value={{disabled: Boolean(rest.disabled)}}>
|
||||
{children}
|
||||
</ItemContext.Provider>
|
||||
</Pressable>
|
||||
</DropdownMenu.Item>
|
||||
)
|
||||
@@ -258,8 +261,16 @@ export function Item({children, label, onPress, ...rest}: ItemProps) {
|
||||
|
||||
export function ItemText({children, style}: ItemTextProps) {
|
||||
const t = useTheme()
|
||||
const {disabled} = React.useContext(ItemContext)
|
||||
return (
|
||||
<Text style={[a.flex_1, a.font_bold, t.atoms.text_contrast_high, style]}>
|
||||
<Text
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.font_bold,
|
||||
t.atoms.text_contrast_high,
|
||||
style,
|
||||
disabled && t.atoms.text_contrast_low,
|
||||
]}>
|
||||
{children}
|
||||
</Text>
|
||||
)
|
||||
@@ -267,10 +278,9 @@ export function ItemText({children, style}: ItemTextProps) {
|
||||
|
||||
export function ItemIcon({icon: Comp, position = 'left'}: ItemIconProps) {
|
||||
const t = useTheme()
|
||||
const {disabled} = React.useContext(ItemContext)
|
||||
return (
|
||||
<Comp
|
||||
size="md"
|
||||
fill={t.atoms.text_contrast_medium.color}
|
||||
<View
|
||||
style={[
|
||||
position === 'left' && {
|
||||
marginLeft: -2,
|
||||
@@ -279,8 +289,16 @@ export function ItemIcon({icon: Comp, position = 'left'}: ItemIconProps) {
|
||||
marginRight: -2,
|
||||
marginLeft: 12,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
]}>
|
||||
<Comp
|
||||
size="md"
|
||||
fill={
|
||||
disabled
|
||||
? t.atoms.text_contrast_low.color
|
||||
: t.atoms.text_contrast_medium.color
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
AccessibilityProps,
|
||||
GestureResponderEvent,
|
||||
PressableProps,
|
||||
AccessibilityProps,
|
||||
} from 'react-native'
|
||||
|
||||
import {Props as SVGIconProps} from '#/components/icons/common'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {TextStyleProp, ViewStyleProp} from '#/alf'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {Props as SVGIconProps} from '#/components/icons/common'
|
||||
|
||||
export type ContextType = {
|
||||
control: Dialog.DialogOuterProps['control']
|
||||
}
|
||||
|
||||
export type ItemContextType = {
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
export type RadixPassThroughTriggerProps = {
|
||||
id: string
|
||||
type: 'button'
|
||||
|
||||
@@ -6,7 +6,6 @@ import {useLingui} from '@lingui/react'
|
||||
|
||||
import {getLabelingServiceTitle} from '#/lib/moderation'
|
||||
import {ReportOption} from '#/lib/moderation/useReportOptions'
|
||||
import {useGate} from '#/lib/statsig/statsig'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
@@ -37,7 +36,6 @@ export function SubmitView({
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const agent = useAgent()
|
||||
const gate = useGate()
|
||||
const [details, setDetails] = React.useState<string>('')
|
||||
const [submitting, setSubmitting] = React.useState<boolean>(false)
|
||||
const [selectedServices, setSelectedServices] = React.useState<string[]>([
|
||||
@@ -63,27 +61,17 @@ export function SubmitView({
|
||||
}
|
||||
const results = await Promise.all(
|
||||
selectedServices.map(did => {
|
||||
if (gate('session_withproxy_fix')) {
|
||||
return agent
|
||||
.createModerationReport(report, {
|
||||
encoding: 'application/json',
|
||||
headers: {
|
||||
'atproto-proxy': `${did}#atproto_labeler`,
|
||||
},
|
||||
})
|
||||
.then(
|
||||
_ => true,
|
||||
_ => false,
|
||||
)
|
||||
} else {
|
||||
return agent
|
||||
.withProxy('atproto_labeler', did)
|
||||
.createModerationReport(report)
|
||||
.then(
|
||||
_ => true,
|
||||
_ => false,
|
||||
)
|
||||
}
|
||||
return agent
|
||||
.createModerationReport(report, {
|
||||
encoding: 'application/json',
|
||||
headers: {
|
||||
'atproto-proxy': `${did}#atproto_labeler`,
|
||||
},
|
||||
})
|
||||
.then(
|
||||
_ => true,
|
||||
_ => false,
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -108,7 +96,6 @@ export function SubmitView({
|
||||
onSubmitComplete,
|
||||
setError,
|
||||
agent,
|
||||
gate,
|
||||
])
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {AppBskyGraphStarterpack, AtUri} from '@atproto/api'
|
||||
import {StarterPackViewBasic} from '@atproto/api/dist/client/types/app/bsky/graph/defs'
|
||||
import {AppBskyGraphDefs, AppBskyGraphStarterpack, AtUri} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
@@ -17,7 +16,11 @@ import {StarterPack} from '#/components/icons/StarterPack'
|
||||
import {BaseLink} from '#/components/Link'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function Default({starterPack}: {starterPack?: StarterPackViewBasic}) {
|
||||
export function Default({
|
||||
starterPack,
|
||||
}: {
|
||||
starterPack?: AppBskyGraphDefs.StarterPackViewBasic
|
||||
}) {
|
||||
if (!starterPack) return null
|
||||
return (
|
||||
<Link starterPack={starterPack}>
|
||||
@@ -29,7 +32,7 @@ export function Default({starterPack}: {starterPack?: StarterPackViewBasic}) {
|
||||
export function Notification({
|
||||
starterPack,
|
||||
}: {
|
||||
starterPack?: StarterPackViewBasic
|
||||
starterPack?: AppBskyGraphDefs.StarterPackViewBasic
|
||||
}) {
|
||||
if (!starterPack) return null
|
||||
return (
|
||||
@@ -44,7 +47,7 @@ export function Card({
|
||||
noIcon,
|
||||
noDescription,
|
||||
}: {
|
||||
starterPack: StarterPackViewBasic
|
||||
starterPack: AppBskyGraphDefs.StarterPackViewBasic
|
||||
noIcon?: boolean
|
||||
noDescription?: boolean
|
||||
}) {
|
||||
@@ -94,7 +97,7 @@ export function Link({
|
||||
starterPack,
|
||||
children,
|
||||
}: {
|
||||
starterPack: StarterPackViewBasic
|
||||
starterPack: AppBskyGraphDefs.StarterPackViewBasic
|
||||
onPress?: () => void
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
@@ -129,7 +132,11 @@ export function Link({
|
||||
)
|
||||
}
|
||||
|
||||
export function Embed({starterPack}: {starterPack: StarterPackViewBasic}) {
|
||||
export function Embed({
|
||||
starterPack,
|
||||
}: {
|
||||
starterPack: AppBskyGraphDefs.StarterPackViewBasic
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const imageUri = getStarterPackOgCard(starterPack)
|
||||
|
||||
|
||||
@@ -249,7 +249,9 @@ function DialogError({details}: {details?: string}) {
|
||||
const control = Dialog.useDialogContext()
|
||||
|
||||
return (
|
||||
<Dialog.ScrollableInner style={a.gap_md} label={_(msg`An error occured`)}>
|
||||
<Dialog.ScrollableInner
|
||||
style={a.gap_md}
|
||||
label={_(msg`An error has occurred`)}>
|
||||
<Dialog.Close />
|
||||
<ErrorScreen
|
||||
title={_(msg`Oh no!`)}
|
||||
|
||||
@@ -193,6 +193,7 @@ export function createInput(Component: typeof TextInput) {
|
||||
lineHeight: a.text_md.fontSize * 1.1875,
|
||||
textAlignVertical: rest.multiline ? 'top' : undefined,
|
||||
minHeight: rest.multiline ? 80 : undefined,
|
||||
minWidth: 0,
|
||||
},
|
||||
// fix for autofill styles covering border
|
||||
web({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react'
|
||||
import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
|
||||
import {StyleProp, View, ViewStyle} from 'react-native'
|
||||
import {ModerationUI} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
@@ -40,7 +40,7 @@ export function ContentHider({
|
||||
|
||||
if (!blur || (ignoreMute && isJustAMute(modui))) {
|
||||
return (
|
||||
<View testID={testID} style={[styles.outer, style]}>
|
||||
<View testID={testID} style={style}>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
@@ -163,21 +163,3 @@ export function ContentHider({
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
outer: {},
|
||||
cover: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
borderRadius: 8,
|
||||
marginTop: 4,
|
||||
paddingVertical: 14,
|
||||
paddingLeft: 14,
|
||||
paddingRight: 18,
|
||||
},
|
||||
showBtn: {
|
||||
marginLeft: 'auto',
|
||||
alignSelf: 'center',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import React from 'react'
|
||||
import {ModerationUI} from '@atproto/api'
|
||||
|
||||
import {
|
||||
ModerationCauseDescription,
|
||||
useModerationCauseDescription,
|
||||
} from '#/lib/moderation/useModerationCauseDescription'
|
||||
import {
|
||||
ModerationDetailsDialog,
|
||||
useModerationDetailsDialogControl,
|
||||
} from '#/components/moderation/ModerationDetailsDialog'
|
||||
|
||||
type Context = {
|
||||
isContentVisible: boolean
|
||||
setIsContentVisible: (show: boolean) => void
|
||||
info: ModerationCauseDescription
|
||||
showInfoDialog: () => void
|
||||
meta: {
|
||||
isNoPwi: boolean
|
||||
allowOverride: boolean
|
||||
}
|
||||
}
|
||||
|
||||
const Context = React.createContext<Context>({} as Context)
|
||||
|
||||
export const useHider = () => React.useContext(Context)
|
||||
|
||||
export function Outer({
|
||||
modui,
|
||||
isContentVisibleInitialState,
|
||||
allowOverride,
|
||||
children,
|
||||
}: React.PropsWithChildren<{
|
||||
isContentVisibleInitialState?: boolean
|
||||
allowOverride?: boolean
|
||||
modui: ModerationUI | undefined
|
||||
}>) {
|
||||
const control = useModerationDetailsDialogControl()
|
||||
const blur = modui?.blurs[0]
|
||||
const [isContentVisible, setIsContentVisible] = React.useState(
|
||||
isContentVisibleInitialState || !blur,
|
||||
)
|
||||
const info = useModerationCauseDescription(blur)
|
||||
|
||||
const meta = {
|
||||
isNoPwi: Boolean(
|
||||
modui?.blurs.find(
|
||||
cause =>
|
||||
cause.type === 'label' &&
|
||||
cause.labelDef.identifier === '!no-unauthenticated',
|
||||
),
|
||||
),
|
||||
allowOverride: allowOverride ?? !modui?.noOverride,
|
||||
}
|
||||
|
||||
const showInfoDialog = () => {
|
||||
control.open()
|
||||
}
|
||||
|
||||
const onSetContentVisible = (show: boolean) => {
|
||||
if (meta.allowOverride) return
|
||||
setIsContentVisible(show)
|
||||
}
|
||||
|
||||
const ctx = {
|
||||
isContentVisible,
|
||||
setIsContentVisible: onSetContentVisible,
|
||||
showInfoDialog,
|
||||
info,
|
||||
meta,
|
||||
}
|
||||
|
||||
return (
|
||||
<Context.Provider value={ctx}>
|
||||
{children}
|
||||
<ModerationDetailsDialog control={control} modcause={blur} />
|
||||
</Context.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function Content({children}: {children: React.ReactNode}) {
|
||||
const ctx = useHider()
|
||||
return ctx.isContentVisible ? children : null
|
||||
}
|
||||
|
||||
export function Mask({children}: {children: React.ReactNode}) {
|
||||
const ctx = useHider()
|
||||
return ctx.isContentVisible ? null : children
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import {useMutation} from '@tanstack/react-query'
|
||||
|
||||
import {useLabelInfo} from '#/lib/moderation/useLabelInfo'
|
||||
import {makeProfileLink} from '#/lib/routes/links'
|
||||
import {useGate} from '#/lib/statsig/statsig'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {logger} from '#/logger'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
@@ -204,7 +203,6 @@ function AppealForm({
|
||||
const [details, setDetails] = React.useState('')
|
||||
const isAccountReport = 'did' in subject
|
||||
const agent = useAgent()
|
||||
const gate = useGate()
|
||||
const sourceName = labeler
|
||||
? sanitizeHandle(labeler.creator.handle, '@')
|
||||
: label.src
|
||||
@@ -214,35 +212,22 @@ function AppealForm({
|
||||
const $type = !isAccountReport
|
||||
? 'com.atproto.repo.strongRef'
|
||||
: 'com.atproto.admin.defs#repoRef'
|
||||
if (gate('session_withproxy_fix')) {
|
||||
await agent.createModerationReport(
|
||||
{
|
||||
reasonType: ComAtprotoModerationDefs.REASONAPPEAL,
|
||||
subject: {
|
||||
$type,
|
||||
...subject,
|
||||
},
|
||||
reason: details,
|
||||
await agent.createModerationReport(
|
||||
{
|
||||
reasonType: ComAtprotoModerationDefs.REASONAPPEAL,
|
||||
subject: {
|
||||
$type,
|
||||
...subject,
|
||||
},
|
||||
{
|
||||
encoding: 'application/json',
|
||||
headers: {
|
||||
'atproto-proxy': `${label.src}#atproto_labeler`,
|
||||
},
|
||||
reason: details,
|
||||
},
|
||||
{
|
||||
encoding: 'application/json',
|
||||
headers: {
|
||||
'atproto-proxy': `${label.src}#atproto_labeler`,
|
||||
},
|
||||
)
|
||||
} else {
|
||||
await agent
|
||||
.withProxy('atproto_labeler', label.src)
|
||||
.createModerationReport({
|
||||
reasonType: ComAtprotoModerationDefs.REASONAPPEAL,
|
||||
subject: {
|
||||
$type,
|
||||
...subject,
|
||||
},
|
||||
reason: details,
|
||||
})
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
onError: err => {
|
||||
logger.error('Failed to submit label appeal', {message: err})
|
||||
|
||||
@@ -18,7 +18,7 @@ export {useDialogControl as useModerationDetailsDialogControl} from '#/component
|
||||
|
||||
export interface ModerationDetailsDialogProps {
|
||||
control: Dialog.DialogOuterProps['control']
|
||||
modcause: ModerationCause
|
||||
modcause?: ModerationCause
|
||||
}
|
||||
|
||||
export function ModerationDetailsDialog(props: ModerationDetailsDialogProps) {
|
||||
@@ -123,7 +123,7 @@ function ModerationDetailsDialogInner({
|
||||
{description}
|
||||
</Text>
|
||||
|
||||
{modcause.type === 'label' && (
|
||||
{modcause?.type === 'label' && (
|
||||
<>
|
||||
<Divider />
|
||||
<Text style={[t.atoms.text, a.text_md, a.leading_snug, a.mt_lg]}>
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
import RNFS from 'react-native-fs'
|
||||
import {BskyAgent, jsonToLex, stringifyLex} from '@atproto/api'
|
||||
|
||||
const GET_TIMEOUT = 15e3 // 15s
|
||||
const POST_TIMEOUT = 60e3 // 60s
|
||||
|
||||
export function doPolyfill() {
|
||||
BskyAgent.configure({fetch: fetchHandler})
|
||||
}
|
||||
|
||||
interface FetchHandlerResponse {
|
||||
status: number
|
||||
headers: Record<string, string>
|
||||
body: any
|
||||
}
|
||||
|
||||
async function fetchHandler(
|
||||
reqUri: string,
|
||||
reqMethod: string,
|
||||
reqHeaders: Record<string, string>,
|
||||
reqBody: any,
|
||||
): Promise<FetchHandlerResponse> {
|
||||
const reqMimeType = reqHeaders['Content-Type'] || reqHeaders['content-type']
|
||||
if (reqMimeType && reqMimeType.startsWith('application/json')) {
|
||||
reqBody = stringifyLex(reqBody)
|
||||
} else if (
|
||||
typeof reqBody === 'string' &&
|
||||
(reqBody.startsWith('/') || reqBody.startsWith('file:'))
|
||||
) {
|
||||
if (reqBody.endsWith('.jpeg') || reqBody.endsWith('.jpg')) {
|
||||
// HACK
|
||||
// React native has a bug that inflates the size of jpegs on upload
|
||||
// we get around that by renaming the file ext to .bin
|
||||
// see https://github.com/facebook/react-native/issues/27099
|
||||
// -prf
|
||||
const newPath = reqBody.replace(/\.jpe?g$/, '.bin')
|
||||
await RNFS.moveFile(reqBody, newPath)
|
||||
reqBody = newPath
|
||||
}
|
||||
// NOTE
|
||||
// React native treats bodies with {uri: string} as file uploads to pull from cache
|
||||
// -prf
|
||||
reqBody = {uri: reqBody}
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const to = setTimeout(
|
||||
() => controller.abort(),
|
||||
reqMethod === 'post' ? POST_TIMEOUT : GET_TIMEOUT,
|
||||
)
|
||||
|
||||
const res = await fetch(reqUri, {
|
||||
method: reqMethod,
|
||||
headers: reqHeaders,
|
||||
body: reqBody,
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
const resStatus = res.status
|
||||
const resHeaders: Record<string, string> = {}
|
||||
res.headers.forEach((value: string, key: string) => {
|
||||
resHeaders[key] = value
|
||||
})
|
||||
const resMimeType = resHeaders['Content-Type'] || resHeaders['content-type']
|
||||
let resBody
|
||||
if (resMimeType) {
|
||||
if (resMimeType.startsWith('application/json')) {
|
||||
resBody = jsonToLex(await res.json())
|
||||
} else if (resMimeType.startsWith('text/')) {
|
||||
resBody = await res.text()
|
||||
} else if (resMimeType === 'application/vnd.ipld.car') {
|
||||
resBody = await res.arrayBuffer()
|
||||
} else {
|
||||
throw new Error('Non-supported mime type')
|
||||
}
|
||||
}
|
||||
|
||||
clearTimeout(to)
|
||||
|
||||
return {
|
||||
status: resStatus,
|
||||
headers: resHeaders,
|
||||
body: resBody,
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export function doPolyfill() {
|
||||
// no polyfill is needed on web
|
||||
}
|
||||
@@ -23,6 +23,7 @@ type FeedSliceItem = {
|
||||
record: AppBskyFeedPost.Record
|
||||
parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
|
||||
isParentBlocked: boolean
|
||||
isParentNotFound: boolean
|
||||
}
|
||||
|
||||
type AuthorContext = {
|
||||
@@ -68,6 +69,7 @@ export class FeedViewPostsSlice {
|
||||
}
|
||||
const parent = reply?.parent
|
||||
const isParentBlocked = AppBskyFeedDefs.isBlockedPost(parent)
|
||||
const isParentNotFound = AppBskyFeedDefs.isNotFoundPost(parent)
|
||||
let parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
|
||||
if (AppBskyFeedDefs.isPostView(parent)) {
|
||||
parentAuthor = parent.author
|
||||
@@ -77,6 +79,7 @@ export class FeedViewPostsSlice {
|
||||
record: post.record,
|
||||
parentAuthor,
|
||||
isParentBlocked,
|
||||
isParentNotFound,
|
||||
})
|
||||
if (!reply || reason) {
|
||||
return
|
||||
@@ -89,23 +92,40 @@ export class FeedViewPostsSlice {
|
||||
this.isOrphan = true
|
||||
return
|
||||
}
|
||||
const root = reply.root
|
||||
const rootIsView =
|
||||
AppBskyFeedDefs.isPostView(root) ||
|
||||
AppBskyFeedDefs.isBlockedPost(root) ||
|
||||
AppBskyFeedDefs.isNotFoundPost(root)
|
||||
/*
|
||||
* If the parent is also the root, we just so happen to have the data we
|
||||
* need to compute if the parent's parent (grandparent) is blocked. This
|
||||
* doesn't always happen, of course, but we can take advantage of it when
|
||||
* it does.
|
||||
*/
|
||||
const grandparent =
|
||||
rootIsView && parent.record.reply?.parent.uri === root.uri
|
||||
? root
|
||||
: undefined
|
||||
const grandparentAuthor = reply.grandparentAuthor
|
||||
const isGrandparentBlocked = Boolean(
|
||||
grandparentAuthor?.viewer?.blockedBy ||
|
||||
grandparentAuthor?.viewer?.blocking ||
|
||||
grandparentAuthor?.viewer?.blockingByList,
|
||||
grandparent && AppBskyFeedDefs.isBlockedPost(grandparent),
|
||||
)
|
||||
const isGrandparentNotFound = Boolean(
|
||||
grandparent && AppBskyFeedDefs.isNotFoundPost(grandparent),
|
||||
)
|
||||
this.items.unshift({
|
||||
post: parent,
|
||||
record: parent.record,
|
||||
parentAuthor: grandparentAuthor,
|
||||
isParentBlocked: isGrandparentBlocked,
|
||||
isParentNotFound: isGrandparentNotFound,
|
||||
})
|
||||
if (isGrandparentBlocked) {
|
||||
this.isOrphan = true
|
||||
// Keep going, it might still have a root.
|
||||
// Keep going, it might still have a root, and we need this for thread
|
||||
// de-deduping
|
||||
}
|
||||
const root = reply.root
|
||||
if (
|
||||
!AppBskyFeedDefs.isPostView(root) ||
|
||||
!AppBskyFeedPost.isRecord(root.record) ||
|
||||
@@ -121,6 +141,7 @@ export class FeedViewPostsSlice {
|
||||
post: root,
|
||||
record: root.record,
|
||||
isParentBlocked: false,
|
||||
isParentNotFound: false,
|
||||
parentAuthor: undefined,
|
||||
})
|
||||
if (parent.record.reply?.parent.uri !== root.uri) {
|
||||
|
||||
+11
-14
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
AppBskyFeedDefs,
|
||||
AppBskyFeedGetFeed as GetCustomFeed,
|
||||
AtpAgent,
|
||||
BskyAgent,
|
||||
} from '@atproto/api'
|
||||
|
||||
@@ -51,7 +50,7 @@ export class CustomFeedAPI implements FeedAPI {
|
||||
const agent = this.agent
|
||||
const isBlueskyOwned = isBlueskyOwnedFeed(this.params.feed)
|
||||
|
||||
const res = agent.session
|
||||
const res = agent.did
|
||||
? await this.agent.app.bsky.feed.getFeed(
|
||||
{
|
||||
...this.params,
|
||||
@@ -106,34 +105,32 @@ async function loggedOutFetch({
|
||||
let contentLangs = getContentLanguages().join(',')
|
||||
|
||||
// manually construct fetch call so we can add the `lang` cache-busting param
|
||||
let res = await AtpAgent.fetch!(
|
||||
let res = await fetch(
|
||||
`https://api.bsky.app/xrpc/app.bsky.feed.getFeed?feed=${feed}${
|
||||
cursor ? `&cursor=${cursor}` : ''
|
||||
}&limit=${limit}&lang=${contentLangs}`,
|
||||
'GET',
|
||||
{'Accept-Language': contentLangs},
|
||||
undefined,
|
||||
{method: 'GET', headers: {'Accept-Language': contentLangs}},
|
||||
)
|
||||
if (res.body?.feed?.length) {
|
||||
let data = res.ok ? await res.json() : null
|
||||
if (data?.feed?.length) {
|
||||
return {
|
||||
success: true,
|
||||
data: res.body,
|
||||
data,
|
||||
}
|
||||
}
|
||||
|
||||
// no data, try again with language headers removed
|
||||
res = await AtpAgent.fetch!(
|
||||
res = await fetch(
|
||||
`https://api.bsky.app/xrpc/app.bsky.feed.getFeed?feed=${feed}${
|
||||
cursor ? `&cursor=${cursor}` : ''
|
||||
}&limit=${limit}`,
|
||||
'GET',
|
||||
{'Accept-Language': ''},
|
||||
undefined,
|
||||
{method: 'GET', headers: {'Accept-Language': ''}},
|
||||
)
|
||||
if (res.body?.feed?.length) {
|
||||
data = res.ok ? await res.json() : null
|
||||
if (data?.feed?.length) {
|
||||
return {
|
||||
success: true,
|
||||
data: res.body,
|
||||
data,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+5
-34
@@ -6,7 +6,6 @@ import {
|
||||
AppBskyFeedThreadgate,
|
||||
BskyAgent,
|
||||
ComAtprotoLabelDefs,
|
||||
ComAtprotoRepoUploadBlob,
|
||||
RichText,
|
||||
} from '@atproto/api'
|
||||
import {AtUri} from '@atproto/api'
|
||||
@@ -15,10 +14,13 @@ import {logger} from '#/logger'
|
||||
import {ThreadgateSetting} from '#/state/queries/threadgate'
|
||||
import {isNetworkError} from 'lib/strings/errors'
|
||||
import {shortenLinks, stripInvalidMentions} from 'lib/strings/rich-text-manip'
|
||||
import {isNative, isWeb} from 'platform/detection'
|
||||
import {isNative} from 'platform/detection'
|
||||
import {ImageModel} from 'state/models/media/image'
|
||||
import {LinkMeta} from '../link-meta/link-meta'
|
||||
import {safeDeleteAsync} from '../media/manip'
|
||||
import {uploadBlob} from './upload-blob'
|
||||
|
||||
export {uploadBlob}
|
||||
|
||||
export interface ExternalEmbedDraft {
|
||||
uri: string
|
||||
@@ -28,25 +30,6 @@ export interface ExternalEmbedDraft {
|
||||
localThumb?: ImageModel
|
||||
}
|
||||
|
||||
export async function uploadBlob(
|
||||
agent: BskyAgent,
|
||||
blob: string,
|
||||
encoding: string,
|
||||
): Promise<ComAtprotoRepoUploadBlob.Response> {
|
||||
if (isWeb) {
|
||||
// `blob` should be a data uri
|
||||
return agent.uploadBlob(convertDataURIToUint8Array(blob), {
|
||||
encoding,
|
||||
})
|
||||
} else {
|
||||
// `blob` should be a path to a file in the local FS
|
||||
return agent.uploadBlob(
|
||||
blob, // this will be special-cased by the fetch monkeypatch in /src/state/lib/api.ts
|
||||
{encoding},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
interface PostOpts {
|
||||
rawText: string
|
||||
replyTo?: string
|
||||
@@ -301,7 +284,7 @@ export async function createThreadgate(
|
||||
|
||||
const postUrip = new AtUri(postUri)
|
||||
await agent.api.com.atproto.repo.putRecord({
|
||||
repo: agent.session!.did,
|
||||
repo: agent.accountDid,
|
||||
collection: 'app.bsky.feed.threadgate',
|
||||
rkey: postUrip.rkey,
|
||||
record: {
|
||||
@@ -312,15 +295,3 @@ export async function createThreadgate(
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// helpers
|
||||
// =
|
||||
|
||||
function convertDataURIToUint8Array(uri: string): Uint8Array {
|
||||
var raw = window.atob(uri.substring(uri.indexOf(';base64,') + 8))
|
||||
var binary = new Uint8Array(new ArrayBuffer(raw.length))
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
binary[i] = raw.charCodeAt(i)
|
||||
}
|
||||
return binary
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import RNFS from 'react-native-fs'
|
||||
import {BskyAgent, ComAtprotoRepoUploadBlob} from '@atproto/api'
|
||||
|
||||
/**
|
||||
* @param encoding Allows overriding the blob's type
|
||||
*/
|
||||
export async function uploadBlob(
|
||||
agent: BskyAgent,
|
||||
input: string | Blob,
|
||||
encoding?: string,
|
||||
): Promise<ComAtprotoRepoUploadBlob.Response> {
|
||||
if (typeof input === 'string' && input.startsWith('file:')) {
|
||||
const blob = await asBlob(input)
|
||||
return agent.uploadBlob(blob, {encoding})
|
||||
}
|
||||
|
||||
if (typeof input === 'string' && input.startsWith('/')) {
|
||||
const blob = await asBlob(`file://${input}`)
|
||||
return agent.uploadBlob(blob, {encoding})
|
||||
}
|
||||
|
||||
if (typeof input === 'string' && input.startsWith('data:')) {
|
||||
const blob = await fetch(input).then(r => r.blob())
|
||||
return agent.uploadBlob(blob, {encoding})
|
||||
}
|
||||
|
||||
if (input instanceof Blob) {
|
||||
return agent.uploadBlob(input, {encoding})
|
||||
}
|
||||
|
||||
throw new TypeError(`Invalid uploadBlob input: ${typeof input}`)
|
||||
}
|
||||
|
||||
async function asBlob(uri: string): Promise<Blob> {
|
||||
return withSafeFile(uri, async safeUri => {
|
||||
// Note
|
||||
// Android does not support `fetch()` on `file://` URIs. for this reason, we
|
||||
// use XMLHttpRequest instead of simply calling:
|
||||
|
||||
// return fetch(safeUri.replace('file:///', 'file:/')).then(r => r.blob())
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest()
|
||||
xhr.onload = () => resolve(xhr.response)
|
||||
xhr.onerror = () => reject(new Error('Failed to load blob'))
|
||||
xhr.responseType = 'blob'
|
||||
xhr.open('GET', safeUri, true)
|
||||
xhr.send(null)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// HACK
|
||||
// React native has a bug that inflates the size of jpegs on upload
|
||||
// we get around that by renaming the file ext to .bin
|
||||
// see https://github.com/facebook/react-native/issues/27099
|
||||
// -prf
|
||||
async function withSafeFile<T>(
|
||||
uri: string,
|
||||
fn: (path: string) => Promise<T>,
|
||||
): Promise<T> {
|
||||
if (uri.endsWith('.jpeg') || uri.endsWith('.jpg')) {
|
||||
// Since we don't "own" the file, we should avoid renaming or modifying it.
|
||||
// Instead, let's copy it to a temporary file and use that (then remove the
|
||||
// temporary file).
|
||||
const newPath = uri.replace(/\.jpe?g$/, '.bin')
|
||||
try {
|
||||
await RNFS.copyFile(uri, newPath)
|
||||
} catch {
|
||||
// Failed to copy the file, just use the original
|
||||
return await fn(uri)
|
||||
}
|
||||
try {
|
||||
return await fn(newPath)
|
||||
} finally {
|
||||
// Remove the temporary file
|
||||
await RNFS.unlink(newPath)
|
||||
}
|
||||
} else {
|
||||
return fn(uri)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import {BskyAgent, ComAtprotoRepoUploadBlob} from '@atproto/api'
|
||||
|
||||
/**
|
||||
* @note It is recommended, on web, to use the `file` instance of the file
|
||||
* selector input element, rather than a `data:` URL, to avoid
|
||||
* loading the file into memory. `File` extends `Blob` "file" instances can
|
||||
* be passed directly to this function.
|
||||
*/
|
||||
export async function uploadBlob(
|
||||
agent: BskyAgent,
|
||||
input: string | Blob,
|
||||
encoding?: string,
|
||||
): Promise<ComAtprotoRepoUploadBlob.Response> {
|
||||
if (typeof input === 'string' && input.startsWith('data:')) {
|
||||
const blob = await fetch(input).then(r => r.blob())
|
||||
return agent.uploadBlob(blob, {encoding})
|
||||
}
|
||||
|
||||
if (input instanceof Blob) {
|
||||
return agent.uploadBlob(input, {
|
||||
encoding,
|
||||
})
|
||||
}
|
||||
|
||||
throw new TypeError(`Invalid uploadBlob input: ${typeof input}`)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import {StackActions, useNavigation} from '@react-navigation/native'
|
||||
|
||||
import {NavigationProp} from 'lib/routes/types'
|
||||
import {router} from '#/routes'
|
||||
|
||||
export function useGoBack(onGoBack?: () => unknown) {
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
return () => {
|
||||
onGoBack?.()
|
||||
if (navigation.canGoBack()) {
|
||||
navigation.goBack()
|
||||
} else {
|
||||
navigation.navigate('HomeTab')
|
||||
// Checking the state for routes ensures that web doesn't encounter errors while going back
|
||||
if (navigation.getState()?.routes) {
|
||||
navigation.dispatch(StackActions.push(...router.matchPath('/')))
|
||||
} else {
|
||||
navigation.navigate('HomeTab')
|
||||
navigation.dispatch(StackActions.popToTop())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import {interpolate, useAnimatedStyle} from 'react-native-reanimated'
|
||||
|
||||
import {useMinimalShellMode} from '#/state/shell/minimal-mode'
|
||||
import {useShellLayout} from '#/state/shell/shell-layout'
|
||||
import {useGate} from '../statsig/statsig'
|
||||
|
||||
// Keep these separated so that we only pay for useAnimatedStyle that gets used.
|
||||
|
||||
@@ -27,8 +28,13 @@ export function useMinimalShellHeaderTransform() {
|
||||
export function useMinimalShellFooterTransform() {
|
||||
const mode = useMinimalShellMode()
|
||||
const {footerHeight} = useShellLayout()
|
||||
const gate = useGate()
|
||||
const isFixedBottomBar = gate('fixed_bottom_bar')
|
||||
|
||||
const footerTransform = useAnimatedStyle(() => {
|
||||
if (isFixedBottomBar) {
|
||||
return {}
|
||||
}
|
||||
return {
|
||||
pointerEvents: mode.value === 0 ? 'auto' : 'none',
|
||||
opacity: Math.pow(1 - mode.value, 2),
|
||||
@@ -39,13 +45,25 @@ export function useMinimalShellFooterTransform() {
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
return footerTransform
|
||||
}
|
||||
|
||||
export function useMinimalShellFabTransform() {
|
||||
const mode = useMinimalShellMode()
|
||||
const gate = useGate()
|
||||
const isFixedBottomBar = gate('fixed_bottom_bar')
|
||||
|
||||
const fabTransform = useAnimatedStyle(() => {
|
||||
if (isFixedBottomBar) {
|
||||
return {
|
||||
transform: [
|
||||
{
|
||||
translateY: -44,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
return {
|
||||
transform: [
|
||||
{
|
||||
|
||||
@@ -218,13 +218,7 @@ export async function safeDeleteAsync(path: string) {
|
||||
// Normalize is necessary for Android, otherwise it doesn't delete.
|
||||
const normalizedPath = normalizePath(path)
|
||||
try {
|
||||
await Promise.allSettled([
|
||||
deleteAsync(normalizedPath, {idempotent: true}),
|
||||
// HACK: Try this one too. Might exist due to api-polyfill hack.
|
||||
deleteAsync(normalizedPath.replace(/\.jpe?g$/, '.bin'), {
|
||||
idempotent: true,
|
||||
}),
|
||||
])
|
||||
await deleteAsync(normalizedPath, {idempotent: true})
|
||||
} catch (e) {
|
||||
console.error('Failed to delete file', e)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import {BskyAgent} from '@atproto/api'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {SessionAccount, useAgent, useSession} from '#/state/session'
|
||||
import {logEvent, useGate} from 'lib/statsig/statsig'
|
||||
import {logEvent} from 'lib/statsig/statsig'
|
||||
import {devicePlatform, isAndroid, isNative} from 'platform/detection'
|
||||
import BackgroundNotificationHandler from '../../../modules/expo-background-notification-handler'
|
||||
|
||||
@@ -86,7 +86,6 @@ export function useNotificationsRegistration() {
|
||||
}
|
||||
|
||||
export function useRequestNotificationsPermission() {
|
||||
const gate = useGate()
|
||||
const {currentAccount} = useSession()
|
||||
const agent = useAgent()
|
||||
|
||||
@@ -102,16 +101,7 @@ export function useRequestNotificationsPermission() {
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
context === 'StartOnboarding' &&
|
||||
gate('request_notifications_permission_after_onboarding_v2')
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
context === 'AfterOnboarding' &&
|
||||
!gate('request_notifications_permission_after_onboarding_v2')
|
||||
) {
|
||||
if (context === 'AfterOnboarding') {
|
||||
return
|
||||
}
|
||||
if (context === 'Home' && !currentAccount) {
|
||||
|
||||
+66
-1
@@ -2,18 +2,83 @@ import React, {useRef, useState} from 'react'
|
||||
import {AppState, AppStateStatus} from 'react-native'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister'
|
||||
import {focusManager, QueryClient} from '@tanstack/react-query'
|
||||
import {focusManager, onlineManager, QueryClient} from '@tanstack/react-query'
|
||||
import {
|
||||
PersistQueryClientProvider,
|
||||
PersistQueryClientProviderProps,
|
||||
} from '@tanstack/react-query-persist-client'
|
||||
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {listenNetworkConfirmed, listenNetworkLost} from '#/state/events'
|
||||
|
||||
// any query keys in this array will be persisted to AsyncStorage
|
||||
export const labelersDetailedInfoQueryKeyRoot = 'labelers-detailed-info'
|
||||
const STORED_CACHE_QUERY_KEY_ROOTS = [labelersDetailedInfoQueryKeyRoot]
|
||||
|
||||
async function checkIsOnline(): Promise<boolean> {
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
setTimeout(() => {
|
||||
controller.abort()
|
||||
}, 15e3)
|
||||
const res = await fetch('https://public.api.bsky.app/xrpc/_health', {
|
||||
cache: 'no-store',
|
||||
signal: controller.signal,
|
||||
})
|
||||
const json = await res.json()
|
||||
if (json.version) {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
} catch (e) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
let receivedNetworkLost = false
|
||||
let receivedNetworkConfirmed = false
|
||||
let isNetworkStateUnclear = false
|
||||
|
||||
listenNetworkLost(() => {
|
||||
receivedNetworkLost = true
|
||||
onlineManager.setOnline(false)
|
||||
})
|
||||
|
||||
listenNetworkConfirmed(() => {
|
||||
receivedNetworkConfirmed = true
|
||||
onlineManager.setOnline(true)
|
||||
})
|
||||
|
||||
let checkPromise: Promise<void> | undefined
|
||||
function checkIsOnlineIfNeeded() {
|
||||
if (checkPromise) {
|
||||
return
|
||||
}
|
||||
receivedNetworkLost = false
|
||||
receivedNetworkConfirmed = false
|
||||
checkPromise = checkIsOnline().then(nextIsOnline => {
|
||||
checkPromise = undefined
|
||||
if (nextIsOnline && receivedNetworkLost) {
|
||||
isNetworkStateUnclear = true
|
||||
}
|
||||
if (!nextIsOnline && receivedNetworkConfirmed) {
|
||||
isNetworkStateUnclear = true
|
||||
}
|
||||
if (!isNetworkStateUnclear) {
|
||||
onlineManager.setOnline(nextIsOnline)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
setInterval(() => {
|
||||
if (AppState.currentState === 'active') {
|
||||
if (!onlineManager.isOnline() || isNetworkStateUnclear) {
|
||||
checkIsOnlineIfNeeded()
|
||||
}
|
||||
}
|
||||
}, 2000)
|
||||
|
||||
focusManager.setEventListener(onFocus => {
|
||||
if (isNative) {
|
||||
const subscription = AppState.addEventListener(
|
||||
|
||||
@@ -14,6 +14,7 @@ export type LogEvents = {
|
||||
}
|
||||
'account:loggedOut': {
|
||||
logContext: 'SwitchAccount' | 'Settings' | 'SignupQueued' | 'Deactivated'
|
||||
scope: 'current' | 'every'
|
||||
}
|
||||
'notifications:openApp': {}
|
||||
'notifications:request': {
|
||||
|
||||
@@ -2,18 +2,10 @@ export type Gate =
|
||||
// Keep this alphabetic please.
|
||||
| 'auto_show_prof_sugg_follows'
|
||||
| 'debug_show_feedcontext'
|
||||
| 'explore_page_profile_card_social_proof'
|
||||
| 'native_pwi_disabled'
|
||||
| 'fixed_bottom_bar'
|
||||
| 'new_user_guided_tour'
|
||||
| 'new_user_progress_guide'
|
||||
| 'onboarding_minimum_interests'
|
||||
| 'request_notifications_permission_after_onboarding_v2'
|
||||
| 'session_withproxy_fix'
|
||||
| 'show_avi_follow_button'
|
||||
| 'show_follow_back_label_v2'
|
||||
| 'suggested_feeds_interstitial'
|
||||
| 'suggested_follows_interstitial'
|
||||
| 'ungroup_follow_backs'
|
||||
| 'video_debug'
|
||||
| 'videos'
|
||||
| 'small_avi_thumb'
|
||||
|
||||
@@ -103,16 +103,21 @@ export function parseEmbedPlayerFromUrl(
|
||||
urlp.hostname === 'm.youtube.com' ||
|
||||
urlp.hostname === 'music.youtube.com'
|
||||
) {
|
||||
const [_, page, shortVideoId] = urlp.pathname.split('/')
|
||||
const [_, page, shortOrLiveVideoId] = urlp.pathname.split('/')
|
||||
|
||||
const isShorts = page === 'shorts'
|
||||
const isLive = page === 'live'
|
||||
const videoId =
|
||||
page === 'shorts' ? shortVideoId : (urlp.searchParams.get('v') as string)
|
||||
isShorts || isLive
|
||||
? shortOrLiveVideoId
|
||||
: (urlp.searchParams.get('v') as string)
|
||||
const seek = encodeURIComponent(urlp.searchParams.get('t') ?? 0)
|
||||
|
||||
if (videoId) {
|
||||
return {
|
||||
type: page === 'shorts' ? 'youtube_short' : 'youtube_video',
|
||||
source: page === 'shorts' ? 'youtubeShorts' : 'youtube',
|
||||
hideDetails: page === 'shorts' ? true : undefined,
|
||||
type: isShorts ? 'youtube_short' : 'youtube_video',
|
||||
source: isShorts ? 'youtubeShorts' : 'youtube',
|
||||
hideDetails: isShorts ? true : undefined,
|
||||
playerUri: `${IFRAME_HOST}/iframe/youtube.html?videoId=${videoId}&start=${seek}`,
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
import {Dimensions, StyleProp, StyleSheet, TextStyle} from 'react-native'
|
||||
import {Theme, TypographyVariant} from './ThemeContext'
|
||||
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {Theme, TypographyVariant} from './ThemeContext'
|
||||
|
||||
// 1 is lightest, 2 is light, 3 is mid, 4 is dark, 5 is darkest
|
||||
export const colors = {
|
||||
|
||||
+3
-3
@@ -295,9 +295,9 @@ export const darkTheme: Theme = {
|
||||
...defaultTheme.palette,
|
||||
default: {
|
||||
background: darkPalette.black,
|
||||
backgroundLight: darkPalette.contrast_50,
|
||||
backgroundLight: darkPalette.contrast_25,
|
||||
text: darkPalette.white,
|
||||
textLight: darkPalette.contrast_700,
|
||||
textLight: darkPalette.contrast_600,
|
||||
textInverted: darkPalette.black,
|
||||
link: darkPalette.primary_500,
|
||||
border: darkPalette.contrast_100,
|
||||
@@ -344,7 +344,7 @@ export const dimTheme: Theme = {
|
||||
default: {
|
||||
...darkTheme.palette.default,
|
||||
background: dimPalette.black,
|
||||
backgroundLight: dimPalette.contrast_50,
|
||||
backgroundLight: dimPalette.contrast_25,
|
||||
text: dimPalette.white,
|
||||
textLight: dimPalette.contrast_700,
|
||||
textInverted: dimPalette.black,
|
||||
|
||||
@@ -38,7 +38,7 @@ export function Deactivated() {
|
||||
const {setShowLoggedOut} = useLoggedOutViewControls()
|
||||
const hasOtherAccounts = accounts.length > 1
|
||||
const setMinimalShellMode = useSetMinimalShellMode()
|
||||
const {logout} = useSessionApi()
|
||||
const {logoutCurrentAccount} = useSessionApi()
|
||||
const agent = useAgent()
|
||||
const [pending, setPending] = React.useState(false)
|
||||
const [error, setError] = React.useState<string | undefined>()
|
||||
@@ -72,8 +72,8 @@ export function Deactivated() {
|
||||
// So we change the URL ourselves. The navigator will pick it up on remount.
|
||||
history.pushState(null, '', '/')
|
||||
}
|
||||
logout('Deactivated')
|
||||
}, [logout])
|
||||
logoutCurrentAccount('Deactivated')
|
||||
}, [logoutCurrentAccount])
|
||||
|
||||
const handleActivate = React.useCallback(async () => {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {AppBskyGraphDefs} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {RQKEY_ROOT as listQueryRoot} from '#/state/queries/list'
|
||||
import {useGoBack} from 'lib/hooks/useGoBack'
|
||||
import {sanitizeHandle} from 'lib/strings/handles'
|
||||
import {useListBlockMutation, useListMuteMutation} from 'state/queries/list'
|
||||
import {
|
||||
UsePreferencesQueryResponse,
|
||||
useRemoveFeedMutation,
|
||||
} from 'state/queries/preferences'
|
||||
import {useSession} from 'state/session'
|
||||
import * as Toast from 'view/com/util/Toast'
|
||||
import {CenteredView} from 'view/com/util/Views'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {useHider} from '#/components/moderation/Hider'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function ListHiddenScreen({
|
||||
list,
|
||||
preferences,
|
||||
}: {
|
||||
list: AppBskyGraphDefs.ListView
|
||||
preferences: UsePreferencesQueryResponse
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const {currentAccount} = useSession()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const isOwner = currentAccount?.did === list.creator.did
|
||||
const goBack = useGoBack()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const isModList = list.purpose === AppBskyGraphDefs.MODLIST
|
||||
|
||||
const [isProcessing, setIsProcessing] = React.useState(false)
|
||||
const listBlockMutation = useListBlockMutation()
|
||||
const listMuteMutation = useListMuteMutation()
|
||||
const {mutateAsync: removeSavedFeed} = useRemoveFeedMutation()
|
||||
|
||||
const {setIsContentVisible} = useHider()
|
||||
|
||||
const savedFeedConfig = preferences.savedFeeds.find(f => f.value === list.uri)
|
||||
|
||||
const onUnsubscribe = async () => {
|
||||
setIsProcessing(true)
|
||||
if (list.viewer?.muted) {
|
||||
try {
|
||||
await listMuteMutation.mutateAsync({uri: list.uri, mute: false})
|
||||
} catch (e) {
|
||||
setIsProcessing(false)
|
||||
logger.error('Failed to unmute list', {message: e})
|
||||
Toast.show(
|
||||
_(
|
||||
msg`There was an issue. Please check your internet connection and try again.`,
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
if (list.viewer?.blocked) {
|
||||
try {
|
||||
await listBlockMutation.mutateAsync({uri: list.uri, block: false})
|
||||
} catch (e) {
|
||||
setIsProcessing(false)
|
||||
logger.error('Failed to unblock list', {message: e})
|
||||
Toast.show(
|
||||
_(
|
||||
msg`There was an issue. Please check your internet connection and try again.`,
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [listQueryRoot],
|
||||
})
|
||||
Toast.show(_(msg`Unsubscribed from list`))
|
||||
setIsProcessing(false)
|
||||
}
|
||||
|
||||
const onRemoveList = async () => {
|
||||
if (!savedFeedConfig) return
|
||||
try {
|
||||
await removeSavedFeed(savedFeedConfig)
|
||||
Toast.show(_(msg`Removed from saved feeds`))
|
||||
} catch (e) {
|
||||
logger.error('Failed to remove list from saved feeds', {message: e})
|
||||
Toast.show(
|
||||
_(
|
||||
msg`There was an issue. Please check your internet connection and try again.`,
|
||||
),
|
||||
)
|
||||
} finally {
|
||||
setIsProcessing(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<CenteredView
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.align_center,
|
||||
a.gap_5xl,
|
||||
!gtMobile && a.justify_between,
|
||||
t.atoms.border_contrast_low,
|
||||
{paddingTop: 175, paddingBottom: 110},
|
||||
]}
|
||||
sideBorders={true}>
|
||||
<View style={[a.w_full, a.align_center, a.gap_lg]}>
|
||||
<EyeSlash
|
||||
style={{color: t.atoms.text_contrast_medium.color}}
|
||||
height={42}
|
||||
width={42}
|
||||
/>
|
||||
<View style={[a.gap_sm, a.align_center]}>
|
||||
<Text style={[a.font_bold, a.text_3xl]}>
|
||||
<Trans>List has been hidden</Trans>
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
a.text_center,
|
||||
a.px_md,
|
||||
t.atoms.text_contrast_high,
|
||||
{lineHeight: 1.4},
|
||||
]}>
|
||||
<Trans>
|
||||
This list - created by{' '}
|
||||
<Text style={[a.text_md, !isOwner && a.font_bold]}>
|
||||
{isOwner
|
||||
? _(msg`you`)
|
||||
: sanitizeHandle(list.creator.handle, '@')}
|
||||
</Text>{' '}
|
||||
- contains possible violations of Bluesky's community guidelines
|
||||
in its name or description.
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={[a.gap_md, gtMobile ? {width: 350} : [a.w_full, a.px_lg]]}>
|
||||
<View style={[a.gap_md]}>
|
||||
{savedFeedConfig ? (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="medium"
|
||||
label={_(msg`Remove from saved feeds`)}
|
||||
onPress={onRemoveList}
|
||||
disabled={isProcessing}>
|
||||
<ButtonText>
|
||||
<Trans>Removed from saved feeds</Trans>
|
||||
</ButtonText>
|
||||
{isProcessing ? (
|
||||
<ButtonIcon icon={Loader} position="right" />
|
||||
) : null}
|
||||
</Button>
|
||||
) : null}
|
||||
{isOwner ? (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="medium"
|
||||
label={_(msg`Show list anyway`)}
|
||||
onPress={() => setIsContentVisible(true)}
|
||||
disabled={isProcessing}>
|
||||
<ButtonText>
|
||||
<Trans>Show anyway</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
) : list.viewer?.muted || list.viewer?.blocked ? (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="medium"
|
||||
label={_(msg`Unsubscribe from list`)}
|
||||
onPress={() => {
|
||||
if (isModList) {
|
||||
onUnsubscribe()
|
||||
} else {
|
||||
onRemoveList()
|
||||
}
|
||||
}}
|
||||
disabled={isProcessing}>
|
||||
<ButtonText>
|
||||
<Trans>Unsubscribe from list</Trans>
|
||||
</ButtonText>
|
||||
{isProcessing ? (
|
||||
<ButtonIcon icon={Loader} position="right" />
|
||||
) : null}
|
||||
</Button>
|
||||
) : null}
|
||||
</View>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
label={_(msg`Return to previous page`)}
|
||||
onPress={goBack}
|
||||
size="medium"
|
||||
disabled={isProcessing}>
|
||||
<ButtonText>
|
||||
<Trans>Go Back</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
</View>
|
||||
</CenteredView>
|
||||
)
|
||||
}
|
||||
@@ -162,7 +162,7 @@ let ProfileHeaderStandard = ({
|
||||
hideBackButton={hideBackButton}
|
||||
isPlaceholderProfile={isPlaceholderProfile}>
|
||||
<View
|
||||
style={[a.px_lg, a.pt_md, a.pb_sm]}
|
||||
style={[a.px_lg, a.pt_md, a.pb_sm, a.overflow_hidden]}
|
||||
pointerEvents={isIOS ? 'auto' : 'box-none'}>
|
||||
<View
|
||||
style={[
|
||||
|
||||
@@ -35,7 +35,7 @@ function DeactivateAccountDialogInner({
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const {_} = useLingui()
|
||||
const agent = useAgent()
|
||||
const {logout} = useSessionApi()
|
||||
const {logoutCurrentAccount} = useSessionApi()
|
||||
const [pending, setPending] = React.useState(false)
|
||||
const [error, setError] = React.useState<string | undefined>()
|
||||
|
||||
@@ -44,7 +44,7 @@ function DeactivateAccountDialogInner({
|
||||
setPending(true)
|
||||
await agent.com.atproto.server.deactivateAccount({})
|
||||
control.close(() => {
|
||||
logout('Deactivated')
|
||||
logoutCurrentAccount('Deactivated')
|
||||
})
|
||||
} catch (e: any) {
|
||||
switch (e.message) {
|
||||
@@ -66,7 +66,7 @@ function DeactivateAccountDialogInner({
|
||||
} finally {
|
||||
setPending(false)
|
||||
}
|
||||
}, [agent, control, logout, _, setPending])
|
||||
}, [agent, control, logoutCurrentAccount, _, setPending])
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -23,7 +23,7 @@ export function SignupQueued() {
|
||||
const insets = useSafeAreaInsets()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const onboardingDispatch = useOnboardingDispatch()
|
||||
const {logout} = useSessionApi()
|
||||
const {logoutCurrentAccount} = useSessionApi()
|
||||
const agent = useAgent()
|
||||
|
||||
const [isProcessing, setProcessing] = React.useState(false)
|
||||
@@ -40,7 +40,7 @@ export function SignupQueued() {
|
||||
const res = await agent.com.atproto.temp.checkSignupQueue()
|
||||
if (res.data.activated) {
|
||||
// ready to go, exchange the access token for a usable one and kick off onboarding
|
||||
await agent.refreshSession()
|
||||
await agent.sessionManager.refreshSession()
|
||||
if (!isSignupQueued(agent.session?.accessJwt)) {
|
||||
onboardingDispatch({type: 'start'})
|
||||
}
|
||||
@@ -153,7 +153,7 @@ export function SignupQueued() {
|
||||
variant="ghost"
|
||||
size="large"
|
||||
label={_(msg`Log out`)}
|
||||
onPress={() => logout('SignupQueued')}>
|
||||
onPress={() => logoutCurrentAccount('SignupQueued')}>
|
||||
<ButtonText style={[{color: t.palette.primary_500}]}>
|
||||
<Trans>Log out</Trans>
|
||||
</ButtonText>
|
||||
@@ -182,7 +182,7 @@ export function SignupQueued() {
|
||||
variant="ghost"
|
||||
size="large"
|
||||
label={_(msg`Log out`)}
|
||||
onPress={() => logout('SignupQueued')}>
|
||||
onPress={() => logoutCurrentAccount('SignupQueued')}>
|
||||
<ButtonText style={[{color: t.palette.primary_500}]}>
|
||||
<Trans>Log out</Trans>
|
||||
</ButtonText>
|
||||
|
||||
@@ -22,6 +22,22 @@ export function listenSessionDropped(fn: () => void): UnlistenFn {
|
||||
return () => emitter.off('session-dropped', fn)
|
||||
}
|
||||
|
||||
export function emitNetworkConfirmed() {
|
||||
emitter.emit('network-confirmed')
|
||||
}
|
||||
export function listenNetworkConfirmed(fn: () => void): UnlistenFn {
|
||||
emitter.on('network-confirmed', fn)
|
||||
return () => emitter.off('network-confirmed', fn)
|
||||
}
|
||||
|
||||
export function emitNetworkLost() {
|
||||
emitter.emit('network-lost')
|
||||
}
|
||||
export function listenNetworkLost(fn: () => void): UnlistenFn {
|
||||
emitter.on('network-lost', fn)
|
||||
return () => emitter.off('network-lost', fn)
|
||||
}
|
||||
|
||||
export function emitPostCreated() {
|
||||
emitter.emit('post-created')
|
||||
}
|
||||
|
||||
+15
-31
@@ -1,11 +1,10 @@
|
||||
import React from 'react'
|
||||
import {AppState, AppStateStatus} from 'react-native'
|
||||
import {AppBskyFeedDefs, BskyAgent} from '@atproto/api'
|
||||
import {AppBskyFeedDefs} from '@atproto/api'
|
||||
import throttle from 'lodash.throttle'
|
||||
|
||||
import {PROD_DEFAULT_FEED} from '#/lib/constants'
|
||||
import {logEvent} from '#/lib/statsig/statsig'
|
||||
import {useGate} from '#/lib/statsig/statsig'
|
||||
import {logger} from '#/logger'
|
||||
import {FeedDescriptor, FeedPostSliceItem} from '#/state/queries/post-feed'
|
||||
import {getFeedPostSlice} from '#/view/com/posts/Feed'
|
||||
@@ -25,7 +24,6 @@ const stateContext = React.createContext<StateContext>({
|
||||
|
||||
export function useFeedFeedback(feed: FeedDescriptor, hasSession: boolean) {
|
||||
const agent = useAgent()
|
||||
const gate = useGate()
|
||||
const enabled = isDiscoverFeed(feed) && hasSession
|
||||
const queue = React.useRef<Set<string>>(new Set())
|
||||
const history = React.useRef<
|
||||
@@ -49,34 +47,20 @@ export function useFeedFeedback(feed: FeedDescriptor, hasSession: boolean) {
|
||||
queue.current.clear()
|
||||
|
||||
// Send to the feed
|
||||
if (gate('session_withproxy_fix')) {
|
||||
agent.app.bsky.feed
|
||||
.sendInteractions(
|
||||
{interactions},
|
||||
{
|
||||
encoding: 'application/json',
|
||||
headers: {
|
||||
// TODO when we start sending to other feeds, we need to grab their DID -prf
|
||||
'atproto-proxy': 'did:web:discover.bsky.app#bsky_fg',
|
||||
},
|
||||
agent.app.bsky.feed
|
||||
.sendInteractions(
|
||||
{interactions},
|
||||
{
|
||||
encoding: 'application/json',
|
||||
headers: {
|
||||
// TODO when we start sending to other feeds, we need to grab their DID -prf
|
||||
'atproto-proxy': 'did:web:discover.bsky.app#bsky_fg',
|
||||
},
|
||||
)
|
||||
.catch((e: any) => {
|
||||
logger.warn('Failed to send feed interactions', {error: e})
|
||||
})
|
||||
} else {
|
||||
const proxyAgent = agent.withProxy(
|
||||
// @ts-ignore TODO need to update withProxy() to support this key -prf
|
||||
'bsky_fg',
|
||||
// TODO when we start sending to other feeds, we need to grab their DID -prf
|
||||
'did:web:discover.bsky.app',
|
||||
) as BskyAgent
|
||||
proxyAgent.app.bsky.feed
|
||||
.sendInteractions({interactions})
|
||||
.catch((e: any) => {
|
||||
logger.warn('Failed to send feed interactions', {error: e})
|
||||
})
|
||||
}
|
||||
},
|
||||
)
|
||||
.catch((e: any) => {
|
||||
logger.warn('Failed to send feed interactions', {error: e})
|
||||
})
|
||||
|
||||
// Send to Statsig
|
||||
if (aggregatedStats.current === null) {
|
||||
@@ -84,7 +68,7 @@ export function useFeedFeedback(feed: FeedDescriptor, hasSession: boolean) {
|
||||
}
|
||||
sendOrAggregateInteractionsForStats(aggregatedStats.current, interactions)
|
||||
throttledFlushAggregatedStats()
|
||||
}, [agent, gate, throttledFlushAggregatedStats])
|
||||
}, [agent, throttledFlushAggregatedStats])
|
||||
|
||||
const sendToFeed = React.useMemo(
|
||||
() =>
|
||||
|
||||
@@ -454,7 +454,8 @@ export function usePinnedFeedsInfos() {
|
||||
}),
|
||||
)
|
||||
|
||||
await Promise.allSettled([feedsPromise, ...listsPromises])
|
||||
await feedsPromise // Fail the whole query if it fails.
|
||||
await Promise.allSettled(listsPromises) // Ignore individual failing ones.
|
||||
|
||||
// order the feeds/lists in the order they were pinned
|
||||
const result: SavedFeedSourceInfo[] = []
|
||||
|
||||
@@ -17,7 +17,7 @@ import {useAgent, useSession} from '../session'
|
||||
import {invalidate as invalidateMyLists} from './my-lists'
|
||||
import {RQKEY as PROFILE_LISTS_RQKEY} from './profile-lists'
|
||||
|
||||
const RQKEY_ROOT = 'list'
|
||||
export const RQKEY_ROOT = 'list'
|
||||
export const RQKEY = (uri: string) => [RQKEY_ROOT, uri]
|
||||
|
||||
export function useListQuery(uri?: string) {
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
useQueryClient,
|
||||
} from '@tanstack/react-query'
|
||||
|
||||
import {useGate} from '#/lib/statsig/statsig'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {useModerationOpts} from '../../preferences/moderation-opts'
|
||||
import {STALE} from '..'
|
||||
@@ -59,7 +58,6 @@ export function useNotificationFeedQuery(opts?: {
|
||||
const moderationOpts = useModerationOpts()
|
||||
const unreads = useUnreadNotificationsApi()
|
||||
const enabled = opts?.enabled !== false
|
||||
const gate = useGate()
|
||||
|
||||
// false: force showing all notifications
|
||||
// undefined: let the server decide
|
||||
@@ -88,7 +86,6 @@ export function useNotificationFeedQuery(opts?: {
|
||||
queryClient,
|
||||
moderationOpts,
|
||||
fetchAdditionalData: true,
|
||||
shouldUngroupFollowBacks: () => gate('ungroup_follow_backs'),
|
||||
priority,
|
||||
})
|
||||
page = fetchedPage
|
||||
|
||||
@@ -8,7 +8,6 @@ import {useQueryClient} from '@tanstack/react-query'
|
||||
import EventEmitter from 'eventemitter3'
|
||||
|
||||
import BroadcastChannel from '#/lib/broadcast'
|
||||
import {useGate} from '#/lib/statsig/statsig'
|
||||
import {logger} from '#/logger'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
import {resetBadgeCount} from 'lib/notifications/notifications'
|
||||
@@ -48,7 +47,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
const agent = useAgent()
|
||||
const queryClient = useQueryClient()
|
||||
const moderationOpts = useModerationOpts()
|
||||
const gate = useGate()
|
||||
|
||||
const [numUnread, setNumUnread] = React.useState('')
|
||||
|
||||
@@ -151,7 +149,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
// only fetch subjects when the page is going to be used
|
||||
// in the notifications query, otherwise skip it
|
||||
fetchAdditionalData: !!invalidate,
|
||||
shouldUngroupFollowBacks: () => gate('ungroup_follow_backs'),
|
||||
})
|
||||
const unreadCount = countUnread(page)
|
||||
const unreadCountStr =
|
||||
@@ -192,7 +189,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
}
|
||||
},
|
||||
}
|
||||
}, [setNumUnread, queryClient, moderationOpts, agent, gate])
|
||||
}, [setNumUnread, queryClient, moderationOpts, agent])
|
||||
checkUnreadRef.current = api.checkUnread
|
||||
|
||||
return (
|
||||
|
||||
@@ -30,7 +30,6 @@ export async function fetchPage({
|
||||
queryClient,
|
||||
moderationOpts,
|
||||
fetchAdditionalData,
|
||||
shouldUngroupFollowBacks,
|
||||
}: {
|
||||
agent: BskyAgent
|
||||
cursor: string | undefined
|
||||
@@ -38,7 +37,6 @@ export async function fetchPage({
|
||||
queryClient: QueryClient
|
||||
moderationOpts: ModerationOpts | undefined
|
||||
fetchAdditionalData: boolean
|
||||
shouldUngroupFollowBacks?: () => boolean
|
||||
priority?: boolean
|
||||
}): Promise<{
|
||||
page: FeedPage
|
||||
@@ -58,7 +56,7 @@ export async function fetchPage({
|
||||
)
|
||||
|
||||
// group notifications which are essentially similar (follows, likes on a post)
|
||||
let notifsGrouped = groupNotifications(notifs, {shouldUngroupFollowBacks})
|
||||
let notifsGrouped = groupNotifications(notifs)
|
||||
|
||||
// we fetch subjects of notifications (usually posts) now instead of lazily
|
||||
// in the UI to avoid relayouts
|
||||
@@ -117,7 +115,6 @@ export function shouldFilterNotif(
|
||||
|
||||
export function groupNotifications(
|
||||
notifs: AppBskyNotificationListNotifications.Notification[],
|
||||
options?: {shouldUngroupFollowBacks?: () => boolean},
|
||||
): FeedNotification[] {
|
||||
const groupedNotifs: FeedNotification[] = []
|
||||
for (const notif of notifs) {
|
||||
@@ -137,9 +134,7 @@ export function groupNotifications(
|
||||
const prevIsFollowBack =
|
||||
groupedNotif.notification.reason === 'follow' &&
|
||||
groupedNotif.notification.author.viewer?.following
|
||||
const shouldUngroup =
|
||||
(nextIsFollowBack || prevIsFollowBack) &&
|
||||
options?.shouldUngroupFollowBacks?.()
|
||||
const shouldUngroup = nextIsFollowBack || prevIsFollowBack
|
||||
if (!shouldUngroup) {
|
||||
groupedNotif.additional = groupedNotif.additional || []
|
||||
groupedNotif.additional.push(notif)
|
||||
|
||||
@@ -80,6 +80,7 @@ export interface FeedPostSliceItem {
|
||||
moderation: ModerationDecision
|
||||
parentAuthor?: AppBskyActorDefs.ProfileViewBasic
|
||||
isParentBlocked?: boolean
|
||||
isParentNotFound?: boolean
|
||||
}
|
||||
|
||||
export interface FeedPostSlice {
|
||||
@@ -326,6 +327,7 @@ export function usePostFeedQuery(
|
||||
moderation: moderations[i],
|
||||
parentAuthor: item.parentAuthor,
|
||||
isParentBlocked: item.isParentBlocked,
|
||||
isParentNotFound: item.isParentNotFound,
|
||||
}
|
||||
return feedPostSliceItem
|
||||
}),
|
||||
|
||||
@@ -137,6 +137,7 @@ export function sortThread(
|
||||
opts: UsePreferencesQueryResponse['threadViewPrefs'],
|
||||
modCache: ThreadModerationCache,
|
||||
currentDid: string | undefined,
|
||||
justPostedUris: Set<string>,
|
||||
): ThreadNode {
|
||||
if (node.type !== 'post') {
|
||||
return node
|
||||
@@ -150,6 +151,20 @@ export function sortThread(
|
||||
return -1
|
||||
}
|
||||
|
||||
if (node.ctx.isHighlightedPost || opts.lab_treeViewEnabled) {
|
||||
const aIsJustPosted =
|
||||
a.post.author.did === currentDid && justPostedUris.has(a.post.uri)
|
||||
const bIsJustPosted =
|
||||
b.post.author.did === currentDid && justPostedUris.has(b.post.uri)
|
||||
if (aIsJustPosted && bIsJustPosted) {
|
||||
return a.post.indexedAt.localeCompare(b.post.indexedAt) // oldest
|
||||
} else if (aIsJustPosted) {
|
||||
return -1 // reply while onscreen
|
||||
} else if (bIsJustPosted) {
|
||||
return 1 // reply while onscreen
|
||||
}
|
||||
}
|
||||
|
||||
const aIsByOp = a.post.author.did === node.post?.author.did
|
||||
const bIsByOp = b.post.author.did === node.post?.author.did
|
||||
if (aIsByOp && bIsByOp) {
|
||||
@@ -206,7 +221,9 @@ export function sortThread(
|
||||
}
|
||||
return b.post.indexedAt.localeCompare(a.post.indexedAt)
|
||||
})
|
||||
node.replies.forEach(reply => sortThread(reply, opts, modCache, currentDid))
|
||||
node.replies.forEach(reply =>
|
||||
sortThread(reply, opts, modCache, currentDid, justPostedUris),
|
||||
)
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
@@ -37,14 +37,14 @@ export function usePreferencesQuery() {
|
||||
refetchOnWindowFocus: true,
|
||||
queryKey: preferencesQueryKey,
|
||||
queryFn: async () => {
|
||||
if (agent.session?.did === undefined) {
|
||||
if (!agent.did) {
|
||||
return DEFAULT_LOGGED_OUT_PREFERENCES
|
||||
} else {
|
||||
const res = await agent.getPreferences()
|
||||
|
||||
// save to local storage to ensure there are labels on initial requests
|
||||
saveLabelers(
|
||||
agent.session.did,
|
||||
agent.did,
|
||||
res.moderationPrefs.labelers.map(l => l.did),
|
||||
)
|
||||
|
||||
|
||||
@@ -40,18 +40,10 @@ export function useProfileListsQuery(did: string, opts?: {enabled?: boolean}) {
|
||||
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
|
||||
}),
|
||||
lists: page.lists.filter(list => {
|
||||
const decision = moderateUserList(list, moderationOpts!)
|
||||
return !decision.ui('contentList').filter
|
||||
}),
|
||||
}
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -222,6 +222,7 @@ export function useProfileFollowMutationQueue(
|
||||
logContext: LogEvents['profile:follow']['logContext'] &
|
||||
LogEvents['profile:unfollow']['logContext'],
|
||||
) {
|
||||
const agent = useAgent()
|
||||
const queryClient = useQueryClient()
|
||||
const did = profile.did
|
||||
const initialFollowingUri = profile.viewer?.following
|
||||
@@ -253,6 +254,20 @@ export function useProfileFollowMutationQueue(
|
||||
updateProfileShadow(queryClient, did, {
|
||||
followingUri: finalFollowingUri,
|
||||
})
|
||||
|
||||
if (finalFollowingUri) {
|
||||
agent.app.bsky.graph
|
||||
.getSuggestedFollowsByActor({
|
||||
actor: did,
|
||||
})
|
||||
.then(res => {
|
||||
const dids = res.data.suggestions
|
||||
.filter(a => !a.viewer?.following)
|
||||
.map(a => a.did)
|
||||
.slice(0, 8)
|
||||
userActionHistory.followSuggestion(dids)
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ describe('session', () => {
|
||||
`)
|
||||
|
||||
const agent = new BskyAgent({service: 'https://alice.com'})
|
||||
agent.session = {
|
||||
agent.sessionManager.session = {
|
||||
active: true,
|
||||
did: 'alice-did',
|
||||
handle: 'alice.test',
|
||||
@@ -76,7 +76,7 @@ describe('session', () => {
|
||||
|
||||
state = run(state, [
|
||||
{
|
||||
type: 'logged-out',
|
||||
type: 'logged-out-every-account',
|
||||
},
|
||||
])
|
||||
// Should keep the account but clear out the tokens.
|
||||
@@ -118,7 +118,7 @@ describe('session', () => {
|
||||
let state = getInitialState([])
|
||||
|
||||
const agent1 = new BskyAgent({service: 'https://alice.com'})
|
||||
agent1.session = {
|
||||
agent1.sessionManager.session = {
|
||||
active: true,
|
||||
did: 'alice-did',
|
||||
handle: 'alice.test',
|
||||
@@ -166,7 +166,7 @@ describe('session', () => {
|
||||
`)
|
||||
|
||||
const agent2 = new BskyAgent({service: 'https://bob.com'})
|
||||
agent2.session = {
|
||||
agent2.sessionManager.session = {
|
||||
active: true,
|
||||
did: 'bob-did',
|
||||
handle: 'bob.test',
|
||||
@@ -230,7 +230,7 @@ describe('session', () => {
|
||||
`)
|
||||
|
||||
const agent3 = new BskyAgent({service: 'https://alice.com'})
|
||||
agent3.session = {
|
||||
agent3.sessionManager.session = {
|
||||
active: true,
|
||||
did: 'alice-did',
|
||||
handle: 'alice-updated.test',
|
||||
@@ -294,7 +294,7 @@ describe('session', () => {
|
||||
`)
|
||||
|
||||
const agent4 = new BskyAgent({service: 'https://jay.com'})
|
||||
agent4.session = {
|
||||
agent4.sessionManager.session = {
|
||||
active: true,
|
||||
did: 'jay-did',
|
||||
handle: 'jay.test',
|
||||
@@ -372,7 +372,7 @@ describe('session', () => {
|
||||
state = run(state, [
|
||||
{
|
||||
// Log everyone out.
|
||||
type: 'logged-out',
|
||||
type: 'logged-out-every-account',
|
||||
},
|
||||
])
|
||||
expect(state.accounts.length).toBe(3)
|
||||
@@ -445,7 +445,7 @@ describe('session', () => {
|
||||
let state = getInitialState([])
|
||||
|
||||
const agent1 = new BskyAgent({service: 'https://alice.com'})
|
||||
agent1.session = {
|
||||
agent1.sessionManager.session = {
|
||||
active: true,
|
||||
did: 'alice-did',
|
||||
handle: 'alice.test',
|
||||
@@ -466,7 +466,7 @@ describe('session', () => {
|
||||
|
||||
state = run(state, [
|
||||
{
|
||||
type: 'logged-out',
|
||||
type: 'logged-out-every-account',
|
||||
},
|
||||
])
|
||||
expect(state.accounts.length).toBe(1)
|
||||
@@ -502,7 +502,7 @@ describe('session', () => {
|
||||
`)
|
||||
|
||||
const agent2 = new BskyAgent({service: 'https://alice.com'})
|
||||
agent2.session = {
|
||||
agent2.sessionManager.session = {
|
||||
active: true,
|
||||
did: 'alice-did',
|
||||
handle: 'alice.test',
|
||||
@@ -553,7 +553,7 @@ describe('session', () => {
|
||||
let state = getInitialState([])
|
||||
|
||||
const agent1 = new BskyAgent({service: 'https://alice.com'})
|
||||
agent1.session = {
|
||||
agent1.sessionManager.session = {
|
||||
active: true,
|
||||
did: 'alice-did',
|
||||
handle: 'alice.test',
|
||||
@@ -598,7 +598,7 @@ describe('session', () => {
|
||||
let state = getInitialState([])
|
||||
|
||||
const agent1 = new BskyAgent({service: 'https://alice.com'})
|
||||
agent1.session = {
|
||||
agent1.sessionManager.session = {
|
||||
active: true,
|
||||
did: 'alice-did',
|
||||
handle: 'alice.test',
|
||||
@@ -606,7 +606,7 @@ describe('session', () => {
|
||||
refreshJwt: 'alice-refresh-jwt-1',
|
||||
}
|
||||
const agent2 = new BskyAgent({service: 'https://bob.com'})
|
||||
agent2.session = {
|
||||
agent2.sessionManager.session = {
|
||||
active: true,
|
||||
did: 'bob-did',
|
||||
handle: 'bob.test',
|
||||
@@ -674,11 +674,108 @@ describe('session', () => {
|
||||
expect(state.currentAgentState.did).toBe(undefined)
|
||||
})
|
||||
|
||||
it('can log out of the current account', () => {
|
||||
let state = getInitialState([])
|
||||
|
||||
const agent1 = new BskyAgent({service: 'https://alice.com'})
|
||||
agent1.sessionManager.session = {
|
||||
active: true,
|
||||
did: 'alice-did',
|
||||
handle: 'alice.test',
|
||||
accessJwt: 'alice-access-jwt-1',
|
||||
refreshJwt: 'alice-refresh-jwt-1',
|
||||
}
|
||||
state = run(state, [
|
||||
{
|
||||
type: 'switched-to-account',
|
||||
newAgent: agent1,
|
||||
newAccount: agentToSessionAccountOrThrow(agent1),
|
||||
},
|
||||
])
|
||||
expect(state.accounts.length).toBe(1)
|
||||
expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-1')
|
||||
expect(state.accounts[0].refreshJwt).toBe('alice-refresh-jwt-1')
|
||||
expect(state.currentAgentState.did).toBe('alice-did')
|
||||
|
||||
const agent2 = new BskyAgent({service: 'https://bob.com'})
|
||||
agent2.sessionManager.session = {
|
||||
active: true,
|
||||
did: 'bob-did',
|
||||
handle: 'bob.test',
|
||||
accessJwt: 'bob-access-jwt-1',
|
||||
refreshJwt: 'bob-refresh-jwt-1',
|
||||
}
|
||||
state = run(state, [
|
||||
{
|
||||
type: 'switched-to-account',
|
||||
newAgent: agent2,
|
||||
newAccount: agentToSessionAccountOrThrow(agent2),
|
||||
},
|
||||
])
|
||||
expect(state.accounts.length).toBe(2)
|
||||
expect(state.accounts[0].accessJwt).toBe('bob-access-jwt-1')
|
||||
expect(state.accounts[0].refreshJwt).toBe('bob-refresh-jwt-1')
|
||||
expect(state.currentAgentState.did).toBe('bob-did')
|
||||
|
||||
state = run(state, [
|
||||
{
|
||||
type: 'logged-out-current-account',
|
||||
},
|
||||
])
|
||||
expect(state.accounts.length).toBe(2)
|
||||
expect(state.accounts[0].accessJwt).toBe(undefined)
|
||||
expect(state.accounts[0].refreshJwt).toBe(undefined)
|
||||
expect(state.accounts[1].accessJwt).toBe('alice-access-jwt-1')
|
||||
expect(state.accounts[1].refreshJwt).toBe('alice-refresh-jwt-1')
|
||||
expect(state.currentAgentState.did).toBe(undefined)
|
||||
expect(printState(state)).toMatchInlineSnapshot(`
|
||||
{
|
||||
"accounts": [
|
||||
{
|
||||
"accessJwt": undefined,
|
||||
"active": true,
|
||||
"did": "bob-did",
|
||||
"email": undefined,
|
||||
"emailAuthFactor": false,
|
||||
"emailConfirmed": false,
|
||||
"handle": "bob.test",
|
||||
"pdsUrl": undefined,
|
||||
"refreshJwt": undefined,
|
||||
"service": "https://bob.com/",
|
||||
"signupQueued": false,
|
||||
"status": undefined,
|
||||
},
|
||||
{
|
||||
"accessJwt": "alice-access-jwt-1",
|
||||
"active": true,
|
||||
"did": "alice-did",
|
||||
"email": undefined,
|
||||
"emailAuthFactor": false,
|
||||
"emailConfirmed": false,
|
||||
"handle": "alice.test",
|
||||
"pdsUrl": undefined,
|
||||
"refreshJwt": "alice-refresh-jwt-1",
|
||||
"service": "https://alice.com/",
|
||||
"signupQueued": false,
|
||||
"status": undefined,
|
||||
},
|
||||
],
|
||||
"currentAgentState": {
|
||||
"agent": {
|
||||
"service": "https://public.api.bsky.app/",
|
||||
},
|
||||
"did": undefined,
|
||||
},
|
||||
"needsPersist": true,
|
||||
}
|
||||
`)
|
||||
})
|
||||
|
||||
it('updates stored account with refreshed tokens', () => {
|
||||
let state = getInitialState([])
|
||||
|
||||
const agent1 = new BskyAgent({service: 'https://alice.com'})
|
||||
agent1.session = {
|
||||
agent1.sessionManager.session = {
|
||||
active: true,
|
||||
did: 'alice-did',
|
||||
handle: 'alice.test',
|
||||
@@ -695,7 +792,7 @@ describe('session', () => {
|
||||
expect(state.accounts.length).toBe(1)
|
||||
expect(state.currentAgentState.did).toBe('alice-did')
|
||||
|
||||
agent1.session = {
|
||||
agent1.sessionManager.session = {
|
||||
active: true,
|
||||
did: 'alice-did',
|
||||
handle: 'alice-updated.test',
|
||||
@@ -748,7 +845,7 @@ describe('session', () => {
|
||||
}
|
||||
`)
|
||||
|
||||
agent1.session = {
|
||||
agent1.sessionManager.session = {
|
||||
active: true,
|
||||
did: 'alice-did',
|
||||
handle: 'alice-updated.test',
|
||||
@@ -801,7 +898,7 @@ describe('session', () => {
|
||||
}
|
||||
`)
|
||||
|
||||
agent1.session = {
|
||||
agent1.sessionManager.session = {
|
||||
active: true,
|
||||
did: 'alice-did',
|
||||
handle: 'alice-updated.test',
|
||||
@@ -859,7 +956,7 @@ describe('session', () => {
|
||||
let state = getInitialState([])
|
||||
|
||||
const agent1 = new BskyAgent({service: 'https://alice.com'})
|
||||
agent1.session = {
|
||||
agent1.sessionManager.session = {
|
||||
active: true,
|
||||
did: 'alice-did',
|
||||
handle: 'alice.test',
|
||||
@@ -876,7 +973,7 @@ describe('session', () => {
|
||||
expect(state.accounts.length).toBe(1)
|
||||
expect(state.currentAgentState.did).toBe('alice-did')
|
||||
|
||||
agent1.session = {
|
||||
agent1.sessionManager.session = {
|
||||
active: true,
|
||||
did: 'alice-did',
|
||||
handle: 'alice-updated.test',
|
||||
@@ -907,7 +1004,7 @@ describe('session', () => {
|
||||
])
|
||||
expect(lastState === state).toBe(true)
|
||||
|
||||
agent1.session = {
|
||||
agent1.sessionManager.session = {
|
||||
active: true,
|
||||
did: 'alice-did',
|
||||
handle: 'alice-updated.test',
|
||||
@@ -931,7 +1028,7 @@ describe('session', () => {
|
||||
let state = getInitialState([])
|
||||
|
||||
const agent1 = new BskyAgent({service: 'https://alice.com'})
|
||||
agent1.session = {
|
||||
agent1.sessionManager.session = {
|
||||
active: true,
|
||||
did: 'alice-did',
|
||||
handle: 'alice.test',
|
||||
@@ -940,7 +1037,7 @@ describe('session', () => {
|
||||
}
|
||||
|
||||
const agent2 = new BskyAgent({service: 'https://bob.com'})
|
||||
agent2.session = {
|
||||
agent2.sessionManager.session = {
|
||||
active: true,
|
||||
did: 'bob-did',
|
||||
handle: 'bob.test',
|
||||
@@ -965,7 +1062,7 @@ describe('session', () => {
|
||||
expect(state.accounts.length).toBe(2)
|
||||
expect(state.currentAgentState.did).toBe('bob-did')
|
||||
|
||||
agent1.session = {
|
||||
agent1.sessionManager.session = {
|
||||
active: true,
|
||||
did: 'alice-did',
|
||||
handle: 'alice-updated.test',
|
||||
@@ -1032,7 +1129,7 @@ describe('session', () => {
|
||||
}
|
||||
`)
|
||||
|
||||
agent2.session = {
|
||||
agent2.sessionManager.session = {
|
||||
active: true,
|
||||
did: 'bob-did',
|
||||
handle: 'bob-updated.test',
|
||||
@@ -1099,7 +1196,7 @@ describe('session', () => {
|
||||
|
||||
// Ignore other events for inactive agent.
|
||||
const lastState = state
|
||||
agent1.session = undefined
|
||||
agent1.sessionManager.session = undefined
|
||||
state = run(state, [
|
||||
{
|
||||
type: 'received-agent-event',
|
||||
@@ -1126,7 +1223,7 @@ describe('session', () => {
|
||||
let state = getInitialState([])
|
||||
|
||||
const agent1 = new BskyAgent({service: 'https://alice.com'})
|
||||
agent1.session = {
|
||||
agent1.sessionManager.session = {
|
||||
active: true,
|
||||
did: 'alice-did',
|
||||
handle: 'alice.test',
|
||||
@@ -1135,7 +1232,7 @@ describe('session', () => {
|
||||
}
|
||||
|
||||
const agent2 = new BskyAgent({service: 'https://bob.com'})
|
||||
agent2.session = {
|
||||
agent2.sessionManager.session = {
|
||||
active: true,
|
||||
did: 'bob-did',
|
||||
handle: 'bob.test',
|
||||
@@ -1162,7 +1259,7 @@ describe('session', () => {
|
||||
expect(state.accounts.length).toBe(1)
|
||||
expect(state.currentAgentState.did).toBe('bob-did')
|
||||
|
||||
agent1.session = {
|
||||
agent1.sessionManager.session = {
|
||||
active: true,
|
||||
did: 'alice-did',
|
||||
handle: 'alice.test',
|
||||
@@ -1184,11 +1281,11 @@ describe('session', () => {
|
||||
expect(state.currentAgentState.did).toBe('bob-did')
|
||||
})
|
||||
|
||||
it('does soft logout on network error', () => {
|
||||
it('ignores network errors', () => {
|
||||
let state = getInitialState([])
|
||||
|
||||
const agent1 = new BskyAgent({service: 'https://alice.com'})
|
||||
agent1.session = {
|
||||
agent1.sessionManager.session = {
|
||||
active: true,
|
||||
did: 'alice-did',
|
||||
handle: 'alice.test',
|
||||
@@ -1206,7 +1303,7 @@ describe('session', () => {
|
||||
expect(state.accounts.length).toBe(1)
|
||||
expect(state.currentAgentState.did).toBe('alice-did')
|
||||
|
||||
agent1.session = undefined
|
||||
agent1.sessionManager.session = undefined
|
||||
state = run(state, [
|
||||
{
|
||||
type: 'received-agent-event',
|
||||
@@ -1217,11 +1314,9 @@ describe('session', () => {
|
||||
},
|
||||
])
|
||||
expect(state.accounts.length).toBe(1)
|
||||
// Network error should reset current user but not reset the tokens.
|
||||
// TODO: We might want to remove or change this behavior?
|
||||
expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-1')
|
||||
expect(state.accounts[0].refreshJwt).toBe('alice-refresh-jwt-1')
|
||||
expect(state.currentAgentState.did).toBe(undefined)
|
||||
expect(state.currentAgentState.did).toBe('alice-did')
|
||||
expect(printState(state)).toMatchInlineSnapshot(`
|
||||
{
|
||||
"accounts": [
|
||||
@@ -1242,9 +1337,9 @@ describe('session', () => {
|
||||
],
|
||||
"currentAgentState": {
|
||||
"agent": {
|
||||
"service": "https://public.api.bsky.app/",
|
||||
"service": "https://alice.com/",
|
||||
},
|
||||
"did": undefined,
|
||||
"did": "alice-did",
|
||||
},
|
||||
"needsPersist": true,
|
||||
}
|
||||
@@ -1255,7 +1350,7 @@ describe('session', () => {
|
||||
let state = getInitialState([])
|
||||
|
||||
const agent1 = new BskyAgent({service: 'https://alice.com'})
|
||||
agent1.session = {
|
||||
agent1.sessionManager.session = {
|
||||
active: true,
|
||||
did: 'alice-did',
|
||||
handle: 'alice.test',
|
||||
@@ -1273,7 +1368,7 @@ describe('session', () => {
|
||||
expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-1')
|
||||
expect(state.currentAgentState.did).toBe('alice-did')
|
||||
|
||||
agent1.session = undefined
|
||||
agent1.sessionManager.session = undefined
|
||||
state = run(state, [
|
||||
{
|
||||
type: 'received-agent-event',
|
||||
@@ -1320,7 +1415,7 @@ describe('session', () => {
|
||||
let state = getInitialState([])
|
||||
|
||||
const agent1 = new BskyAgent({service: 'https://alice.com'})
|
||||
agent1.session = {
|
||||
agent1.sessionManager.session = {
|
||||
active: true,
|
||||
did: 'alice-did',
|
||||
handle: 'alice.test',
|
||||
@@ -1338,7 +1433,7 @@ describe('session', () => {
|
||||
expect(state.accounts[0].accessJwt).toBe('alice-access-jwt-1')
|
||||
expect(state.currentAgentState.did).toBe('alice-did')
|
||||
|
||||
agent1.session = undefined
|
||||
agent1.sessionManager.session = undefined
|
||||
state = run(state, [
|
||||
{
|
||||
type: 'received-agent-event',
|
||||
@@ -1385,7 +1480,7 @@ describe('session', () => {
|
||||
let state = getInitialState([])
|
||||
|
||||
const agent1 = new BskyAgent({service: 'https://alice.com'})
|
||||
agent1.session = {
|
||||
agent1.sessionManager.session = {
|
||||
active: true,
|
||||
did: 'alice-did',
|
||||
handle: 'alice.test',
|
||||
@@ -1393,7 +1488,7 @@ describe('session', () => {
|
||||
refreshJwt: 'alice-refresh-jwt-1',
|
||||
}
|
||||
const agent2 = new BskyAgent({service: 'https://bob.com'})
|
||||
agent2.session = {
|
||||
agent2.sessionManager.session = {
|
||||
active: true,
|
||||
did: 'bob-did',
|
||||
handle: 'bob.test',
|
||||
@@ -1416,7 +1511,7 @@ describe('session', () => {
|
||||
expect(state.currentAgentState.did).toBe('bob-did')
|
||||
|
||||
const anotherTabAgent1 = new BskyAgent({service: 'https://jay.com'})
|
||||
anotherTabAgent1.session = {
|
||||
anotherTabAgent1.sessionManager.session = {
|
||||
active: true,
|
||||
did: 'jay-did',
|
||||
handle: 'jay.test',
|
||||
@@ -1424,7 +1519,7 @@ describe('session', () => {
|
||||
refreshJwt: 'jay-refresh-jwt-1',
|
||||
}
|
||||
const anotherTabAgent2 = new BskyAgent({service: 'https://alice.com'})
|
||||
anotherTabAgent2.session = {
|
||||
anotherTabAgent2.sessionManager.session = {
|
||||
active: true,
|
||||
did: 'bob-did',
|
||||
handle: 'bob.test',
|
||||
@@ -1492,7 +1587,7 @@ describe('session', () => {
|
||||
`)
|
||||
|
||||
const anotherTabAgent3 = new BskyAgent({service: 'https://clarence.com'})
|
||||
anotherTabAgent3.session = {
|
||||
anotherTabAgent3.sessionManager.session = {
|
||||
active: true,
|
||||
did: 'clarence-did',
|
||||
handle: 'clarence.test',
|
||||
|
||||
+84
-35
@@ -12,6 +12,7 @@ import {tryFetchGates} from '#/lib/statsig/statsig'
|
||||
import {getAge} from '#/lib/strings/time'
|
||||
import {logger} from '#/logger'
|
||||
import {snoozeEmailConfirmationPrompt} from '#/state/shell/reminders'
|
||||
import {emitNetworkConfirmed, emitNetworkLost} from '../events'
|
||||
import {addSessionErrorLog} from './logging'
|
||||
import {
|
||||
configureModerationForAccount,
|
||||
@@ -22,7 +23,7 @@ import {isSessionExpired, isSignupQueued} from './util'
|
||||
|
||||
export function createPublicAgent() {
|
||||
configureModerationForGuest() // Side effect but only relevant for tests
|
||||
return new BskyAgent({service: PUBLIC_BSKY_SERVICE})
|
||||
return new BskyAppAgent({service: PUBLIC_BSKY_SERVICE})
|
||||
}
|
||||
|
||||
export async function createAgentAndResume(
|
||||
@@ -33,9 +34,9 @@ export async function createAgentAndResume(
|
||||
event: AtpSessionEvent,
|
||||
) => void,
|
||||
) {
|
||||
const agent = new BskyAgent({service: storedAccount.service})
|
||||
const agent = new BskyAppAgent({service: storedAccount.service})
|
||||
if (storedAccount.pdsUrl) {
|
||||
agent.pdsUrl = agent.api.xrpc.uri = new URL(storedAccount.pdsUrl)
|
||||
agent.sessionManager.pdsUrl = new URL(storedAccount.pdsUrl)
|
||||
}
|
||||
const gates = tryFetchGates(storedAccount.did, 'prefer-low-latency')
|
||||
const moderation = configureModerationForAccount(agent, storedAccount)
|
||||
@@ -43,9 +44,8 @@ export async function createAgentAndResume(
|
||||
if (isSessionExpired(storedAccount)) {
|
||||
await networkRetry(1, () => agent.resumeSession(prevSession))
|
||||
} else {
|
||||
agent.session = prevSession
|
||||
agent.sessionManager.session = prevSession
|
||||
if (!storedAccount.signupQueued) {
|
||||
// Intentionally not awaited to unblock the UI:
|
||||
networkRetry(3, () => agent.resumeSession(prevSession)).catch(
|
||||
(e: any) => {
|
||||
logger.error(`networkRetry failed to resume session`, {
|
||||
@@ -60,7 +60,7 @@ export async function createAgentAndResume(
|
||||
}
|
||||
}
|
||||
|
||||
return prepareAgent(agent, gates, moderation, onSessionChange)
|
||||
return agent.prepare(gates, moderation, onSessionChange)
|
||||
}
|
||||
|
||||
export async function createAgentAndLogin(
|
||||
@@ -81,13 +81,13 @@ export async function createAgentAndLogin(
|
||||
event: AtpSessionEvent,
|
||||
) => void,
|
||||
) {
|
||||
const agent = new BskyAgent({service})
|
||||
const agent = new BskyAppAgent({service})
|
||||
await agent.login({identifier, password, authFactorToken})
|
||||
|
||||
const account = agentToSessionAccountOrThrow(agent)
|
||||
const gates = tryFetchGates(account.did, 'prefer-fresh-gates')
|
||||
const moderation = configureModerationForAccount(agent, account)
|
||||
return prepareAgent(agent, moderation, gates, onSessionChange)
|
||||
return agent.prepare(gates, moderation, onSessionChange)
|
||||
}
|
||||
|
||||
export async function createAgentAndCreateAccount(
|
||||
@@ -116,7 +116,7 @@ export async function createAgentAndCreateAccount(
|
||||
event: AtpSessionEvent,
|
||||
) => void,
|
||||
) {
|
||||
const agent = new BskyAgent({service})
|
||||
const agent = new BskyAppAgent({service})
|
||||
await agent.createAccount({
|
||||
email,
|
||||
password,
|
||||
@@ -174,32 +174,7 @@ export async function createAgentAndCreateAccount(
|
||||
logger.error(e, {context: `session: failed snoozeEmailConfirmationPrompt`})
|
||||
}
|
||||
|
||||
return prepareAgent(agent, gates, moderation, onSessionChange)
|
||||
}
|
||||
|
||||
async function prepareAgent(
|
||||
agent: BskyAgent,
|
||||
// Not awaited in the calling code so we can delay blocking on them.
|
||||
gates: Promise<void>,
|
||||
moderation: Promise<void>,
|
||||
onSessionChange: (
|
||||
agent: BskyAgent,
|
||||
did: string,
|
||||
event: AtpSessionEvent,
|
||||
) => void,
|
||||
) {
|
||||
// There's nothing else left to do, so block on them here.
|
||||
await Promise.all([gates, moderation])
|
||||
|
||||
// Now the agent is ready.
|
||||
const account = agentToSessionAccountOrThrow(agent)
|
||||
agent.setPersistSessionHandler(event => {
|
||||
onSessionChange(agent, account.did, event)
|
||||
if (event !== 'create' && event !== 'update') {
|
||||
addSessionErrorLog(account.did, event)
|
||||
}
|
||||
})
|
||||
return {agent, account}
|
||||
return agent.prepare(gates, moderation, onSessionChange)
|
||||
}
|
||||
|
||||
export function agentToSessionAccountOrThrow(agent: BskyAgent): SessionAccount {
|
||||
@@ -251,3 +226,77 @@ export function sessionAccountToSession(
|
||||
status: account.status,
|
||||
}
|
||||
}
|
||||
|
||||
// Not exported. Use factories above to create it.
|
||||
let realFetch = globalThis.fetch
|
||||
class BskyAppAgent extends BskyAgent {
|
||||
persistSessionHandler: ((event: AtpSessionEvent) => void) | undefined =
|
||||
undefined
|
||||
|
||||
constructor({service}: {service: string}) {
|
||||
super({
|
||||
service,
|
||||
async fetch(...args) {
|
||||
let success = false
|
||||
try {
|
||||
const result = await realFetch(...args)
|
||||
success = true
|
||||
return result
|
||||
} catch (e) {
|
||||
success = false
|
||||
throw e
|
||||
} finally {
|
||||
if (success) {
|
||||
emitNetworkConfirmed()
|
||||
} else {
|
||||
emitNetworkLost()
|
||||
}
|
||||
}
|
||||
},
|
||||
persistSession: (event: AtpSessionEvent) => {
|
||||
if (this.persistSessionHandler) {
|
||||
this.persistSessionHandler(event)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async prepare(
|
||||
// Not awaited in the calling code so we can delay blocking on them.
|
||||
gates: Promise<void>,
|
||||
moderation: Promise<void>,
|
||||
onSessionChange: (
|
||||
agent: BskyAgent,
|
||||
did: string,
|
||||
event: AtpSessionEvent,
|
||||
) => void,
|
||||
) {
|
||||
// There's nothing else left to do, so block on them here.
|
||||
await Promise.all([gates, moderation])
|
||||
|
||||
// Now the agent is ready.
|
||||
const account = agentToSessionAccountOrThrow(this)
|
||||
let lastSession = this.sessionManager.session
|
||||
this.persistSessionHandler = event => {
|
||||
if (this.sessionManager.session) {
|
||||
lastSession = this.sessionManager.session
|
||||
} else if (event === 'network-error') {
|
||||
// Put it back, we'll try again later.
|
||||
this.sessionManager.session = lastSession
|
||||
}
|
||||
|
||||
onSessionChange(this, account.did, event)
|
||||
if (event !== 'create' && event !== 'update') {
|
||||
addSessionErrorLog(account.did, event)
|
||||
}
|
||||
}
|
||||
return {account, agent: this}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.sessionManager.session = undefined
|
||||
this.persistSessionHandler = undefined
|
||||
}
|
||||
}
|
||||
|
||||
export type {BskyAppAgent}
|
||||
|
||||
+36
-10
@@ -11,6 +11,7 @@ import {IS_DEV} from '#/env'
|
||||
import {emitSessionDropped} from '../events'
|
||||
import {
|
||||
agentToSessionAccount,
|
||||
BskyAppAgent,
|
||||
createAgentAndCreateAccount,
|
||||
createAgentAndLogin,
|
||||
createAgentAndResume,
|
||||
@@ -34,7 +35,8 @@ const AgentContext = React.createContext<BskyAgent | null>(null)
|
||||
const ApiContext = React.createContext<SessionApiContext>({
|
||||
createAccount: async () => {},
|
||||
login: async () => {},
|
||||
logout: async () => {},
|
||||
logoutCurrentAccount: async () => {},
|
||||
logoutEveryAccount: async () => {},
|
||||
resumeSession: async () => {},
|
||||
removeAccount: () => {},
|
||||
})
|
||||
@@ -114,14 +116,31 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
[onAgentSessionChange, cancelPendingTask],
|
||||
)
|
||||
|
||||
const logout = React.useCallback<SessionApiContext['logout']>(
|
||||
const logoutCurrentAccount = React.useCallback<
|
||||
SessionApiContext['logoutEveryAccount']
|
||||
>(
|
||||
logContext => {
|
||||
addSessionDebugLog({type: 'method:start', method: 'logout'})
|
||||
cancelPendingTask()
|
||||
dispatch({
|
||||
type: 'logged-out',
|
||||
type: 'logged-out-current-account',
|
||||
})
|
||||
logEvent('account:loggedOut', {logContext})
|
||||
logEvent('account:loggedOut', {logContext, scope: 'current'})
|
||||
addSessionDebugLog({type: 'method:end', method: 'logout'})
|
||||
},
|
||||
[cancelPendingTask],
|
||||
)
|
||||
|
||||
const logoutEveryAccount = React.useCallback<
|
||||
SessionApiContext['logoutEveryAccount']
|
||||
>(
|
||||
logContext => {
|
||||
addSessionDebugLog({type: 'method:start', method: 'logout'})
|
||||
cancelPendingTask()
|
||||
dispatch({
|
||||
type: 'logged-out-every-account',
|
||||
})
|
||||
logEvent('account:loggedOut', {logContext, scope: 'every'})
|
||||
addSessionDebugLog({type: 'method:end', method: 'logout'})
|
||||
},
|
||||
[cancelPendingTask],
|
||||
@@ -202,7 +221,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
} else {
|
||||
const agent = state.currentAgentState.agent as BskyAgent
|
||||
const prevSession = agent.session
|
||||
agent.session = sessionAccountToSession(syncedAccount)
|
||||
agent.sessionManager.session = sessionAccountToSession(syncedAccount)
|
||||
addSessionDebugLog({
|
||||
type: 'agent:patch',
|
||||
agent,
|
||||
@@ -229,17 +248,25 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
() => ({
|
||||
createAccount,
|
||||
login,
|
||||
logout,
|
||||
logoutCurrentAccount,
|
||||
logoutEveryAccount,
|
||||
resumeSession,
|
||||
removeAccount,
|
||||
}),
|
||||
[createAccount, login, logout, resumeSession, removeAccount],
|
||||
[
|
||||
createAccount,
|
||||
login,
|
||||
logoutCurrentAccount,
|
||||
logoutEveryAccount,
|
||||
resumeSession,
|
||||
removeAccount,
|
||||
],
|
||||
)
|
||||
|
||||
// @ts-ignore
|
||||
if (IS_DEV && isWeb) window.agent = state.currentAgentState.agent
|
||||
|
||||
const agent = state.currentAgentState.agent as BskyAgent
|
||||
const agent = state.currentAgentState.agent as BskyAppAgent
|
||||
const currentAgentRef = React.useRef(agent)
|
||||
React.useEffect(() => {
|
||||
if (currentAgentRef.current !== agent) {
|
||||
@@ -249,8 +276,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
addSessionDebugLog({type: 'agent:switch', prevAgent, nextAgent: agent})
|
||||
// We never reuse agents so let's fully neutralize the previous one.
|
||||
// This ensures it won't try to consume any refresh tokens.
|
||||
prevAgent.session = undefined
|
||||
prevAgent.setPersistSessionHandler(undefined)
|
||||
prevAgent.dispose()
|
||||
}
|
||||
}, [agent])
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ type Log =
|
||||
type: 'agent:patch'
|
||||
agent: object
|
||||
prevSession: AtpSessionData | undefined
|
||||
nextSession: AtpSessionData
|
||||
nextSession: AtpSessionData | undefined
|
||||
}
|
||||
|
||||
export function wrapSessionReducerForLogging(reducer: Reducer): Reducer {
|
||||
|
||||
@@ -42,7 +42,10 @@ export type Action =
|
||||
accountDid: string
|
||||
}
|
||||
| {
|
||||
type: 'logged-out'
|
||||
type: 'logged-out-current-account'
|
||||
}
|
||||
| {
|
||||
type: 'logged-out-every-account'
|
||||
}
|
||||
| {
|
||||
type: 'synced-accounts'
|
||||
@@ -79,12 +82,8 @@ let reducer = (state: State, action: Action): State => {
|
||||
return state
|
||||
}
|
||||
if (sessionEvent === 'network-error') {
|
||||
// Don't change stored accounts but kick to the choose account screen.
|
||||
return {
|
||||
accounts: state.accounts,
|
||||
currentAgentState: createPublicAgentState(),
|
||||
needsPersist: true,
|
||||
}
|
||||
// Assume it's transient.
|
||||
return state
|
||||
}
|
||||
const existingAccount = state.accounts.find(a => a.did === accountDid)
|
||||
if (
|
||||
@@ -142,7 +141,23 @@ let reducer = (state: State, action: Action): State => {
|
||||
needsPersist: true,
|
||||
}
|
||||
}
|
||||
case 'logged-out': {
|
||||
case 'logged-out-current-account': {
|
||||
const {currentAgentState} = state
|
||||
return {
|
||||
accounts: state.accounts.map(a =>
|
||||
a.did === currentAgentState.did
|
||||
? {
|
||||
...a,
|
||||
refreshJwt: undefined,
|
||||
accessJwt: undefined,
|
||||
}
|
||||
: a,
|
||||
),
|
||||
currentAgentState: createPublicAgentState(),
|
||||
needsPersist: true,
|
||||
}
|
||||
}
|
||||
case 'logged-out-every-account': {
|
||||
return {
|
||||
accounts: state.accounts.map(a => ({
|
||||
...a,
|
||||
|
||||
@@ -29,12 +29,12 @@ export type SessionApiContext = {
|
||||
},
|
||||
logContext: LogEvents['account:loggedIn']['logContext'],
|
||||
) => Promise<void>
|
||||
/**
|
||||
* A full logout. Clears the `currentAccount` from session, AND removes
|
||||
* access tokens from all accounts, so that returning as any user will
|
||||
* require a full login.
|
||||
*/
|
||||
logout: (logContext: LogEvents['account:loggedOut']['logContext']) => void
|
||||
logoutCurrentAccount: (
|
||||
logContext: LogEvents['account:loggedOut']['logContext'],
|
||||
) => void
|
||||
logoutEveryAccount: (
|
||||
logContext: LogEvents['account:loggedOut']['logContext'],
|
||||
) => void
|
||||
resumeSession: (account: SessionAccount) => Promise<void>
|
||||
removeAccount: (account: SessionAccount) => void
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
AppBskyActorDefs,
|
||||
AppBskyEmbedRecord,
|
||||
AppBskyRichtextFacet,
|
||||
ModerationDecision,
|
||||
AppBskyActorDefs,
|
||||
} from '@atproto/api'
|
||||
|
||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||
|
||||
export interface ComposerOptsPostRef {
|
||||
@@ -31,7 +32,7 @@ export interface ComposerOptsQuote {
|
||||
}
|
||||
export interface ComposerOpts {
|
||||
replyTo?: ComposerOptsPostRef
|
||||
onPost?: () => void
|
||||
onPost?: (postUri: string | undefined) => void
|
||||
quote?: ComposerOptsQuote
|
||||
mention?: string // handle of user to mention
|
||||
openPicker?: (pos: DOMRect | undefined) => void
|
||||
|
||||
@@ -2,7 +2,6 @@ import React from 'react'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useGate} from '#/lib/statsig/statsig'
|
||||
import {
|
||||
ProgressGuideToast,
|
||||
ProgressGuideToastRef,
|
||||
@@ -61,7 +60,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
const {data: preferences} = usePreferencesQuery()
|
||||
const {mutateAsync, variables, isPending} =
|
||||
useSetActiveProgressGuideMutation()
|
||||
const gate = useGate()
|
||||
|
||||
const activeProgressGuide = (
|
||||
isPending ? variables : preferences?.bskyAppState?.activeProgressGuide
|
||||
@@ -89,9 +87,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
const controls = React.useMemo(() => {
|
||||
return {
|
||||
startProgressGuide(guide: ProgressGuideName) {
|
||||
if (!gate('new_user_progress_guide')) {
|
||||
return
|
||||
}
|
||||
if (guide === 'like-10-and-follow-7') {
|
||||
const guideObj = {
|
||||
guide: 'like-10-and-follow-7',
|
||||
@@ -148,7 +143,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
mutateAsync(guide?.isComplete ? undefined : guide)
|
||||
},
|
||||
}
|
||||
}, [activeProgressGuide, mutateAsync, gate, setLocalGuideState])
|
||||
}, [activeProgressGuide, mutateAsync, setLocalGuideState])
|
||||
|
||||
return (
|
||||
<ProgressGuideContext.Provider value={localGuideState}>
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react'
|
||||
|
||||
const LIKE_WINDOW = 100
|
||||
const FOLLOW_WINDOW = 100
|
||||
const FOLLOW_SUGGESTION_WINDOW = 100
|
||||
const SEEN_WINDOW = 100
|
||||
|
||||
export type SeenPost = {
|
||||
@@ -22,6 +23,10 @@ export type UserActionHistory = {
|
||||
* The last 100 DIDs the user has followed
|
||||
*/
|
||||
follows: string[]
|
||||
/*
|
||||
* The last 100 DIDs of suggested follows based on last follows
|
||||
*/
|
||||
followSuggestions: string[]
|
||||
/**
|
||||
* The last 100 post URIs the user has seen from the Discover feed only
|
||||
*/
|
||||
@@ -31,6 +36,7 @@ export type UserActionHistory = {
|
||||
const userActionHistory: UserActionHistory = {
|
||||
likes: [],
|
||||
follows: [],
|
||||
followSuggestions: [],
|
||||
seen: [],
|
||||
}
|
||||
|
||||
@@ -58,6 +64,13 @@ export function follow(dids: string[]) {
|
||||
.concat(dids)
|
||||
.slice(-FOLLOW_WINDOW)
|
||||
}
|
||||
|
||||
export function followSuggestion(dids: string[]) {
|
||||
userActionHistory.followSuggestions = userActionHistory.followSuggestions
|
||||
.concat(dids)
|
||||
.slice(-FOLLOW_SUGGESTION_WINDOW)
|
||||
}
|
||||
|
||||
export function unfollow(dids: string[]) {
|
||||
userActionHistory.follows = userActionHistory.follows.filter(
|
||||
uri => !dids.includes(uri),
|
||||
|
||||
@@ -1,25 +1,20 @@
|
||||
import React from 'react'
|
||||
import {Pressable, View} from 'react-native'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
|
||||
import {useAnalytics} from '#/lib/analytics/analytics'
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
import {logEvent} from '#/lib/statsig/statsig'
|
||||
import {s} from '#/lib/styles'
|
||||
import {isIOS, isNative} from '#/platform/detection'
|
||||
import {useSession} from '#/state/session'
|
||||
import {isIOS} from '#/platform/detection'
|
||||
import {
|
||||
useLoggedOutView,
|
||||
useLoggedOutViewControls,
|
||||
} from '#/state/shell/logged-out'
|
||||
import {useSetMinimalShellMode} from '#/state/shell/minimal-mode'
|
||||
import {NavigationProp} from 'lib/routes/types'
|
||||
import {useGate} from 'lib/statsig/statsig'
|
||||
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
|
||||
import {Text} from '#/view/com/util/text/Text'
|
||||
import {Login} from '#/screens/Login'
|
||||
import {Signup} from '#/screens/Signup'
|
||||
import {LandingScreen} from '#/screens/StarterPack/StarterPackLandingScreen'
|
||||
@@ -34,7 +29,6 @@ enum ScreenState {
|
||||
export {ScreenState as LoggedOutScreenState}
|
||||
|
||||
export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
|
||||
const {hasSession} = useSession()
|
||||
const {_} = useLingui()
|
||||
const pal = usePalette('default')
|
||||
const setMinimalShellMode = useSetMinimalShellMode()
|
||||
@@ -52,10 +46,7 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
|
||||
}
|
||||
})
|
||||
const {clearRequestedAccount} = useLoggedOutViewControls()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const gate = useGate()
|
||||
|
||||
const isFirstScreen = screenState === ScreenState.S_LoginOrCreateAccount
|
||||
React.useEffect(() => {
|
||||
screen('Login')
|
||||
setMinimalShellMode(true)
|
||||
@@ -68,10 +59,6 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
|
||||
clearRequestedAccount()
|
||||
}, [clearRequestedAccount, onDismiss])
|
||||
|
||||
const onPressSearch = React.useCallback(() => {
|
||||
navigation.navigate(`SearchTab`)
|
||||
}, [navigation])
|
||||
|
||||
return (
|
||||
<View testID="noSessionView" style={[s.hContentRegion, pal.view]}>
|
||||
<ErrorBoundary>
|
||||
@@ -98,39 +85,6 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
|
||||
}}
|
||||
/>
|
||||
</Pressable>
|
||||
) : isNative &&
|
||||
!hasSession &&
|
||||
isFirstScreen &&
|
||||
!gate('native_pwi_disabled') ? (
|
||||
<Pressable
|
||||
accessibilityHint={_(msg`Search for users`)}
|
||||
accessibilityLabel={_(msg`Search for users`)}
|
||||
accessibilityRole="button"
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
position: 'absolute',
|
||||
top: 20,
|
||||
right: 20,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
zIndex: 100,
|
||||
backgroundColor: pal.btn.backgroundColor,
|
||||
borderRadius: 100,
|
||||
}}
|
||||
onPress={onPressSearch}>
|
||||
<Text type="lg-bold" style={[pal.text]}>
|
||||
<Trans>Search</Trans>{' '}
|
||||
</Text>
|
||||
<FontAwesomeIcon
|
||||
icon="search"
|
||||
size={16}
|
||||
style={{
|
||||
color: String(pal.text.color),
|
||||
}}
|
||||
/>
|
||||
</Pressable>
|
||||
) : null}
|
||||
|
||||
{screenState === ScreenState.S_StarterPack ? (
|
||||
|
||||
@@ -392,7 +392,7 @@ export const ComposePost = observer(function ComposePost({
|
||||
emitPostCreated()
|
||||
}
|
||||
setLangPrefs.savePostLanguageToHistory()
|
||||
onPost?.()
|
||||
onPost?.(postUri)
|
||||
onClose()
|
||||
Toast.show(
|
||||
replyTo
|
||||
|
||||
@@ -91,7 +91,7 @@ export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) {
|
||||
type={replyTo.author.associated?.labeler ? 'labeler' : 'user'}
|
||||
/>
|
||||
<View style={styles.replyToPost}>
|
||||
<Text type="xl-medium" style={t.atoms.text}>
|
||||
<Text type="xl-medium" style={t.atoms.text} numberOfLines={1}>
|
||||
{sanitizeDisplayName(
|
||||
replyTo.author.displayName || sanitizeHandle(replyTo.author.handle),
|
||||
)}
|
||||
|
||||
@@ -85,71 +85,59 @@ export const TextInput = forwardRef(function TextInputImpl(
|
||||
const pastSuggestedUris = useRef(new Set<string>())
|
||||
const prevDetectedUris = useRef(new Map<string, LinkFacetMatch>())
|
||||
const onChangeText = useCallback(
|
||||
(newText: string) => {
|
||||
/*
|
||||
* This is a hack to bump the rendering of our styled
|
||||
* `textDecorated` to _after_ whatever processing is happening
|
||||
* within the `PasteInput` library. Without this, the elements in
|
||||
* `textDecorated` are not correctly painted to screen.
|
||||
*
|
||||
* NB: we tried a `0` timeout as well, but only positive values worked.
|
||||
*
|
||||
* @see https://github.com/bluesky-social/social-app/issues/929
|
||||
*/
|
||||
setTimeout(async () => {
|
||||
const mayBePaste = newText.length > prevLength.current + 1
|
||||
async (newText: string) => {
|
||||
const mayBePaste = newText.length > prevLength.current + 1
|
||||
|
||||
const newRt = new RichText({text: newText})
|
||||
newRt.detectFacetsWithoutResolution()
|
||||
setRichText(newRt)
|
||||
const newRt = new RichText({text: newText})
|
||||
newRt.detectFacetsWithoutResolution()
|
||||
setRichText(newRt)
|
||||
|
||||
const prefix = getMentionAt(
|
||||
newText,
|
||||
textInputSelection.current?.start || 0,
|
||||
)
|
||||
if (prefix) {
|
||||
setAutocompletePrefix(prefix.value)
|
||||
} else if (autocompletePrefix) {
|
||||
setAutocompletePrefix('')
|
||||
}
|
||||
const prefix = getMentionAt(
|
||||
newText,
|
||||
textInputSelection.current?.start || 0,
|
||||
)
|
||||
if (prefix) {
|
||||
setAutocompletePrefix(prefix.value)
|
||||
} else if (autocompletePrefix) {
|
||||
setAutocompletePrefix('')
|
||||
}
|
||||
|
||||
const nextDetectedUris = new Map<string, LinkFacetMatch>()
|
||||
if (newRt.facets) {
|
||||
for (const facet of newRt.facets) {
|
||||
for (const feature of facet.features) {
|
||||
if (AppBskyRichtextFacet.isLink(feature)) {
|
||||
if (isUriImage(feature.uri)) {
|
||||
const res = await downloadAndResize({
|
||||
uri: feature.uri,
|
||||
width: POST_IMG_MAX.width,
|
||||
height: POST_IMG_MAX.height,
|
||||
mode: 'contain',
|
||||
maxSize: POST_IMG_MAX.size,
|
||||
timeout: 15e3,
|
||||
})
|
||||
const nextDetectedUris = new Map<string, LinkFacetMatch>()
|
||||
if (newRt.facets) {
|
||||
for (const facet of newRt.facets) {
|
||||
for (const feature of facet.features) {
|
||||
if (AppBskyRichtextFacet.isLink(feature)) {
|
||||
if (isUriImage(feature.uri)) {
|
||||
const res = await downloadAndResize({
|
||||
uri: feature.uri,
|
||||
width: POST_IMG_MAX.width,
|
||||
height: POST_IMG_MAX.height,
|
||||
mode: 'contain',
|
||||
maxSize: POST_IMG_MAX.size,
|
||||
timeout: 15e3,
|
||||
})
|
||||
|
||||
if (res !== undefined) {
|
||||
onPhotoPasted(res.path)
|
||||
}
|
||||
} else {
|
||||
nextDetectedUris.set(feature.uri, {facet, rt: newRt})
|
||||
if (res !== undefined) {
|
||||
onPhotoPasted(res.path)
|
||||
}
|
||||
} else {
|
||||
nextDetectedUris.set(feature.uri, {facet, rt: newRt})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const suggestedUri = suggestLinkCardUri(
|
||||
mayBePaste,
|
||||
nextDetectedUris,
|
||||
prevDetectedUris.current,
|
||||
pastSuggestedUris.current,
|
||||
)
|
||||
prevDetectedUris.current = nextDetectedUris
|
||||
if (suggestedUri) {
|
||||
onNewLink(suggestedUri)
|
||||
}
|
||||
prevLength.current = newText.length
|
||||
}, 1)
|
||||
}
|
||||
const suggestedUri = suggestLinkCardUri(
|
||||
mayBePaste,
|
||||
nextDetectedUris,
|
||||
prevDetectedUris.current,
|
||||
pastSuggestedUris.current,
|
||||
)
|
||||
prevDetectedUris.current = nextDetectedUris
|
||||
if (suggestedUri) {
|
||||
onNewLink(suggestedUri)
|
||||
}
|
||||
prevLength.current = newText.length
|
||||
},
|
||||
[setRichText, autocompletePrefix, onPhotoPasted, onNewLink],
|
||||
)
|
||||
|
||||
@@ -16,8 +16,8 @@ export function VideoPreview({
|
||||
}) {
|
||||
const player = useVideoPlayer(video.uri, player => {
|
||||
player.loop = true
|
||||
player.muted = true
|
||||
player.play()
|
||||
player.volume = 0
|
||||
})
|
||||
|
||||
return (
|
||||
|
||||
@@ -3,6 +3,7 @@ import {View} from 'react-native'
|
||||
// @ts-expect-error no type definition
|
||||
import ProgressPie from 'react-native-progress/Pie'
|
||||
import {ImagePickerAsset} from 'expo-image-picker'
|
||||
import {Trans} from '@lingui/macro'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
@@ -46,7 +47,9 @@ export function VideoTranscodeProgress({
|
||||
color={t.atoms.text.color}
|
||||
progress={progress}
|
||||
/>
|
||||
<Text>Compressing...</Text>
|
||||
<Text>
|
||||
<Trans>Compressing...</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
import React from 'react'
|
||||
import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
|
||||
import {AppBskyGraphDefs, AtUri, RichText} from '@atproto/api'
|
||||
import {Trans} from '@lingui/macro'
|
||||
|
||||
import {useSession} from '#/state/session'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {makeProfileLink} from 'lib/routes/links'
|
||||
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||
import {sanitizeHandle} from 'lib/strings/handles'
|
||||
import {s} from 'lib/styles'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {RichText as RichTextCom} from '#/components/RichText'
|
||||
import {Link} from '../util/Link'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {UserAvatar} from '../util/UserAvatar'
|
||||
|
||||
export const ListCard = ({
|
||||
testID,
|
||||
list,
|
||||
noBg,
|
||||
noBorder,
|
||||
renderButton,
|
||||
style,
|
||||
}: {
|
||||
testID?: string
|
||||
list: AppBskyGraphDefs.ListView
|
||||
noBg?: boolean
|
||||
noBorder?: boolean
|
||||
renderButton?: () => JSX.Element
|
||||
style?: StyleProp<ViewStyle>
|
||||
}) => {
|
||||
const pal = usePalette('default')
|
||||
const {currentAccount} = useSession()
|
||||
|
||||
const rkey = React.useMemo(() => {
|
||||
try {
|
||||
const urip = new AtUri(list.uri)
|
||||
return urip.rkey
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}, [list])
|
||||
|
||||
const descriptionRichText = React.useMemo(() => {
|
||||
if (list.description) {
|
||||
return new RichText({
|
||||
text: list.description,
|
||||
facets: list.descriptionFacets,
|
||||
})
|
||||
}
|
||||
return undefined
|
||||
}, [list])
|
||||
|
||||
return (
|
||||
<Link
|
||||
testID={testID}
|
||||
style={[
|
||||
styles.outer,
|
||||
pal.border,
|
||||
noBorder && styles.outerNoBorder,
|
||||
!noBg && pal.view,
|
||||
style,
|
||||
]}
|
||||
href={makeProfileLink(list.creator, 'lists', rkey)}
|
||||
title={list.name}
|
||||
asAnchor
|
||||
anchorNoUnderline>
|
||||
<View style={styles.layout}>
|
||||
<View style={styles.layoutAvi}>
|
||||
<UserAvatar type="list" size={40} avatar={list.avatar} />
|
||||
</View>
|
||||
<View style={styles.layoutContent}>
|
||||
<Text
|
||||
type="lg"
|
||||
style={[s.bold, pal.text]}
|
||||
numberOfLines={1}
|
||||
lineHeight={1.2}>
|
||||
{sanitizeDisplayName(list.name)}
|
||||
</Text>
|
||||
<Text type="md" style={[pal.textLight]} numberOfLines={1}>
|
||||
{list.purpose === 'app.bsky.graph.defs#curatelist' &&
|
||||
(list.creator.did === currentAccount?.did ? (
|
||||
<Trans>User list by you</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
User list by {sanitizeHandle(list.creator.handle, '@')}
|
||||
</Trans>
|
||||
))}
|
||||
{list.purpose === 'app.bsky.graph.defs#modlist' &&
|
||||
(list.creator.did === currentAccount?.did ? (
|
||||
<Trans>Moderation list by you</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
Moderation list by {sanitizeHandle(list.creator.handle, '@')}
|
||||
</Trans>
|
||||
))}
|
||||
</Text>
|
||||
<View style={s.flexRow}>
|
||||
{list.viewer?.muted ? (
|
||||
<View style={[s.mt5, pal.btn, styles.pill]}>
|
||||
<Text type="xs" style={pal.text}>
|
||||
<Trans>Muted</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{list.viewer?.blocked ? (
|
||||
<View style={[s.mt5, pal.btn, styles.pill]}>
|
||||
<Text type="xs" style={pal.text}>
|
||||
<Trans>Blocked</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
{renderButton ? (
|
||||
<View style={styles.layoutButton}>{renderButton()}</View>
|
||||
) : undefined}
|
||||
</View>
|
||||
{descriptionRichText ? (
|
||||
<View style={styles.details}>
|
||||
<RichTextCom
|
||||
style={[a.flex_1]}
|
||||
numberOfLines={20}
|
||||
value={descriptionRichText}
|
||||
/>
|
||||
</View>
|
||||
) : undefined}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
outer: {
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
paddingHorizontal: 6,
|
||||
},
|
||||
outerNoBorder: {
|
||||
borderTopWidth: 0,
|
||||
},
|
||||
layout: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
layoutAvi: {
|
||||
width: 54,
|
||||
paddingLeft: 4,
|
||||
paddingTop: 8,
|
||||
paddingBottom: 10,
|
||||
},
|
||||
avi: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
resizeMode: 'cover',
|
||||
},
|
||||
layoutContent: {
|
||||
flex: 1,
|
||||
paddingRight: 10,
|
||||
paddingTop: 10,
|
||||
paddingBottom: 10,
|
||||
},
|
||||
layoutButton: {
|
||||
paddingRight: 10,
|
||||
},
|
||||
details: {
|
||||
paddingLeft: 54,
|
||||
paddingRight: 10,
|
||||
paddingBottom: 10,
|
||||
},
|
||||
pill: {
|
||||
borderRadius: 4,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
},
|
||||
btn: {
|
||||
paddingVertical: 7,
|
||||
borderRadius: 50,
|
||||
marginLeft: 6,
|
||||
paddingHorizontal: 14,
|
||||
},
|
||||
})
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
FlatList as RNFlatList,
|
||||
RefreshControl,
|
||||
StyleProp,
|
||||
StyleSheet,
|
||||
View,
|
||||
ViewStyle,
|
||||
} from 'react-native'
|
||||
@@ -18,10 +17,13 @@ import {MyListsFilter, useMyListsQuery} from '#/state/queries/my-lists'
|
||||
import {useAnalytics} from 'lib/analytics/analytics'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {s} from 'lib/styles'
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {useModerationOpts} from 'state/preferences/moderation-opts'
|
||||
import {EmptyState} from 'view/com/util/EmptyState'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import * as ListCard from '#/components/ListCard'
|
||||
import {ErrorMessage} from '../util/error/ErrorMessage'
|
||||
import {List} from '../util/List'
|
||||
import {ListCard} from './ListCard'
|
||||
|
||||
const LOADING = {_reactKey: '__loading__'}
|
||||
const EMPTY = {_reactKey: '__empty__'}
|
||||
@@ -41,8 +43,10 @@ export function MyLists({
|
||||
testID?: string
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
const t = useTheme()
|
||||
const {track} = useAnalytics()
|
||||
const {_} = useLingui()
|
||||
const moderationOpts = useModerationOpts()
|
||||
const [isPTRing, setIsPTRing] = React.useState(false)
|
||||
const {data, isFetching, isFetched, isError, error, refetch} =
|
||||
useMyListsQuery(filter)
|
||||
@@ -53,7 +57,7 @@ export function MyLists({
|
||||
if (isError && isEmpty) {
|
||||
items = items.concat([ERROR_ITEM])
|
||||
}
|
||||
if (!isFetched && isFetching) {
|
||||
if ((!isFetched && isFetching) || !moderationOpts) {
|
||||
items = items.concat([LOADING])
|
||||
} else if (isEmpty) {
|
||||
items = items.concat([EMPTY])
|
||||
@@ -61,7 +65,7 @@ export function MyLists({
|
||||
items = items.concat(data)
|
||||
}
|
||||
return items
|
||||
}, [isError, isEmpty, isFetched, isFetching, data])
|
||||
}, [isError, isEmpty, isFetched, isFetching, moderationOpts, data])
|
||||
|
||||
// events
|
||||
// =
|
||||
@@ -85,7 +89,6 @@ export function MyLists({
|
||||
if (item === EMPTY) {
|
||||
return (
|
||||
<EmptyState
|
||||
key={item._reactKey}
|
||||
icon="list-ul"
|
||||
message={_(msg`You have no lists.`)}
|
||||
testID="listsEmpty"
|
||||
@@ -94,14 +97,13 @@ export function MyLists({
|
||||
} else if (item === ERROR_ITEM) {
|
||||
return (
|
||||
<ErrorMessage
|
||||
key={item._reactKey}
|
||||
message={cleanError(error)}
|
||||
onPressTryAgain={onRefresh}
|
||||
/>
|
||||
)
|
||||
} else if (item === LOADING) {
|
||||
return (
|
||||
<View key={item._reactKey} style={{padding: 20}}>
|
||||
<View style={{padding: 20}}>
|
||||
<ActivityIndicator />
|
||||
</View>
|
||||
)
|
||||
@@ -109,15 +111,18 @@ export function MyLists({
|
||||
return renderItem ? (
|
||||
renderItem(item, index)
|
||||
) : (
|
||||
<ListCard
|
||||
key={item.uri}
|
||||
list={item}
|
||||
testID={`list-${item.name}`}
|
||||
style={styles.item}
|
||||
/>
|
||||
<View
|
||||
style={[
|
||||
(index !== 0 || isWeb) && a.border_t,
|
||||
t.atoms.border_contrast_low,
|
||||
a.px_lg,
|
||||
a.py_lg,
|
||||
]}>
|
||||
<ListCard.Default view={item} />
|
||||
</View>
|
||||
)
|
||||
},
|
||||
[error, onRefresh, renderItem, _],
|
||||
[renderItem, t.atoms.border_contrast_low, _, error, onRefresh],
|
||||
)
|
||||
|
||||
if (inline) {
|
||||
@@ -166,10 +171,3 @@ export function MyLists({
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
item: {
|
||||
paddingHorizontal: 18,
|
||||
paddingVertical: 4,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -25,7 +25,6 @@ import {useLingui} from '@lingui/react'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {useGate} from '#/lib/statsig/statsig'
|
||||
import {parseTenorGif} from '#/lib/strings/embed-player'
|
||||
import {logger} from '#/logger'
|
||||
import {FeedNotification} from '#/state/queries/notifications/feed'
|
||||
@@ -87,7 +86,6 @@ let FeedItem = ({
|
||||
const pal = usePalette('default')
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const gate = useGate()
|
||||
const [isAuthorsExpanded, setAuthorsExpanded] = useState<boolean>(false)
|
||||
const itemHref = useMemo(() => {
|
||||
if (item.type === 'post-like' || item.type === 'repost') {
|
||||
@@ -207,7 +205,7 @@ let FeedItem = ({
|
||||
}
|
||||
}
|
||||
|
||||
if (isFollowBack && gate('ungroup_follow_backs')) {
|
||||
if (isFollowBack) {
|
||||
action = _(msg`followed you back`)
|
||||
} else {
|
||||
action = _(msg`followed you`)
|
||||
@@ -255,6 +253,7 @@ let FeedItem = ({
|
||||
borderColor: pal.colors.unreadNotifBorder,
|
||||
},
|
||||
{borderTopWidth: hideTopBorder ? 0 : StyleSheet.hairlineWidth},
|
||||
a.overflow_hidden,
|
||||
]}
|
||||
href={itemHref}
|
||||
noFeedback
|
||||
@@ -547,7 +546,7 @@ function ExpandedAuthorsList({
|
||||
}, [heightInterp, visible])
|
||||
|
||||
return (
|
||||
<Animated.View style={[heightStyle, styles.overflowHidden]}>
|
||||
<Animated.View style={[a.overflow_hidden, heightStyle]}>
|
||||
{visible &&
|
||||
authors.map(author => (
|
||||
<NewLink
|
||||
@@ -643,9 +642,6 @@ function AdditionalPostText({post}: {post?: AppBskyFeedDefs.PostView}) {
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overflowHidden: {
|
||||
overflow: 'hidden',
|
||||
},
|
||||
pointer: isWeb
|
||||
? {
|
||||
// @ts-ignore web only
|
||||
|
||||
@@ -193,13 +193,11 @@ const styles = StyleSheet.create({
|
||||
tabBarContainer: {
|
||||
// @ts-ignore web-only
|
||||
position: 'sticky',
|
||||
overflow: 'hidden',
|
||||
top: 0,
|
||||
zIndex: 1,
|
||||
},
|
||||
tabBarContainerDesktop: {
|
||||
marginHorizontal: 'auto',
|
||||
paddingHorizontal: 2,
|
||||
width: 600,
|
||||
borderLeftWidth: 1,
|
||||
borderRightWidth: 1,
|
||||
|
||||
@@ -180,7 +180,7 @@ const desktopStyles = StyleSheet.create({
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: '100%',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import React, {useEffect, useRef} from 'react'
|
||||
import {useWindowDimensions, View} from 'react-native'
|
||||
import React, {useRef} from 'react'
|
||||
import {StyleSheet, useWindowDimensions, View} from 'react-native'
|
||||
import {runOnJS} from 'react-native-reanimated'
|
||||
import Animated from 'react-native-reanimated'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {AppBskyFeedDefs} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
|
||||
import {clamp} from '#/lib/numbers'
|
||||
import {ScrollProvider} from '#/lib/ScrollContext'
|
||||
import {isAndroid, isNative, isWeb} from '#/platform/detection'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
@@ -21,7 +24,9 @@ import {
|
||||
} from '#/state/queries/post-thread'
|
||||
import {usePreferencesQuery} from '#/state/queries/preferences'
|
||||
import {useSession} from '#/state/session'
|
||||
import {useComposerControls} from '#/state/shell'
|
||||
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
|
||||
import {useMinimalShellFabTransform} from 'lib/hooks/useMinimalShellTransform'
|
||||
import {useSetTitle} from 'lib/hooks/useSetTitle'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||
@@ -30,9 +35,9 @@ import {CenteredView} from 'view/com/util/Views'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {ComposePrompt} from '../composer/Prompt'
|
||||
import {List, ListMethods} from '../util/List'
|
||||
import {ViewHeader} from '../util/ViewHeader'
|
||||
import {PostThreadComposePrompt} from './PostThreadComposePrompt'
|
||||
import {PostThreadItem} from './PostThreadItem'
|
||||
import {PostThreadLoadMore} from './PostThreadLoadMore'
|
||||
import {PostThreadShowHiddenReplies} from './PostThreadShowHiddenReplies'
|
||||
@@ -80,15 +85,7 @@ const keyExtractor = (item: RowItem) => {
|
||||
return item._reactKey
|
||||
}
|
||||
|
||||
export function PostThread({
|
||||
uri,
|
||||
onCanReply,
|
||||
onPressReply,
|
||||
}: {
|
||||
uri: string | undefined
|
||||
onCanReply: (canReply: boolean) => void
|
||||
onPressReply: () => unknown
|
||||
}) {
|
||||
export function PostThread({uri}: {uri: string | undefined}) {
|
||||
const {hasSession, currentAccount} = useSession()
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
@@ -163,12 +160,22 @@ export function PostThread({
|
||||
return cache
|
||||
}, [thread, moderationOpts])
|
||||
|
||||
const [justPostedUris, setJustPostedUris] = React.useState(
|
||||
() => new Set<string>(),
|
||||
)
|
||||
|
||||
const skeleton = React.useMemo(() => {
|
||||
const threadViewPrefs = preferences?.threadViewPrefs
|
||||
if (!threadViewPrefs || !thread) return null
|
||||
|
||||
return createThreadSkeleton(
|
||||
sortThread(thread, threadViewPrefs, threadModerationCache, currentDid),
|
||||
sortThread(
|
||||
thread,
|
||||
threadViewPrefs,
|
||||
threadModerationCache,
|
||||
currentDid,
|
||||
justPostedUris,
|
||||
),
|
||||
!!currentDid,
|
||||
treeView,
|
||||
threadModerationCache,
|
||||
@@ -181,6 +188,7 @@ export function PostThread({
|
||||
treeView,
|
||||
threadModerationCache,
|
||||
hiddenRepliesState,
|
||||
justPostedUris,
|
||||
])
|
||||
|
||||
const error = React.useMemo(() => {
|
||||
@@ -210,14 +218,6 @@ export function PostThread({
|
||||
return null
|
||||
}, [thread, skeleton?.highlightedPost, isThreadError, _, threadError])
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
onCanReply(false)
|
||||
} else if (rootPost) {
|
||||
onCanReply(!rootPost.viewer?.replyDisabled)
|
||||
}
|
||||
}, [rootPost, onCanReply, error])
|
||||
|
||||
// construct content
|
||||
const posts = React.useMemo(() => {
|
||||
if (!skeleton) return []
|
||||
@@ -313,6 +313,38 @@ export function PostThread({
|
||||
setMaxReplies(prev => prev + 50)
|
||||
}, [isFetching, maxReplies, posts.length])
|
||||
|
||||
const onPostReply = React.useCallback(
|
||||
(postUri: string | undefined) => {
|
||||
refetch()
|
||||
if (postUri) {
|
||||
setJustPostedUris(set => {
|
||||
const nextSet = new Set(set)
|
||||
nextSet.add(postUri)
|
||||
return nextSet
|
||||
})
|
||||
}
|
||||
},
|
||||
[refetch],
|
||||
)
|
||||
|
||||
const {openComposer} = useComposerControls()
|
||||
const onPressReply = React.useCallback(() => {
|
||||
if (thread?.type !== 'post') {
|
||||
return
|
||||
}
|
||||
openComposer({
|
||||
replyTo: {
|
||||
uri: thread.post.uri,
|
||||
cid: thread.post.cid,
|
||||
text: thread.record.text,
|
||||
author: thread.post.author,
|
||||
embed: thread.post.embed,
|
||||
},
|
||||
onPost: onPostReply,
|
||||
})
|
||||
}, [openComposer, thread, onPostReply])
|
||||
|
||||
const canReply = !error && rootPost && !rootPost.viewer?.replyDisabled
|
||||
const hasParents =
|
||||
skeleton?.highlightedPost?.type === 'post' &&
|
||||
(skeleton.highlightedPost.ctx.isParentLoading ||
|
||||
@@ -324,7 +356,9 @@ export function PostThread({
|
||||
if (item === REPLY_PROMPT && hasSession) {
|
||||
return (
|
||||
<View>
|
||||
{!isMobile && <ComposePrompt onPressCompose={onPressReply} />}
|
||||
{!isMobile && (
|
||||
<PostThreadComposePrompt onPressCompose={onPressReply} />
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
} else if (item === SHOW_HIDDEN_REPLIES || item === SHOW_MUTED_REPLIES) {
|
||||
@@ -406,7 +440,7 @@ export function PostThread({
|
||||
HiddenRepliesState.ShowAndOverridePostHider &&
|
||||
item.ctx.depth > 0
|
||||
}
|
||||
onPostReply={refetch}
|
||||
onPostReply={onPostReply}
|
||||
hideTopBorder={index === 0 && !item.ctx.isParentLoading}
|
||||
/>
|
||||
</View>
|
||||
@@ -473,10 +507,30 @@ export function PostThread({
|
||||
sideBorders={false}
|
||||
/>
|
||||
</ScrollProvider>
|
||||
{isMobile && canReply && hasSession && (
|
||||
<MobileComposePrompt onPressReply={onPressReply} />
|
||||
)}
|
||||
</CenteredView>
|
||||
)
|
||||
}
|
||||
|
||||
function MobileComposePrompt({onPressReply}: {onPressReply: () => unknown}) {
|
||||
const safeAreaInsets = useSafeAreaInsets()
|
||||
const fabMinimalShellTransform = useMinimalShellFabTransform()
|
||||
return (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.prompt,
|
||||
fabMinimalShellTransform,
|
||||
{
|
||||
bottom: clamp(safeAreaInsets.bottom, 15, 30),
|
||||
},
|
||||
]}>
|
||||
<PostThreadComposePrompt onPressCompose={onPressReply} />
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
|
||||
function isThreadPost(v: unknown): v is ThreadPost {
|
||||
return !!v && typeof v === 'object' && 'type' in v && v.type === 'post'
|
||||
}
|
||||
@@ -622,3 +676,12 @@ function hasBranchingReplies(node?: ThreadNode) {
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
prompt: {
|
||||
// @ts-ignore web-only
|
||||
position: isWeb ? 'fixed' : 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
},
|
||||
})
|
||||
|
||||
+5
-1
@@ -10,7 +10,11 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {UserAvatar} from '../util/UserAvatar'
|
||||
|
||||
export function ComposePrompt({onPressCompose}: {onPressCompose: () => void}) {
|
||||
export function PostThreadComposePrompt({
|
||||
onPressCompose,
|
||||
}: {
|
||||
onPressCompose: () => void
|
||||
}) {
|
||||
const {currentAccount} = useSession()
|
||||
const {data: profile} = useProfileQuery({did: currentAccount?.did})
|
||||
const pal = usePalette('default')
|
||||
@@ -75,7 +75,7 @@ export function PostThreadItem({
|
||||
showParentReplyLine?: boolean
|
||||
hasPrecedingItem: boolean
|
||||
overrideBlur: boolean
|
||||
onPostReply: () => void
|
||||
onPostReply: (postUri: string | undefined) => void
|
||||
hideTopBorder?: boolean
|
||||
}) {
|
||||
const postShadowed = usePostShadow(post)
|
||||
@@ -169,7 +169,7 @@ let PostThreadItemLoaded = ({
|
||||
showParentReplyLine?: boolean
|
||||
hasPrecedingItem: boolean
|
||||
overrideBlur: boolean
|
||||
onPostReply: () => void
|
||||
onPostReply: (postUri: string | undefined) => void
|
||||
hideTopBorder?: boolean
|
||||
}): React.ReactNode => {
|
||||
const pal = usePalette('default')
|
||||
@@ -660,7 +660,7 @@ function ExpandedPostDetails({
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.flex_wrap,
|
||||
a.gap_sm,
|
||||
a.gap_xs,
|
||||
s.mt2,
|
||||
s.mb10,
|
||||
]}>
|
||||
@@ -742,6 +742,7 @@ const styles = StyleSheet.create({
|
||||
flexWrap: 'wrap',
|
||||
paddingBottom: 4,
|
||||
paddingRight: 10,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
postTextLargeContainer: {
|
||||
paddingHorizontal: 0,
|
||||
|
||||
@@ -12,17 +12,17 @@ import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {MAX_POST_LINES} from '#/lib/constants'
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
|
||||
import {makeProfileLink} from '#/lib/routes/links'
|
||||
import {countLines} from '#/lib/strings/helpers'
|
||||
import {colors, s} from '#/lib/styles'
|
||||
import {POST_TOMBSTONE, Shadow, usePostShadow} from '#/state/cache/post-shadow'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {precacheProfile} from '#/state/queries/profile'
|
||||
import {useSession} from '#/state/session'
|
||||
import {useComposerControls} from '#/state/shell/composer'
|
||||
import {MAX_POST_LINES} from 'lib/constants'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {makeProfileLink} from 'lib/routes/links'
|
||||
import {countLines} from 'lib/strings/helpers'
|
||||
import {colors, s} from 'lib/styles'
|
||||
import {precacheProfile} from 'state/queries/profile'
|
||||
import {AviFollowButton} from '#/view/com/posts/AviFollowButton'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
|
||||
@@ -280,6 +280,7 @@ const styles = StyleSheet.create({
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
replyLine: {
|
||||
position: 'absolute',
|
||||
|
||||
@@ -7,7 +7,6 @@ import {useNavigation} from '@react-navigation/native'
|
||||
|
||||
import {createHitslop} from '#/lib/constants'
|
||||
import {NavigationProp} from '#/lib/routes/types'
|
||||
import {useGate} from '#/lib/statsig/statsig'
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
||||
import {useSession} from '#/state/session'
|
||||
@@ -37,7 +36,6 @@ export function AviFollowButton({
|
||||
profile: profile,
|
||||
logContext: 'AvatarButton',
|
||||
})
|
||||
const gate = useGate()
|
||||
const {currentAccount, hasSession} = useSession()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
|
||||
@@ -80,7 +78,7 @@ export function AviFollowButton({
|
||||
},
|
||||
]
|
||||
|
||||
return hasSession && gate('show_avi_follow_button') ? (
|
||||
return hasSession ? (
|
||||
<View style={a.relative}>
|
||||
{children}
|
||||
|
||||
|
||||
@@ -349,8 +349,7 @@ let Feed = ({
|
||||
const shouldShow =
|
||||
(interstitial.type === feedInterstitialType &&
|
||||
gate('suggested_feeds_interstitial')) ||
|
||||
(interstitial.type === followInterstitialType &&
|
||||
gate('suggested_follows_interstitial')) ||
|
||||
interstitial.type === followInterstitialType ||
|
||||
interstitial.type === progressGuideInterstitialType
|
||||
|
||||
if (shouldShow) {
|
||||
@@ -481,9 +480,7 @@ let Feed = ({
|
||||
// -prf
|
||||
return <DiscoverFallbackHeader />
|
||||
}
|
||||
return (
|
||||
<FeedSlice slice={item.slice} hideTopBorder={index === 0 && !isWeb} />
|
||||
)
|
||||
return <FeedSlice slice={item.slice} hideTopBorder={index === 0} />
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, {memo, useMemo, useState} from 'react'
|
||||
import React, {memo, useId, useMemo, useState} from 'react'
|
||||
import {StyleSheet, View} from 'react-native'
|
||||
import {
|
||||
AppBskyActorDefs,
|
||||
@@ -63,6 +63,7 @@ interface FeedItemProps {
|
||||
feedContext: string | undefined
|
||||
hideTopBorder?: boolean
|
||||
isParentBlocked?: boolean
|
||||
isParentNotFound?: boolean
|
||||
}
|
||||
|
||||
export function FeedItem({
|
||||
@@ -78,6 +79,7 @@ export function FeedItem({
|
||||
isThreadParent,
|
||||
hideTopBorder,
|
||||
isParentBlocked,
|
||||
isParentNotFound,
|
||||
}: FeedItemProps & {post: AppBskyFeedDefs.PostView}): React.ReactNode {
|
||||
const postShadowed = usePostShadow(post)
|
||||
const richText = useMemo(
|
||||
@@ -109,6 +111,7 @@ export function FeedItem({
|
||||
isThreadParent={isThreadParent}
|
||||
hideTopBorder={hideTopBorder}
|
||||
isParentBlocked={isParentBlocked}
|
||||
isParentNotFound={isParentNotFound}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -129,6 +132,7 @@ let FeedItemInner = ({
|
||||
isThreadParent,
|
||||
hideTopBorder,
|
||||
isParentBlocked,
|
||||
isParentNotFound,
|
||||
}: FeedItemProps & {
|
||||
richText: RichTextAPI
|
||||
post: Shadow<AppBskyFeedDefs.PostView>
|
||||
@@ -137,7 +141,6 @@ let FeedItemInner = ({
|
||||
const {openComposer} = useComposerControls()
|
||||
const pal = usePalette('default')
|
||||
const {_} = useLingui()
|
||||
const gate = useGate()
|
||||
|
||||
const href = useMemo(() => {
|
||||
const urip = new AtUri(post.uri)
|
||||
@@ -345,9 +348,14 @@ let FeedItemInner = ({
|
||||
postHref={href}
|
||||
onOpenAuthor={onOpenAuthor}
|
||||
/>
|
||||
{showReplyTo && (parentAuthor || isParentBlocked) && (
|
||||
<ReplyToLabel blocked={isParentBlocked} profile={parentAuthor} />
|
||||
)}
|
||||
{showReplyTo &&
|
||||
(parentAuthor || isParentBlocked || isParentNotFound) && (
|
||||
<ReplyToLabel
|
||||
blocked={isParentBlocked}
|
||||
notFound={isParentNotFound}
|
||||
profile={parentAuthor}
|
||||
/>
|
||||
)}
|
||||
<LabelsOnMyPost post={post} />
|
||||
<PostContent
|
||||
moderation={moderation}
|
||||
@@ -356,9 +364,7 @@ let FeedItemInner = ({
|
||||
postAuthor={post.author}
|
||||
onOpenEmbed={onOpenEmbed}
|
||||
/>
|
||||
{gate('video_debug') && (
|
||||
<VideoEmbed source="https://lumi.jazco.dev/watch/did:plc:q6gjnaw2blty4crticxkmujt/Qmc8w93UpTa2adJHg4ZhnDPrBs1EsbzrekzPcqF5SwusuZ/playlist.m3u8" />
|
||||
)}
|
||||
<VideoDebug />
|
||||
<PostCtrls
|
||||
post={post}
|
||||
record={record}
|
||||
@@ -441,9 +447,11 @@ PostContent = memo(PostContent)
|
||||
function ReplyToLabel({
|
||||
profile,
|
||||
blocked,
|
||||
notFound,
|
||||
}: {
|
||||
profile: AppBskyActorDefs.ProfileViewBasic | undefined
|
||||
blocked?: boolean
|
||||
notFound?: boolean
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
const {currentAccount} = useSession()
|
||||
@@ -451,6 +459,8 @@ function ReplyToLabel({
|
||||
let label
|
||||
if (blocked) {
|
||||
label = <Trans context="description">Reply to a blocked post</Trans>
|
||||
} else if (notFound) {
|
||||
label = <Trans context="description">Reply to an unknown post</Trans>
|
||||
} else if (profile != null) {
|
||||
const isMe = profile.did === currentAccount?.did
|
||||
if (isMe) {
|
||||
@@ -501,6 +511,19 @@ function ReplyToLabel({
|
||||
)
|
||||
}
|
||||
|
||||
function VideoDebug() {
|
||||
const gate = useGate()
|
||||
const id = useId()
|
||||
|
||||
if (!gate('video_debug')) return null
|
||||
|
||||
return (
|
||||
<VideoEmbed
|
||||
source={`https://lumi.jazco.dev/watch/did:plc:q6gjnaw2blty4crticxkmujt/Qmc8w93UpTa2adJHg4ZhnDPrBs1EsbzrekzPcqF5SwusuZ/playlist.m3u8?ignore_me_just_testing_frontend_stuff=${id}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
outer: {
|
||||
paddingLeft: 10,
|
||||
@@ -544,6 +567,7 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
paddingBottom: 2,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
contentHiderChild: {
|
||||
marginTop: 6,
|
||||
|
||||
@@ -36,6 +36,7 @@ let FeedSlice = ({
|
||||
isThreadChild={isThreadChildAt(slice.items, 0)}
|
||||
hideTopBorder={hideTopBorder}
|
||||
isParentBlocked={slice.items[0].isParentBlocked}
|
||||
isParentNotFound={slice.items[0].isParentNotFound}
|
||||
/>
|
||||
<ViewFullThread uri={slice.items[0].uri} />
|
||||
<FeedItem
|
||||
@@ -53,6 +54,7 @@ let FeedSlice = ({
|
||||
isThreadParent={isThreadParentAt(slice.items, beforeLast)}
|
||||
isThreadChild={isThreadChildAt(slice.items, beforeLast)}
|
||||
isParentBlocked={slice.items[beforeLast].isParentBlocked}
|
||||
isParentNotFound={slice.items[beforeLast].isParentNotFound}
|
||||
/>
|
||||
<FeedItem
|
||||
key={slice.items[last]._reactKey}
|
||||
@@ -66,6 +68,7 @@ let FeedSlice = ({
|
||||
isThreadParent={isThreadParentAt(slice.items, last)}
|
||||
isThreadChild={isThreadChildAt(slice.items, last)}
|
||||
isParentBlocked={slice.items[last].isParentBlocked}
|
||||
isParentNotFound={slice.items[last].isParentNotFound}
|
||||
isThreadLastChild
|
||||
/>
|
||||
</>
|
||||
@@ -90,6 +93,7 @@ let FeedSlice = ({
|
||||
isThreadChildAt(slice.items, i) && slice.items.length === i + 1
|
||||
}
|
||||
isParentBlocked={slice.items[i].isParentBlocked}
|
||||
isParentNotFound={slice.items[i].isParentNotFound}
|
||||
hideTopBorder={hideTopBorder && i === 0}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -20,7 +20,7 @@ const BTN = {height: 1, width: 1, backgroundColor: 'red'}
|
||||
|
||||
export function TestCtrls() {
|
||||
const queryClient = useQueryClient()
|
||||
const {logout, login} = useSessionApi()
|
||||
const {logoutEveryAccount, login} = useSessionApi()
|
||||
const {openModal} = useModalControls()
|
||||
const onboardingDispatch = useOnboardingDispatch()
|
||||
const {setShowLoggedOut} = useLoggedOutViewControls()
|
||||
@@ -60,7 +60,7 @@ export function TestCtrls() {
|
||||
/>
|
||||
<Pressable
|
||||
testID="e2eSignOut"
|
||||
onPress={() => logout('Settings')}
|
||||
onPress={() => logoutEveryAccount('Settings')}
|
||||
accessibilityRole="button"
|
||||
style={BTN}
|
||||
/>
|
||||
|
||||
@@ -4,26 +4,27 @@ import {
|
||||
FontAwesomeIcon,
|
||||
FontAwesomeIconStyle,
|
||||
} from '@fortawesome/react-native-fontawesome'
|
||||
import {s} from 'lib/styles'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {DropdownItem, NativeDropdown} from './forms/NativeDropdown'
|
||||
import * as Toast from '../../com/util/Toast'
|
||||
import {useSessionApi, SessionAccount} from '#/state/session'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {SessionAccount, useSessionApi} from '#/state/session'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {s} from 'lib/styles'
|
||||
import {useDialogControl} from '#/components/Dialog'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import * as Toast from '../../com/util/Toast'
|
||||
import {DropdownItem, NativeDropdown} from './forms/NativeDropdown'
|
||||
|
||||
export function AccountDropdownBtn({account}: {account: SessionAccount}) {
|
||||
const pal = usePalette('default')
|
||||
const {removeAccount} = useSessionApi()
|
||||
const removePromptControl = useDialogControl()
|
||||
const {_} = useLingui()
|
||||
|
||||
const items: DropdownItem[] = [
|
||||
{
|
||||
label: _(msg`Remove account`),
|
||||
onPress: () => {
|
||||
removeAccount(account)
|
||||
Toast.show(_(msg`Account removed from quick access`))
|
||||
},
|
||||
onPress: removePromptControl.open,
|
||||
icon: {
|
||||
ios: {
|
||||
name: 'trash',
|
||||
@@ -34,17 +35,32 @@ export function AccountDropdownBtn({account}: {account: SessionAccount}) {
|
||||
},
|
||||
]
|
||||
return (
|
||||
<Pressable accessibilityRole="button" style={s.pl10}>
|
||||
<NativeDropdown
|
||||
testID="accountSettingsDropdownBtn"
|
||||
items={items}
|
||||
accessibilityLabel={_(msg`Account options`)}
|
||||
accessibilityHint="">
|
||||
<FontAwesomeIcon
|
||||
icon="ellipsis-h"
|
||||
style={pal.textLight as FontAwesomeIconStyle}
|
||||
/>
|
||||
</NativeDropdown>
|
||||
</Pressable>
|
||||
<>
|
||||
<Pressable accessibilityRole="button" style={s.pl10}>
|
||||
<NativeDropdown
|
||||
testID="accountSettingsDropdownBtn"
|
||||
items={items}
|
||||
accessibilityLabel={_(msg`Account options`)}
|
||||
accessibilityHint="">
|
||||
<FontAwesomeIcon
|
||||
icon="ellipsis-h"
|
||||
style={pal.textLight as FontAwesomeIconStyle}
|
||||
/>
|
||||
</NativeDropdown>
|
||||
</Pressable>
|
||||
<Prompt.Basic
|
||||
control={removePromptControl}
|
||||
title={_(msg`Remove from quick access?`)}
|
||||
description={_(
|
||||
msg`This will remove @${account.handle} from the quick access list.`,
|
||||
)}
|
||||
onConfirm={() => {
|
||||
removeAccount(account)
|
||||
Toast.show(_(msg`Account removed from quick access`))
|
||||
}}
|
||||
confirmButtonCta={_(msg`Remove`)}
|
||||
confirmButtonColor="negative"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -91,11 +91,7 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => {
|
||||
</Text>
|
||||
</ProfileHoverCard>
|
||||
{!isAndroid && (
|
||||
<Text
|
||||
type="md"
|
||||
style={pal.textLight}
|
||||
lineHeight={1.2}
|
||||
accessible={false}>
|
||||
<Text type="md" style={pal.textLight} accessible={false}>
|
||||
·
|
||||
</Text>
|
||||
)}
|
||||
@@ -104,7 +100,6 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => {
|
||||
<TextLinkOnWebOnly
|
||||
type="md"
|
||||
style={pal.textLight}
|
||||
lineHeight={1.2}
|
||||
text={timeElapsed}
|
||||
accessibilityLabel={niceDate(opts.timestamp)}
|
||||
title={niceDate(opts.timestamp)}
|
||||
|
||||
@@ -8,7 +8,6 @@ import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {useGate} from '#/lib/statsig/statsig'
|
||||
import {logger} from '#/logger'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {
|
||||
@@ -179,7 +178,6 @@ let UserAvatar = ({
|
||||
const pal = usePalette('default')
|
||||
const backgroundColor = pal.colors.backgroundLight
|
||||
const finalShape = overrideShape ?? (type === 'user' ? 'circle' : 'square')
|
||||
const gate = useGate()
|
||||
|
||||
const aviStyle = useMemo(() => {
|
||||
if (finalShape === 'square') {
|
||||
@@ -223,10 +221,7 @@ let UserAvatar = ({
|
||||
style={aviStyle}
|
||||
resizeMode="cover"
|
||||
source={{
|
||||
uri: hackModifyThumbnailPath(
|
||||
avatar,
|
||||
size < 90 && gate('small_avi_thumb'),
|
||||
),
|
||||
uri: hackModifyThumbnailPath(avatar, size < 90),
|
||||
}}
|
||||
blurRadius={moderation?.blur ? BLUR_AMOUNT : 0}
|
||||
/>
|
||||
@@ -236,10 +231,7 @@ let UserAvatar = ({
|
||||
style={aviStyle}
|
||||
contentFit="cover"
|
||||
source={{
|
||||
uri: hackModifyThumbnailPath(
|
||||
avatar,
|
||||
size < 90 && gate('small_avi_thumb'),
|
||||
),
|
||||
uri: hackModifyThumbnailPath(avatar, size < 90),
|
||||
}}
|
||||
blurRadius={moderation?.blur ? BLUR_AMOUNT : 0}
|
||||
/>
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import React from 'react'
|
||||
import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {ListCard} from 'view/com/lists/ListCard'
|
||||
import {AppBskyGraphDefs} from '@atproto/api'
|
||||
import {s} from 'lib/styles'
|
||||
|
||||
export function ListEmbed({
|
||||
item,
|
||||
style,
|
||||
}: {
|
||||
item: AppBskyGraphDefs.ListView
|
||||
style?: StyleProp<ViewStyle>
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
|
||||
return (
|
||||
<View style={[pal.view, pal.border, s.border1, styles.container]}>
|
||||
<ListCard list={item} style={[style, styles.card]} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
borderRadius: 8,
|
||||
},
|
||||
card: {
|
||||
borderTopWidth: 0,
|
||||
borderRadius: 8,
|
||||
},
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react'
|
||||
import React, {useCallback, useState} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {VideoEmbedInnerNative} from 'view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative'
|
||||
@@ -8,13 +8,23 @@ import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon} from '#/components/Button'
|
||||
import {Play_Filled_Corner2_Rounded as PlayIcon} from '#/components/icons/Play'
|
||||
import {VisibilityView} from '../../../../../modules/expo-bluesky-swiss-army'
|
||||
import {ErrorBoundary} from '../ErrorBoundary'
|
||||
import {useActiveVideoView} from './ActiveVideoContext'
|
||||
import * as VideoFallback from './VideoEmbedInner/VideoFallback'
|
||||
|
||||
export function VideoEmbed({source}: {source: string}) {
|
||||
const t = useTheme()
|
||||
const {active, setActive} = useActiveVideoView({source})
|
||||
const {_} = useLingui()
|
||||
|
||||
const [key, setKey] = useState(0)
|
||||
const renderError = useCallback(
|
||||
(error: unknown) => (
|
||||
<VideoError error={error} retry={() => setKey(key + 1)} />
|
||||
),
|
||||
[key],
|
||||
)
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
@@ -25,27 +35,42 @@ export function VideoEmbed({source}: {source: string}) {
|
||||
t.atoms.bg_contrast_25,
|
||||
a.my_xs,
|
||||
]}>
|
||||
<VisibilityView
|
||||
enabled={true}
|
||||
onChangeStatus={isActive => {
|
||||
if (isActive) {
|
||||
setActive()
|
||||
}
|
||||
}}>
|
||||
{active ? (
|
||||
<VideoEmbedInnerNative />
|
||||
) : (
|
||||
<Button
|
||||
style={[a.flex_1, t.atoms.bg_contrast_25]}
|
||||
onPress={setActive}
|
||||
label={_(msg`Play video`)}
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="large">
|
||||
<ButtonIcon icon={PlayIcon} />
|
||||
</Button>
|
||||
)}
|
||||
</VisibilityView>
|
||||
<ErrorBoundary renderError={renderError} key={key}>
|
||||
<VisibilityView
|
||||
enabled={true}
|
||||
onChangeStatus={isActive => {
|
||||
if (isActive) {
|
||||
setActive()
|
||||
}
|
||||
}}>
|
||||
{active ? (
|
||||
<VideoEmbedInnerNative />
|
||||
) : (
|
||||
<Button
|
||||
style={[a.flex_1, t.atoms.bg_contrast_25]}
|
||||
onPress={setActive}
|
||||
label={_(msg`Play video`)}
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="large">
|
||||
<ButtonIcon icon={PlayIcon} />
|
||||
</Button>
|
||||
)}
|
||||
</VisibilityView>
|
||||
</ErrorBoundary>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function VideoError({retry}: {error: unknown; retry: () => void}) {
|
||||
return (
|
||||
<VideoFallback.Container>
|
||||
<VideoFallback.Text>
|
||||
<Trans>
|
||||
An error occurred while loading the video. Please try again later.
|
||||
</Trans>
|
||||
</VideoFallback.Text>
|
||||
<VideoFallback.RetryButton onPress={retry} />
|
||||
</VideoFallback.Container>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import React, {useCallback, useEffect, useRef, useState} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/macro'
|
||||
|
||||
import {
|
||||
HLSUnsupportedError,
|
||||
VideoEmbedInnerWeb,
|
||||
} from 'view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {ErrorBoundary} from '../ErrorBoundary'
|
||||
import {useActiveVideoView} from './ActiveVideoContext'
|
||||
import * as VideoFallback from './VideoEmbedInner/VideoFallback'
|
||||
|
||||
export function VideoEmbed({source}: {source: string}) {
|
||||
const t = useTheme()
|
||||
@@ -138,32 +136,11 @@ function ViewportObserver({
|
||||
}
|
||||
|
||||
function VideoError({error, retry}: {error: unknown; retry: () => void}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
|
||||
const isHLS = error instanceof HLSUnsupportedError
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
t.atoms.bg_contrast_25,
|
||||
a.justify_center,
|
||||
a.align_center,
|
||||
a.px_lg,
|
||||
a.border,
|
||||
t.atoms.border_contrast_low,
|
||||
a.rounded_sm,
|
||||
a.gap_lg,
|
||||
]}>
|
||||
<Text
|
||||
style={[
|
||||
a.text_center,
|
||||
t.atoms.text_contrast_high,
|
||||
a.text_md,
|
||||
a.leading_snug,
|
||||
{maxWidth: 300},
|
||||
]}>
|
||||
<VideoFallback.Container>
|
||||
<VideoFallback.Text>
|
||||
{isHLS ? (
|
||||
<Trans>
|
||||
Your browser does not support the video format. Please try a
|
||||
@@ -174,19 +151,8 @@ function VideoError({error, retry}: {error: unknown; retry: () => void}) {
|
||||
An error occurred while loading the video. Please try again later.
|
||||
</Trans>
|
||||
)}
|
||||
</Text>
|
||||
{!isHLS && (
|
||||
<Button
|
||||
onPress={retry}
|
||||
size="small"
|
||||
color="secondary_inverted"
|
||||
variant="solid"
|
||||
label={_(msg`Retry`)}>
|
||||
<ButtonText>
|
||||
<Trans>Retry</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
</VideoFallback.Text>
|
||||
{!isHLS && <VideoFallback.RetryButton onPress={retry} />}
|
||||
</VideoFallback.Container>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,27 +1,72 @@
|
||||
import React, {useEffect, useRef, useState} from 'react'
|
||||
import React, {useCallback, useEffect, useRef, useState} from 'react'
|
||||
import {Pressable, View} from 'react-native'
|
||||
import Animated, {FadeInDown, FadeOutDown} from 'react-native-reanimated'
|
||||
import {VideoPlayer, VideoView} from 'expo-video'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useIsFocused} from '@react-navigation/native'
|
||||
|
||||
import {useVideoPlayer} from 'view/com/util/post-embeds/VideoPlayerContext'
|
||||
import {android, atoms as a} from '#/alf'
|
||||
import {HITSLOP_30} from '#/lib/constants'
|
||||
import {useAppState} from '#/lib/hooks/useAppState'
|
||||
import {logger} from '#/logger'
|
||||
import {useVideoPlayer} from '#/view/com/util/post-embeds/VideoPlayerContext'
|
||||
import {android, atoms as a, useTheme} from '#/alf'
|
||||
import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute'
|
||||
import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {
|
||||
AudioCategory,
|
||||
PlatformInfo,
|
||||
} from '../../../../../../modules/expo-bluesky-swiss-army'
|
||||
|
||||
export function VideoEmbedInnerNative() {
|
||||
const player = useVideoPlayer()
|
||||
const ref = useRef<VideoView>(null)
|
||||
const isScreenFocused = useIsFocused()
|
||||
const isAppFocused = useAppState()
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (isAppFocused === 'active' && isScreenFocused && !player.playing) {
|
||||
PlatformInfo.setAudioCategory(AudioCategory.Ambient)
|
||||
PlatformInfo.setAudioActive(false)
|
||||
player.muted = true
|
||||
player.play()
|
||||
} else if (player.playing) {
|
||||
player.pause()
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
'Failed to play/pause while backgrounding/switching screens',
|
||||
{safeMessage: err},
|
||||
)
|
||||
}
|
||||
}, [isAppFocused, player, isScreenFocused])
|
||||
|
||||
const enterFullscreen = useCallback(() => {
|
||||
ref.current?.enterFullscreen()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<View style={[a.flex_1, a.relative]} collapsable={false}>
|
||||
<View style={[a.flex_1, a.relative]}>
|
||||
<VideoView
|
||||
ref={ref}
|
||||
player={player}
|
||||
style={a.flex_1}
|
||||
style={[a.flex_1, a.rounded_sm]}
|
||||
nativeControls={true}
|
||||
onEnterFullscreen={() => {
|
||||
PlatformInfo.setAudioCategory(AudioCategory.Playback)
|
||||
PlatformInfo.setAudioActive(true)
|
||||
player.muted = false
|
||||
}}
|
||||
onExitFullscreen={() => {
|
||||
PlatformInfo.setAudioCategory(AudioCategory.Ambient)
|
||||
PlatformInfo.setAudioActive(false)
|
||||
player.muted = true
|
||||
if (!player.playing) player.play()
|
||||
}}
|
||||
/>
|
||||
<Controls
|
||||
player={player}
|
||||
enterFullscreen={() => ref.current?.enterFullscreen()}
|
||||
/>
|
||||
<Controls player={player} enterFullscreen={enterFullscreen} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -33,6 +78,9 @@ function Controls({
|
||||
player: VideoPlayer
|
||||
enterFullscreen: () => void
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const [isMuted, setIsMuted] = useState(player.muted)
|
||||
const [duration, setDuration] = useState(() => Math.floor(player.duration))
|
||||
const [currentTime, setCurrentTime] = useState(() =>
|
||||
Math.floor(player.currentTime),
|
||||
@@ -47,50 +95,121 @@ function Controls({
|
||||
// duration gets reset to 0 on loop
|
||||
if (player.duration) setDuration(Math.floor(player.duration))
|
||||
setCurrentTime(Math.floor(player.currentTime))
|
||||
|
||||
// how often should we update the time?
|
||||
// 1000 gets out of sync with the video time
|
||||
}, 250)
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-shadow
|
||||
const sub = player.addListener('volumeChange', ({isMuted}) => {
|
||||
setIsMuted(isMuted)
|
||||
})
|
||||
|
||||
return () => {
|
||||
clearInterval(interval)
|
||||
sub.remove()
|
||||
}
|
||||
}, [player])
|
||||
|
||||
if (isNaN(timeRemaining)) {
|
||||
return null
|
||||
}
|
||||
const onPressFullscreen = useCallback(() => {
|
||||
switch (player.status) {
|
||||
case 'idle':
|
||||
case 'loading':
|
||||
case 'readyToPlay': {
|
||||
if (!player.playing) player.play()
|
||||
enterFullscreen()
|
||||
break
|
||||
}
|
||||
case 'error': {
|
||||
player.replay()
|
||||
break
|
||||
}
|
||||
}
|
||||
}, [player, enterFullscreen])
|
||||
|
||||
const toggleMuted = useCallback(() => {
|
||||
const muted = !player.muted
|
||||
// We want to set this to the _inverse_ of the new value, because we actually want for the audio to be mixed when
|
||||
// the video is muted, and vice versa.
|
||||
const mix = !muted
|
||||
const category = muted ? AudioCategory.Ambient : AudioCategory.Playback
|
||||
|
||||
PlatformInfo.setAudioCategory(category)
|
||||
PlatformInfo.setAudioActive(mix)
|
||||
player.muted = muted
|
||||
}, [player])
|
||||
|
||||
// show countdown when:
|
||||
// 1. timeRemaining is a number - was seeing NaNs
|
||||
// 2. duration is greater than 0 - means metadata has loaded
|
||||
// 3. we're less than 5 second into the video
|
||||
const showTime = !isNaN(timeRemaining) && duration > 0 && currentTime <= 5
|
||||
|
||||
return (
|
||||
<View style={[a.absolute, a.inset_0]}>
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.75',
|
||||
{showTime && (
|
||||
<Animated.View
|
||||
entering={FadeInDown.duration(300)}
|
||||
exiting={FadeOutDown.duration(500)}
|
||||
style={[
|
||||
{
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.75)',
|
||||
borderRadius: 6,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 3,
|
||||
position: 'absolute',
|
||||
left: 5,
|
||||
bottom: 5,
|
||||
minHeight: 20,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
]}>
|
||||
<Text
|
||||
style={[
|
||||
{color: t.palette.white, fontSize: 12},
|
||||
a.font_bold,
|
||||
android({lineHeight: 1.25}),
|
||||
]}>
|
||||
{minutes}:{seconds}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
)}
|
||||
<Pressable
|
||||
onPress={onPressFullscreen}
|
||||
style={a.flex_1}
|
||||
accessibilityLabel={_(msg`Video`)}
|
||||
accessibilityHint={_(msg`Tap to enter full screen`)}
|
||||
accessibilityRole="button"
|
||||
/>
|
||||
{duration > 0 && (
|
||||
<Animated.View
|
||||
entering={FadeInDown.duration(300)}
|
||||
style={{
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.75)',
|
||||
borderRadius: 6,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 3,
|
||||
position: 'absolute',
|
||||
left: 5,
|
||||
bottom: 5,
|
||||
},
|
||||
]}
|
||||
pointerEvents="none">
|
||||
<Text
|
||||
style={[
|
||||
{color: 'white', fontSize: 12},
|
||||
a.font_bold,
|
||||
android({lineHeight: 1.25}),
|
||||
]}>
|
||||
{minutes}:{seconds}
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable
|
||||
onPress={enterFullscreen}
|
||||
style={a.flex_1}
|
||||
accessibilityLabel="Video"
|
||||
accessibilityHint="Tap to enter full screen"
|
||||
accessibilityRole="button"
|
||||
/>
|
||||
right: 5,
|
||||
minHeight: 20,
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
<Pressable
|
||||
onPress={toggleMuted}
|
||||
style={a.flex_1}
|
||||
accessibilityLabel={isMuted ? _(msg`Muted`) : _(msg`Unmuted`)}
|
||||
accessibilityHint={_(msg`Tap to toggle sound`)}
|
||||
accessibilityRole="button"
|
||||
hitSlop={HITSLOP_30}>
|
||||
{isMuted ? (
|
||||
<MuteIcon width={14} fill={t.palette.white} />
|
||||
) : (
|
||||
<UnmuteIcon width={14} fill={t.palette.white} />
|
||||
)}
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export function VideoEmbedInnerNative() {
|
||||
throw new Error('VideoEmbedInnerNative may not be used on native.')
|
||||
throw new Error('VideoEmbedInnerNative may not be used on web.')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {Text as TypoText} from '#/components/Typography'
|
||||
|
||||
export function Container({children}: {children: React.ReactNode}) {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
t.atoms.bg_contrast_25,
|
||||
a.justify_center,
|
||||
a.align_center,
|
||||
a.px_lg,
|
||||
a.border,
|
||||
t.atoms.border_contrast_low,
|
||||
a.rounded_sm,
|
||||
a.gap_lg,
|
||||
]}>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function Text({children}: {children: React.ReactNode}) {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<TypoText
|
||||
style={[
|
||||
a.text_center,
|
||||
t.atoms.text_contrast_high,
|
||||
a.text_md,
|
||||
a.leading_snug,
|
||||
{maxWidth: 300},
|
||||
]}>
|
||||
{children}
|
||||
</TypoText>
|
||||
)
|
||||
}
|
||||
|
||||
export function RetryButton({onPress}: {onPress: () => void}) {
|
||||
const {_} = useLingui()
|
||||
|
||||
return (
|
||||
<Button
|
||||
onPress={onPress}
|
||||
size="small"
|
||||
color="secondary_inverted"
|
||||
variant="solid"
|
||||
label={_(msg`Retry`)}>
|
||||
<ButtonText>
|
||||
<Trans>Retry</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function Controls() {
|
||||
throw new Error('VideoWebControls may not be used on native.')
|
||||
}
|
||||
@@ -1,7 +1,51 @@
|
||||
import React from 'react'
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from 'react'
|
||||
import {Pressable, View} from 'react-native'
|
||||
import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import type Hls from 'hls.js'
|
||||
|
||||
export function Controls({}: {
|
||||
import {isIPhoneWeb} from 'platform/detection'
|
||||
import {
|
||||
useAutoplayDisabled,
|
||||
useSetSubtitlesEnabled,
|
||||
useSubtitlesEnabled,
|
||||
} from 'state/preferences'
|
||||
import {atoms as a, useTheme, web} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {
|
||||
ArrowsDiagonalIn_Stroke2_Corner0_Rounded as ArrowsInIcon,
|
||||
ArrowsDiagonalOut_Stroke2_Corner0_Rounded as ArrowsOutIcon,
|
||||
} from '#/components/icons/ArrowsDiagonal'
|
||||
import {
|
||||
CC_Filled_Corner0_Rounded as CCActiveIcon,
|
||||
CC_Stroke2_Corner0_Rounded as CCInactiveIcon,
|
||||
} from '#/components/icons/CC'
|
||||
import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute'
|
||||
import {Pause_Filled_Corner0_Rounded as PauseIcon} from '#/components/icons/Pause'
|
||||
import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play'
|
||||
import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function Controls({
|
||||
videoRef,
|
||||
hlsRef,
|
||||
active,
|
||||
setActive,
|
||||
focused,
|
||||
setFocused,
|
||||
onScreen,
|
||||
fullscreenRef,
|
||||
hasSubtitleTrack,
|
||||
}: {
|
||||
videoRef: React.RefObject<HTMLVideoElement>
|
||||
hlsRef: React.RefObject<Hls | undefined>
|
||||
active: boolean
|
||||
@@ -11,6 +55,533 @@ export function Controls({}: {
|
||||
onScreen: boolean
|
||||
fullscreenRef: React.RefObject<HTMLDivElement>
|
||||
hasSubtitleTrack: boolean
|
||||
}): React.ReactElement {
|
||||
throw new Error('Web-only component')
|
||||
}) {
|
||||
const {
|
||||
play,
|
||||
pause,
|
||||
playing,
|
||||
muted,
|
||||
toggleMute,
|
||||
togglePlayPause,
|
||||
currentTime,
|
||||
duration,
|
||||
buffering,
|
||||
error,
|
||||
canPlay,
|
||||
} = useVideoUtils(videoRef)
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const subtitlesEnabled = useSubtitlesEnabled()
|
||||
const setSubtitlesEnabled = useSetSubtitlesEnabled()
|
||||
const {
|
||||
state: hovered,
|
||||
onIn: onMouseEnter,
|
||||
onOut: onMouseLeave,
|
||||
} = useInteractionState()
|
||||
const [isFullscreen, toggleFullscreen] = useFullscreen(fullscreenRef)
|
||||
const {state: hasFocus, onIn: onFocus, onOut: onBlur} = useInteractionState()
|
||||
const [interactingViaKeypress, setInteractingViaKeypress] = useState(false)
|
||||
|
||||
const onKeyDown = useCallback(() => {
|
||||
setInteractingViaKeypress(true)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (interactingViaKeypress) {
|
||||
document.addEventListener('click', () => setInteractingViaKeypress(false))
|
||||
return () => {
|
||||
document.removeEventListener('click', () =>
|
||||
setInteractingViaKeypress(false),
|
||||
)
|
||||
}
|
||||
}
|
||||
}, [interactingViaKeypress])
|
||||
|
||||
// pause + unfocus when another video is active
|
||||
useEffect(() => {
|
||||
if (!active) {
|
||||
pause()
|
||||
setFocused(false)
|
||||
}
|
||||
}, [active, pause, setFocused])
|
||||
|
||||
// autoplay/pause based on visibility
|
||||
const autoplayDisabled = useAutoplayDisabled()
|
||||
useEffect(() => {
|
||||
if (active && !autoplayDisabled) {
|
||||
if (onScreen) {
|
||||
play()
|
||||
} else {
|
||||
pause()
|
||||
}
|
||||
}
|
||||
}, [onScreen, pause, active, play, autoplayDisabled])
|
||||
|
||||
// use minimal quality when not focused
|
||||
useEffect(() => {
|
||||
if (!hlsRef.current) return
|
||||
if (focused) {
|
||||
// auto decide quality based on network conditions
|
||||
hlsRef.current.autoLevelCapping = -1
|
||||
} else {
|
||||
hlsRef.current.autoLevelCapping = 0
|
||||
}
|
||||
}, [hlsRef, focused])
|
||||
|
||||
useEffect(() => {
|
||||
if (!hlsRef.current) return
|
||||
if (hasSubtitleTrack && subtitlesEnabled && canPlay) {
|
||||
hlsRef.current.subtitleTrack = 0
|
||||
} else {
|
||||
hlsRef.current.subtitleTrack = -1
|
||||
}
|
||||
}, [hasSubtitleTrack, subtitlesEnabled, hlsRef, canPlay])
|
||||
|
||||
// clicking on any button should focus the player, if it's not already focused
|
||||
const drawFocus = useCallback(() => {
|
||||
if (!active) {
|
||||
setActive()
|
||||
}
|
||||
setFocused(true)
|
||||
}, [active, setActive, setFocused])
|
||||
|
||||
const onPressEmptySpace = useCallback(() => {
|
||||
if (!focused) {
|
||||
drawFocus()
|
||||
} else {
|
||||
togglePlayPause()
|
||||
}
|
||||
}, [togglePlayPause, drawFocus, focused])
|
||||
|
||||
const onPressPlayPause = useCallback(() => {
|
||||
drawFocus()
|
||||
togglePlayPause()
|
||||
}, [drawFocus, togglePlayPause])
|
||||
|
||||
const onPressSubtitles = useCallback(() => {
|
||||
drawFocus()
|
||||
setSubtitlesEnabled(!subtitlesEnabled)
|
||||
}, [drawFocus, setSubtitlesEnabled, subtitlesEnabled])
|
||||
|
||||
const onPressMute = useCallback(() => {
|
||||
drawFocus()
|
||||
toggleMute()
|
||||
}, [drawFocus, toggleMute])
|
||||
|
||||
const onPressFullscreen = useCallback(() => {
|
||||
drawFocus()
|
||||
toggleFullscreen()
|
||||
}, [drawFocus, toggleFullscreen])
|
||||
|
||||
const showControls =
|
||||
(focused && !playing) || (interactingViaKeypress ? hasFocus : hovered)
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
onClick={evt => {
|
||||
evt.stopPropagation()
|
||||
setInteractingViaKeypress(false)
|
||||
}}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
onKeyDown={onKeyDown}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityHint={_(
|
||||
focused
|
||||
? msg`Unmute video`
|
||||
: playing
|
||||
? msg`Pause video`
|
||||
: msg`Play video`,
|
||||
)}
|
||||
style={a.flex_1}
|
||||
onPress={onPressEmptySpace}
|
||||
/>
|
||||
<View
|
||||
style={[
|
||||
a.flex_shrink_0,
|
||||
a.w_full,
|
||||
a.px_sm,
|
||||
a.pt_sm,
|
||||
a.pb_md,
|
||||
a.gap_md,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
web({
|
||||
background:
|
||||
'linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.4), rgba(0, 0, 0, 0.7))',
|
||||
}),
|
||||
showControls ? {opacity: 1} : {opacity: 0},
|
||||
]}>
|
||||
<Button
|
||||
label={_(playing ? msg`Pause` : msg`Play`)}
|
||||
onPress={onPressPlayPause}
|
||||
{...btnProps}>
|
||||
{playing ? (
|
||||
<PauseIcon fill={t.palette.white} width={20} />
|
||||
) : (
|
||||
<PlayIcon fill={t.palette.white} width={20} />
|
||||
)}
|
||||
</Button>
|
||||
<View style={a.flex_1} />
|
||||
<Text style={{color: t.palette.white}}>
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</Text>
|
||||
{hasSubtitleTrack && (
|
||||
<Button
|
||||
label={_(
|
||||
subtitlesEnabled ? msg`Disable subtitles` : msg`Enable subtitles`,
|
||||
)}
|
||||
onPress={onPressSubtitles}
|
||||
{...btnProps}>
|
||||
{subtitlesEnabled ? (
|
||||
<CCActiveIcon fill={t.palette.white} width={20} />
|
||||
) : (
|
||||
<CCInactiveIcon fill={t.palette.white} width={20} />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
label={_(muted ? msg`Unmute` : msg`Mute`)}
|
||||
onPress={onPressMute}
|
||||
{...btnProps}>
|
||||
{muted ? (
|
||||
<MuteIcon fill={t.palette.white} width={20} />
|
||||
) : (
|
||||
<UnmuteIcon fill={t.palette.white} width={20} />
|
||||
)}
|
||||
</Button>
|
||||
{!isIPhoneWeb && (
|
||||
<Button
|
||||
label={_(muted ? msg`Unmute` : msg`Mute`)}
|
||||
onPress={onPressFullscreen}
|
||||
{...btnProps}>
|
||||
{isFullscreen ? (
|
||||
<ArrowsInIcon fill={t.palette.white} width={20} />
|
||||
) : (
|
||||
<ArrowsOutIcon fill={t.palette.white} width={20} />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
{(showControls || !focused) && (
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(200)}
|
||||
exiting={FadeOut.duration(200)}
|
||||
style={[
|
||||
a.absolute,
|
||||
{
|
||||
height: 5,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
backgroundColor: 'rgba(255,255,255,0.4)',
|
||||
},
|
||||
]}>
|
||||
{duration > 0 && (
|
||||
<View
|
||||
style={[
|
||||
a.h_full,
|
||||
a.mr_auto,
|
||||
{
|
||||
backgroundColor: t.palette.white,
|
||||
width: `${(currentTime / duration) * 100}%`,
|
||||
opacity: 0.8,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Animated.View>
|
||||
)}
|
||||
{(buffering || error) && (
|
||||
<Animated.View
|
||||
pointerEvents="none"
|
||||
entering={FadeIn.delay(1000).duration(200)}
|
||||
exiting={FadeOut.duration(200)}
|
||||
style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
|
||||
{buffering && <Loader fill={t.palette.white} size="lg" />}
|
||||
{error && (
|
||||
<Text style={{color: t.palette.white}}>
|
||||
<Trans>An error occurred</Trans>
|
||||
</Text>
|
||||
)}
|
||||
</Animated.View>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const btnProps = {
|
||||
variant: 'ghost',
|
||||
shape: 'round',
|
||||
size: 'medium',
|
||||
style: a.p_2xs,
|
||||
hoverStyle: {backgroundColor: 'rgba(255, 255, 255, 0.1)'},
|
||||
} as const
|
||||
|
||||
function formatTime(time: number) {
|
||||
if (isNaN(time)) {
|
||||
return '--'
|
||||
}
|
||||
|
||||
time = Math.round(time)
|
||||
|
||||
const minutes = Math.floor(time / 60)
|
||||
const seconds = String(time % 60).padStart(2, '0')
|
||||
|
||||
return `${minutes}:${seconds}`
|
||||
}
|
||||
|
||||
function useVideoUtils(ref: React.RefObject<HTMLVideoElement>) {
|
||||
const [playing, setPlaying] = useState(false)
|
||||
const [muted, setMuted] = useState(true)
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
const [duration, setDuration] = useState(0)
|
||||
const [buffering, setBuffering] = useState(false)
|
||||
const [error, setError] = useState(false)
|
||||
const [canPlay, setCanPlay] = useState(false)
|
||||
const playWhenReadyRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return
|
||||
|
||||
let bufferingTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
function round(num: number) {
|
||||
return Math.round(num * 100) / 100
|
||||
}
|
||||
|
||||
// Initial values
|
||||
setCurrentTime(round(ref.current.currentTime) || 0)
|
||||
setDuration(round(ref.current.duration) || 0)
|
||||
setMuted(ref.current.muted)
|
||||
setPlaying(!ref.current.paused)
|
||||
|
||||
const handleTimeUpdate = () => {
|
||||
if (!ref.current) return
|
||||
setCurrentTime(round(ref.current.currentTime) || 0)
|
||||
}
|
||||
|
||||
const handleDurationChange = () => {
|
||||
if (!ref.current) return
|
||||
setDuration(round(ref.current.duration) || 0)
|
||||
}
|
||||
|
||||
const handlePlay = () => {
|
||||
setPlaying(true)
|
||||
}
|
||||
|
||||
const handlePause = () => {
|
||||
setPlaying(false)
|
||||
}
|
||||
|
||||
const handleVolumeChange = () => {
|
||||
if (!ref.current) return
|
||||
setMuted(ref.current.muted)
|
||||
}
|
||||
|
||||
const handleError = () => {
|
||||
setError(true)
|
||||
}
|
||||
|
||||
const handleCanPlay = () => {
|
||||
setBuffering(false)
|
||||
setCanPlay(true)
|
||||
|
||||
if (!ref.current) return
|
||||
if (playWhenReadyRef.current) {
|
||||
ref.current.play()
|
||||
playWhenReadyRef.current = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleCanPlayThrough = () => {
|
||||
setBuffering(false)
|
||||
}
|
||||
|
||||
const handleWaiting = () => {
|
||||
if (bufferingTimeout) clearTimeout(bufferingTimeout)
|
||||
bufferingTimeout = setTimeout(() => {
|
||||
setBuffering(true)
|
||||
}, 200) // Delay to avoid frequent buffering state changes
|
||||
}
|
||||
|
||||
const handlePlaying = () => {
|
||||
if (bufferingTimeout) clearTimeout(bufferingTimeout)
|
||||
setBuffering(false)
|
||||
setError(false)
|
||||
}
|
||||
|
||||
const handleSeeking = () => {
|
||||
setBuffering(true)
|
||||
}
|
||||
|
||||
const handleSeeked = () => {
|
||||
setBuffering(false)
|
||||
}
|
||||
|
||||
const handleStalled = () => {
|
||||
if (bufferingTimeout) clearTimeout(bufferingTimeout)
|
||||
bufferingTimeout = setTimeout(() => {
|
||||
setBuffering(true)
|
||||
}, 200) // Delay to avoid frequent buffering state changes
|
||||
}
|
||||
|
||||
const handleEnded = () => {
|
||||
setPlaying(false)
|
||||
setBuffering(false)
|
||||
setError(false)
|
||||
}
|
||||
|
||||
const abortController = new AbortController()
|
||||
|
||||
ref.current.addEventListener('timeupdate', handleTimeUpdate, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('durationchange', handleDurationChange, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('play', handlePlay, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('pause', handlePause, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('volumechange', handleVolumeChange, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('error', handleError, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('canplay', handleCanPlay, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('canplaythrough', handleCanPlayThrough, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('waiting', handleWaiting, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('playing', handlePlaying, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('seeking', handleSeeking, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('seeked', handleSeeked, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('stalled', handleStalled, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('ended', handleEnded, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
|
||||
return () => {
|
||||
abortController.abort()
|
||||
clearTimeout(bufferingTimeout)
|
||||
}
|
||||
}, [ref])
|
||||
|
||||
const play = useCallback(() => {
|
||||
if (!ref.current) return
|
||||
|
||||
if (ref.current.ended) {
|
||||
ref.current.currentTime = 0
|
||||
}
|
||||
|
||||
if (ref.current.readyState < HTMLMediaElement.HAVE_FUTURE_DATA) {
|
||||
playWhenReadyRef.current = true
|
||||
} else {
|
||||
const promise = ref.current.play()
|
||||
if (promise !== undefined) {
|
||||
promise.catch(err => {
|
||||
console.error('Error playing video:', err)
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [ref])
|
||||
|
||||
const pause = useCallback(() => {
|
||||
if (!ref.current) return
|
||||
|
||||
ref.current.pause()
|
||||
playWhenReadyRef.current = false
|
||||
}, [ref])
|
||||
|
||||
const togglePlayPause = useCallback(() => {
|
||||
if (!ref.current) return
|
||||
|
||||
if (ref.current.paused) {
|
||||
play()
|
||||
} else {
|
||||
pause()
|
||||
}
|
||||
}, [ref, play, pause])
|
||||
|
||||
const mute = useCallback(() => {
|
||||
if (!ref.current) return
|
||||
|
||||
ref.current.muted = true
|
||||
}, [ref])
|
||||
|
||||
const unmute = useCallback(() => {
|
||||
if (!ref.current) return
|
||||
|
||||
ref.current.muted = false
|
||||
}, [ref])
|
||||
|
||||
const toggleMute = useCallback(() => {
|
||||
if (!ref.current) return
|
||||
|
||||
ref.current.muted = !ref.current.muted
|
||||
}, [ref])
|
||||
|
||||
return {
|
||||
play,
|
||||
pause,
|
||||
togglePlayPause,
|
||||
duration,
|
||||
currentTime,
|
||||
playing,
|
||||
muted,
|
||||
mute,
|
||||
unmute,
|
||||
toggleMute,
|
||||
buffering,
|
||||
error,
|
||||
canPlay,
|
||||
}
|
||||
}
|
||||
|
||||
function fullscreenSubscribe(onChange: () => void) {
|
||||
document.addEventListener('fullscreenchange', onChange)
|
||||
return () => document.removeEventListener('fullscreenchange', onChange)
|
||||
}
|
||||
|
||||
function useFullscreen(ref: React.RefObject<HTMLElement>) {
|
||||
const isFullscreen = useSyncExternalStore(fullscreenSubscribe, () =>
|
||||
Boolean(document.fullscreenElement),
|
||||
)
|
||||
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
if (isFullscreen) {
|
||||
document.exitFullscreen()
|
||||
} else {
|
||||
if (!ref.current) return
|
||||
ref.current.requestFullscreen()
|
||||
}
|
||||
}, [isFullscreen, ref])
|
||||
|
||||
return [isFullscreen, toggleFullscreen] as const
|
||||
}
|
||||
|
||||
@@ -1,587 +0,0 @@
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from 'react'
|
||||
import {Pressable, View} from 'react-native'
|
||||
import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import type Hls from 'hls.js'
|
||||
|
||||
import {isIPhoneWeb} from 'platform/detection'
|
||||
import {
|
||||
useAutoplayDisabled,
|
||||
useSetSubtitlesEnabled,
|
||||
useSubtitlesEnabled,
|
||||
} from 'state/preferences'
|
||||
import {atoms as a, useTheme, web} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {
|
||||
ArrowsDiagonalIn_Stroke2_Corner0_Rounded as ArrowsInIcon,
|
||||
ArrowsDiagonalOut_Stroke2_Corner0_Rounded as ArrowsOutIcon,
|
||||
} from '#/components/icons/ArrowsDiagonal'
|
||||
import {
|
||||
CC_Filled_Corner0_Rounded as CCActiveIcon,
|
||||
CC_Stroke2_Corner0_Rounded as CCInactiveIcon,
|
||||
} from '#/components/icons/CC'
|
||||
import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute'
|
||||
import {Pause_Filled_Corner0_Rounded as PauseIcon} from '#/components/icons/Pause'
|
||||
import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play'
|
||||
import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function Controls({
|
||||
videoRef,
|
||||
hlsRef,
|
||||
active,
|
||||
setActive,
|
||||
focused,
|
||||
setFocused,
|
||||
onScreen,
|
||||
fullscreenRef,
|
||||
hasSubtitleTrack,
|
||||
}: {
|
||||
videoRef: React.RefObject<HTMLVideoElement>
|
||||
hlsRef: React.RefObject<Hls | undefined>
|
||||
active: boolean
|
||||
setActive: () => void
|
||||
focused: boolean
|
||||
setFocused: (focused: boolean) => void
|
||||
onScreen: boolean
|
||||
fullscreenRef: React.RefObject<HTMLDivElement>
|
||||
hasSubtitleTrack: boolean
|
||||
}) {
|
||||
const {
|
||||
play,
|
||||
pause,
|
||||
playing,
|
||||
muted,
|
||||
toggleMute,
|
||||
togglePlayPause,
|
||||
currentTime,
|
||||
duration,
|
||||
buffering,
|
||||
error,
|
||||
canPlay,
|
||||
} = useVideoUtils(videoRef)
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const subtitlesEnabled = useSubtitlesEnabled()
|
||||
const setSubtitlesEnabled = useSetSubtitlesEnabled()
|
||||
const {
|
||||
state: hovered,
|
||||
onIn: onMouseEnter,
|
||||
onOut: onMouseLeave,
|
||||
} = useInteractionState()
|
||||
const [isFullscreen, toggleFullscreen] = useFullscreen(fullscreenRef)
|
||||
const {state: hasFocus, onIn: onFocus, onOut: onBlur} = useInteractionState()
|
||||
const [interactingViaKeypress, setInteractingViaKeypress] = useState(false)
|
||||
|
||||
const onKeyDown = useCallback(() => {
|
||||
setInteractingViaKeypress(true)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (interactingViaKeypress) {
|
||||
document.addEventListener('click', () => setInteractingViaKeypress(false))
|
||||
return () => {
|
||||
document.removeEventListener('click', () =>
|
||||
setInteractingViaKeypress(false),
|
||||
)
|
||||
}
|
||||
}
|
||||
}, [interactingViaKeypress])
|
||||
|
||||
// pause + unfocus when another video is active
|
||||
useEffect(() => {
|
||||
if (!active) {
|
||||
pause()
|
||||
setFocused(false)
|
||||
}
|
||||
}, [active, pause, setFocused])
|
||||
|
||||
// autoplay/pause based on visibility
|
||||
const autoplayDisabled = useAutoplayDisabled()
|
||||
useEffect(() => {
|
||||
if (active && !autoplayDisabled) {
|
||||
if (onScreen) {
|
||||
play()
|
||||
} else {
|
||||
pause()
|
||||
}
|
||||
}
|
||||
}, [onScreen, pause, active, play, autoplayDisabled])
|
||||
|
||||
// use minimal quality when not focused
|
||||
useEffect(() => {
|
||||
if (!hlsRef.current) return
|
||||
if (focused) {
|
||||
// auto decide quality based on network conditions
|
||||
hlsRef.current.autoLevelCapping = -1
|
||||
} else {
|
||||
hlsRef.current.autoLevelCapping = 0
|
||||
}
|
||||
}, [hlsRef, focused])
|
||||
|
||||
useEffect(() => {
|
||||
if (!hlsRef.current) return
|
||||
if (hasSubtitleTrack && subtitlesEnabled && canPlay) {
|
||||
hlsRef.current.subtitleTrack = 0
|
||||
} else {
|
||||
hlsRef.current.subtitleTrack = -1
|
||||
}
|
||||
}, [hasSubtitleTrack, subtitlesEnabled, hlsRef, canPlay])
|
||||
|
||||
// clicking on any button should focus the player, if it's not already focused
|
||||
const drawFocus = useCallback(() => {
|
||||
if (!active) {
|
||||
setActive()
|
||||
}
|
||||
setFocused(true)
|
||||
}, [active, setActive, setFocused])
|
||||
|
||||
const onPressEmptySpace = useCallback(() => {
|
||||
if (!focused) {
|
||||
drawFocus()
|
||||
} else {
|
||||
togglePlayPause()
|
||||
}
|
||||
}, [togglePlayPause, drawFocus, focused])
|
||||
|
||||
const onPressPlayPause = useCallback(() => {
|
||||
drawFocus()
|
||||
togglePlayPause()
|
||||
}, [drawFocus, togglePlayPause])
|
||||
|
||||
const onPressSubtitles = useCallback(() => {
|
||||
drawFocus()
|
||||
setSubtitlesEnabled(!subtitlesEnabled)
|
||||
}, [drawFocus, setSubtitlesEnabled, subtitlesEnabled])
|
||||
|
||||
const onPressMute = useCallback(() => {
|
||||
drawFocus()
|
||||
toggleMute()
|
||||
}, [drawFocus, toggleMute])
|
||||
|
||||
const onPressFullscreen = useCallback(() => {
|
||||
drawFocus()
|
||||
toggleFullscreen()
|
||||
}, [drawFocus, toggleFullscreen])
|
||||
|
||||
const showControls =
|
||||
(focused && !playing) || (interactingViaKeypress ? hasFocus : hovered)
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
onClick={evt => {
|
||||
evt.stopPropagation()
|
||||
setInteractingViaKeypress(false)
|
||||
}}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
onKeyDown={onKeyDown}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityHint={_(
|
||||
focused
|
||||
? msg`Unmute video`
|
||||
: playing
|
||||
? msg`Pause video`
|
||||
: msg`Play video`,
|
||||
)}
|
||||
style={a.flex_1}
|
||||
onPress={onPressEmptySpace}
|
||||
/>
|
||||
<View
|
||||
style={[
|
||||
a.flex_shrink_0,
|
||||
a.w_full,
|
||||
a.px_sm,
|
||||
a.pt_sm,
|
||||
a.pb_md,
|
||||
a.gap_md,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
web({
|
||||
background:
|
||||
'linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.4), rgba(0, 0, 0, 0.7))',
|
||||
}),
|
||||
showControls ? {opacity: 1} : {opacity: 0},
|
||||
]}>
|
||||
<Button
|
||||
label={_(playing ? msg`Pause` : msg`Play`)}
|
||||
onPress={onPressPlayPause}
|
||||
{...btnProps}>
|
||||
{playing ? (
|
||||
<PauseIcon fill={t.palette.white} width={20} />
|
||||
) : (
|
||||
<PlayIcon fill={t.palette.white} width={20} />
|
||||
)}
|
||||
</Button>
|
||||
<View style={a.flex_1} />
|
||||
<Text style={{color: t.palette.white}}>
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</Text>
|
||||
{hasSubtitleTrack && (
|
||||
<Button
|
||||
label={_(
|
||||
subtitlesEnabled ? msg`Disable subtitles` : msg`Enable subtitles`,
|
||||
)}
|
||||
onPress={onPressSubtitles}
|
||||
{...btnProps}>
|
||||
{subtitlesEnabled ? (
|
||||
<CCActiveIcon fill={t.palette.white} width={20} />
|
||||
) : (
|
||||
<CCInactiveIcon fill={t.palette.white} width={20} />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
label={_(muted ? msg`Unmute` : msg`Mute`)}
|
||||
onPress={onPressMute}
|
||||
{...btnProps}>
|
||||
{muted ? (
|
||||
<MuteIcon fill={t.palette.white} width={20} />
|
||||
) : (
|
||||
<UnmuteIcon fill={t.palette.white} width={20} />
|
||||
)}
|
||||
</Button>
|
||||
{!isIPhoneWeb && (
|
||||
<Button
|
||||
label={_(muted ? msg`Unmute` : msg`Mute`)}
|
||||
onPress={onPressFullscreen}
|
||||
{...btnProps}>
|
||||
{isFullscreen ? (
|
||||
<ArrowsInIcon fill={t.palette.white} width={20} />
|
||||
) : (
|
||||
<ArrowsOutIcon fill={t.palette.white} width={20} />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
{(showControls || !focused) && (
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(200)}
|
||||
exiting={FadeOut.duration(200)}
|
||||
style={[
|
||||
a.absolute,
|
||||
{
|
||||
height: 5,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
backgroundColor: 'rgba(255,255,255,0.4)',
|
||||
},
|
||||
]}>
|
||||
{duration > 0 && (
|
||||
<View
|
||||
style={[
|
||||
a.h_full,
|
||||
a.mr_auto,
|
||||
{
|
||||
backgroundColor: t.palette.white,
|
||||
width: `${(currentTime / duration) * 100}%`,
|
||||
opacity: 0.8,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Animated.View>
|
||||
)}
|
||||
{(buffering || error) && (
|
||||
<Animated.View
|
||||
pointerEvents="none"
|
||||
entering={FadeIn.delay(1000).duration(200)}
|
||||
exiting={FadeOut.duration(200)}
|
||||
style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
|
||||
{buffering && <Loader fill={t.palette.white} size="lg" />}
|
||||
{error && (
|
||||
<Text style={{color: t.palette.white}}>
|
||||
<Trans>An error occurred</Trans>
|
||||
</Text>
|
||||
)}
|
||||
</Animated.View>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const btnProps = {
|
||||
variant: 'ghost',
|
||||
shape: 'round',
|
||||
size: 'medium',
|
||||
style: a.p_2xs,
|
||||
hoverStyle: {backgroundColor: 'rgba(255, 255, 255, 0.1)'},
|
||||
} as const
|
||||
|
||||
function formatTime(time: number) {
|
||||
if (isNaN(time)) {
|
||||
return '--'
|
||||
}
|
||||
|
||||
time = Math.round(time)
|
||||
|
||||
const minutes = Math.floor(time / 60)
|
||||
const seconds = String(time % 60).padStart(2, '0')
|
||||
|
||||
return `${minutes}:${seconds}`
|
||||
}
|
||||
|
||||
function useVideoUtils(ref: React.RefObject<HTMLVideoElement>) {
|
||||
const [playing, setPlaying] = useState(false)
|
||||
const [muted, setMuted] = useState(true)
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
const [duration, setDuration] = useState(0)
|
||||
const [buffering, setBuffering] = useState(false)
|
||||
const [error, setError] = useState(false)
|
||||
const [canPlay, setCanPlay] = useState(false)
|
||||
const playWhenReadyRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return
|
||||
|
||||
let bufferingTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
function round(num: number) {
|
||||
return Math.round(num * 100) / 100
|
||||
}
|
||||
|
||||
// Initial values
|
||||
setCurrentTime(round(ref.current.currentTime) || 0)
|
||||
setDuration(round(ref.current.duration) || 0)
|
||||
setMuted(ref.current.muted)
|
||||
setPlaying(!ref.current.paused)
|
||||
|
||||
const handleTimeUpdate = () => {
|
||||
if (!ref.current) return
|
||||
setCurrentTime(round(ref.current.currentTime) || 0)
|
||||
}
|
||||
|
||||
const handleDurationChange = () => {
|
||||
if (!ref.current) return
|
||||
setDuration(round(ref.current.duration) || 0)
|
||||
}
|
||||
|
||||
const handlePlay = () => {
|
||||
setPlaying(true)
|
||||
}
|
||||
|
||||
const handlePause = () => {
|
||||
setPlaying(false)
|
||||
}
|
||||
|
||||
const handleVolumeChange = () => {
|
||||
if (!ref.current) return
|
||||
setMuted(ref.current.muted)
|
||||
}
|
||||
|
||||
const handleError = () => {
|
||||
setError(true)
|
||||
}
|
||||
|
||||
const handleCanPlay = () => {
|
||||
setBuffering(false)
|
||||
setCanPlay(true)
|
||||
|
||||
if (!ref.current) return
|
||||
if (playWhenReadyRef.current) {
|
||||
ref.current.play()
|
||||
playWhenReadyRef.current = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleCanPlayThrough = () => {
|
||||
setBuffering(false)
|
||||
}
|
||||
|
||||
const handleWaiting = () => {
|
||||
if (bufferingTimeout) clearTimeout(bufferingTimeout)
|
||||
bufferingTimeout = setTimeout(() => {
|
||||
setBuffering(true)
|
||||
}, 200) // Delay to avoid frequent buffering state changes
|
||||
}
|
||||
|
||||
const handlePlaying = () => {
|
||||
if (bufferingTimeout) clearTimeout(bufferingTimeout)
|
||||
setBuffering(false)
|
||||
setError(false)
|
||||
}
|
||||
|
||||
const handleSeeking = () => {
|
||||
setBuffering(true)
|
||||
}
|
||||
|
||||
const handleSeeked = () => {
|
||||
setBuffering(false)
|
||||
}
|
||||
|
||||
const handleStalled = () => {
|
||||
if (bufferingTimeout) clearTimeout(bufferingTimeout)
|
||||
bufferingTimeout = setTimeout(() => {
|
||||
setBuffering(true)
|
||||
}, 200) // Delay to avoid frequent buffering state changes
|
||||
}
|
||||
|
||||
const handleEnded = () => {
|
||||
setPlaying(false)
|
||||
setBuffering(false)
|
||||
setError(false)
|
||||
}
|
||||
|
||||
const abortController = new AbortController()
|
||||
|
||||
ref.current.addEventListener('timeupdate', handleTimeUpdate, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('durationchange', handleDurationChange, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('play', handlePlay, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('pause', handlePause, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('volumechange', handleVolumeChange, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('error', handleError, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('canplay', handleCanPlay, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('canplaythrough', handleCanPlayThrough, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('waiting', handleWaiting, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('playing', handlePlaying, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('seeking', handleSeeking, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('seeked', handleSeeked, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('stalled', handleStalled, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('ended', handleEnded, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
|
||||
return () => {
|
||||
abortController.abort()
|
||||
clearTimeout(bufferingTimeout)
|
||||
}
|
||||
}, [ref])
|
||||
|
||||
const play = useCallback(() => {
|
||||
if (!ref.current) return
|
||||
|
||||
if (ref.current.ended) {
|
||||
ref.current.currentTime = 0
|
||||
}
|
||||
|
||||
if (ref.current.readyState < HTMLMediaElement.HAVE_FUTURE_DATA) {
|
||||
playWhenReadyRef.current = true
|
||||
} else {
|
||||
const promise = ref.current.play()
|
||||
if (promise !== undefined) {
|
||||
promise.catch(err => {
|
||||
console.error('Error playing video:', err)
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [ref])
|
||||
|
||||
const pause = useCallback(() => {
|
||||
if (!ref.current) return
|
||||
|
||||
ref.current.pause()
|
||||
playWhenReadyRef.current = false
|
||||
}, [ref])
|
||||
|
||||
const togglePlayPause = useCallback(() => {
|
||||
if (!ref.current) return
|
||||
|
||||
if (ref.current.paused) {
|
||||
play()
|
||||
} else {
|
||||
pause()
|
||||
}
|
||||
}, [ref, play, pause])
|
||||
|
||||
const mute = useCallback(() => {
|
||||
if (!ref.current) return
|
||||
|
||||
ref.current.muted = true
|
||||
}, [ref])
|
||||
|
||||
const unmute = useCallback(() => {
|
||||
if (!ref.current) return
|
||||
|
||||
ref.current.muted = false
|
||||
}, [ref])
|
||||
|
||||
const toggleMute = useCallback(() => {
|
||||
if (!ref.current) return
|
||||
|
||||
ref.current.muted = !ref.current.muted
|
||||
}, [ref])
|
||||
|
||||
return {
|
||||
play,
|
||||
pause,
|
||||
togglePlayPause,
|
||||
duration,
|
||||
currentTime,
|
||||
playing,
|
||||
muted,
|
||||
mute,
|
||||
unmute,
|
||||
toggleMute,
|
||||
buffering,
|
||||
error,
|
||||
canPlay,
|
||||
}
|
||||
}
|
||||
|
||||
function fullscreenSubscribe(onChange: () => void) {
|
||||
document.addEventListener('fullscreenchange', onChange)
|
||||
return () => document.removeEventListener('fullscreenchange', onChange)
|
||||
}
|
||||
|
||||
function useFullscreen(ref: React.RefObject<HTMLElement>) {
|
||||
const isFullscreen = useSyncExternalStore(fullscreenSubscribe, () =>
|
||||
Boolean(document.fullscreenElement),
|
||||
)
|
||||
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
if (isFullscreen) {
|
||||
document.exitFullscreen()
|
||||
} else {
|
||||
if (!ref.current) return
|
||||
ref.current.requestFullscreen()
|
||||
}
|
||||
}, [isFullscreen, ref])
|
||||
|
||||
return [isFullscreen, toggleFullscreen] as const
|
||||
}
|
||||
@@ -2,6 +2,12 @@ import React, {useContext} from 'react'
|
||||
import type {VideoPlayer} from 'expo-video'
|
||||
import {useVideoPlayer as useExpoVideoPlayer} from 'expo-video'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {
|
||||
AudioCategory,
|
||||
PlatformInfo,
|
||||
} from '../../../../../modules/expo-bluesky-swiss-army'
|
||||
|
||||
const VideoPlayerContext = React.createContext<VideoPlayer | null>(null)
|
||||
|
||||
export function VideoPlayerProvider({
|
||||
@@ -13,8 +19,16 @@ export function VideoPlayerProvider({
|
||||
}) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-shadow
|
||||
const player = useExpoVideoPlayer(source, player => {
|
||||
player.loop = true
|
||||
player.play()
|
||||
try {
|
||||
PlatformInfo.setAudioCategory(AudioCategory.Ambient)
|
||||
PlatformInfo.setAudioActive(false)
|
||||
|
||||
player.loop = true
|
||||
player.muted = true
|
||||
player.play()
|
||||
} catch (err) {
|
||||
logger.error('Failed to init video player', {safeMessage: err})
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
|
||||
@@ -25,13 +25,13 @@ import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import * as ListCard from '#/components/ListCard'
|
||||
import {Embed as StarterPackCard} from '#/components/StarterPack/StarterPackCard'
|
||||
import {ContentHider} from '../../../../components/moderation/ContentHider'
|
||||
import {AutoSizedImage} from '../images/AutoSizedImage'
|
||||
import {ImageLayoutGrid} from '../images/ImageLayoutGrid'
|
||||
import {ExternalLinkEmbed} from './ExternalLinkEmbed'
|
||||
import {ListEmbed} from './ListEmbed'
|
||||
import {MaybeQuoteEmbed} from './QuoteEmbed'
|
||||
|
||||
type Embed =
|
||||
@@ -203,10 +203,20 @@ function MaybeListCard({view}: {view: AppBskyGraphDefs.ListView}) {
|
||||
const moderation = React.useMemo(() => {
|
||||
return moderationOpts ? moderateUserList(view, moderationOpts) : undefined
|
||||
}, [view, moderationOpts])
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<ContentHider modui={moderation?.ui('contentList')}>
|
||||
<ListEmbed item={view} />
|
||||
<View
|
||||
style={[
|
||||
a.border,
|
||||
t.atoms.border_contrast_medium,
|
||||
a.p_md,
|
||||
a.rounded_sm,
|
||||
a.mt_sm,
|
||||
]}>
|
||||
<ListCard.Default view={view} />
|
||||
</View>
|
||||
</ContentHider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ export function AccessibilitySettingsScreen({}: Props) {
|
||||
style={s.flex1}
|
||||
contentContainerStyle={[
|
||||
s.flex1,
|
||||
{paddingBottom: 200},
|
||||
{paddingBottom: 100},
|
||||
isMobile && pal.viewLight,
|
||||
]}>
|
||||
<Text type="xl-bold" style={[pal.text, styles.heading]}>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||
import {useSetTitle} from '#/lib/hooks/useSetTitle'
|
||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||
import {logEvent, LogEvents} from '#/lib/statsig/statsig'
|
||||
import {useGate} from '#/lib/statsig/statsig'
|
||||
import {emitSoftReset} from '#/state/events'
|
||||
import {SavedFeedSourceInfo, usePinnedFeedsInfos} from '#/state/queries/feed'
|
||||
import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed'
|
||||
@@ -88,6 +89,7 @@ function HomeScreenReady({
|
||||
const selectedFeed = allFeeds[selectedIndex]
|
||||
const requestNotificationsPermission = useRequestNotificationsPermission()
|
||||
const triggerTourIfQueued = useTriggerTourIfQueued(TOURS.HOME)
|
||||
const gate = useGate()
|
||||
|
||||
useSetTitle(pinnedFeedInfos[selectedIndex]?.displayName)
|
||||
useOTAUpdates()
|
||||
@@ -169,6 +171,10 @@ function HomeScreenReady({
|
||||
const {isMobile} = useWebMediaQueries()
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
if (gate('fixed_bottom_bar')) {
|
||||
// Unnecessary because it's always there.
|
||||
return
|
||||
}
|
||||
const listener = AppState.addEventListener('change', nextAppState => {
|
||||
if (nextAppState === 'active') {
|
||||
if (isMobile && mode.value === 1) {
|
||||
@@ -181,7 +187,7 @@ function HomeScreenReady({
|
||||
return () => {
|
||||
listener.remove()
|
||||
}
|
||||
}, [setMinimalShellMode, mode, isMobile]),
|
||||
}, [setMinimalShellMode, mode, isMobile, gate]),
|
||||
)
|
||||
|
||||
const onPageSelected = React.useCallback(
|
||||
|
||||
@@ -145,6 +145,7 @@ export function LanguageSettingsScreen(_props: Props) {
|
||||
backgroundColor: pal.viewLight.backgroundColor,
|
||||
color: pal.text.color,
|
||||
fontSize: 14,
|
||||
fontFamily: 'inherit',
|
||||
letterSpacing: 0.5,
|
||||
fontWeight: '500',
|
||||
paddingHorizontal: 14,
|
||||
@@ -236,6 +237,7 @@ export function LanguageSettingsScreen(_props: Props) {
|
||||
backgroundColor: pal.viewLight.backgroundColor,
|
||||
color: pal.text.color,
|
||||
fontSize: 14,
|
||||
fontFamily: 'inherit',
|
||||
letterSpacing: 0.5,
|
||||
fontWeight: '500',
|
||||
paddingHorizontal: 14,
|
||||
|
||||
@@ -1,39 +1,19 @@
|
||||
import React from 'react'
|
||||
import {StyleSheet, View} from 'react-native'
|
||||
import Animated from 'react-native-reanimated'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {View} from 'react-native'
|
||||
import {useFocusEffect} from '@react-navigation/native'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
import {clamp} from 'lodash'
|
||||
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {
|
||||
RQKEY as POST_THREAD_RQKEY,
|
||||
ThreadNode,
|
||||
} from '#/state/queries/post-thread'
|
||||
import {useSession} from '#/state/session'
|
||||
import {useSetMinimalShellMode} from '#/state/shell'
|
||||
import {useComposerControls} from '#/state/shell/composer'
|
||||
import {useMinimalShellFabTransform} from 'lib/hooks/useMinimalShellTransform'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types'
|
||||
import {makeRecordUri} from 'lib/strings/url-helpers'
|
||||
import {s} from 'lib/styles'
|
||||
import {ComposePrompt} from 'view/com/composer/Prompt'
|
||||
import {PostThread as PostThreadComponent} from '../com/post-thread/PostThread'
|
||||
|
||||
type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostThread'>
|
||||
export function PostThreadScreen({route}: Props) {
|
||||
const queryClient = useQueryClient()
|
||||
const {hasSession} = useSession()
|
||||
const fabMinimalShellTransform = useMinimalShellFabTransform()
|
||||
const setMinimalShellMode = useSetMinimalShellMode()
|
||||
const {openComposer} = useComposerControls()
|
||||
const safeAreaInsets = useSafeAreaInsets()
|
||||
|
||||
const {name, rkey} = route.params
|
||||
const {isMobile} = useWebMediaQueries()
|
||||
const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey)
|
||||
const [canReply, setCanReply] = React.useState(false)
|
||||
|
||||
useFocusEffect(
|
||||
React.useCallback(() => {
|
||||
@@ -41,59 +21,11 @@ export function PostThreadScreen({route}: Props) {
|
||||
}, [setMinimalShellMode]),
|
||||
)
|
||||
|
||||
const onPressReply = React.useCallback(() => {
|
||||
if (!uri) {
|
||||
return
|
||||
}
|
||||
const thread = queryClient.getQueryData<ThreadNode>(POST_THREAD_RQKEY(uri))
|
||||
if (thread?.type !== 'post') {
|
||||
return
|
||||
}
|
||||
openComposer({
|
||||
replyTo: {
|
||||
uri: thread.post.uri,
|
||||
cid: thread.post.cid,
|
||||
text: thread.record.text,
|
||||
author: thread.post.author,
|
||||
embed: thread.post.embed,
|
||||
},
|
||||
onPost: () =>
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: POST_THREAD_RQKEY(uri),
|
||||
}),
|
||||
})
|
||||
}, [openComposer, queryClient, uri])
|
||||
|
||||
return (
|
||||
<View style={s.hContentRegion}>
|
||||
<View style={s.flex1}>
|
||||
<PostThreadComponent
|
||||
uri={uri}
|
||||
onPressReply={onPressReply}
|
||||
onCanReply={setCanReply}
|
||||
/>
|
||||
<PostThreadComponent uri={uri} />
|
||||
</View>
|
||||
{isMobile && canReply && hasSession && (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.prompt,
|
||||
fabMinimalShellTransform,
|
||||
{
|
||||
bottom: clamp(safeAreaInsets.bottom, 15, 30),
|
||||
},
|
||||
]}>
|
||||
<ComposePrompt onPressCompose={onPressReply} />
|
||||
</Animated.View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
prompt: {
|
||||
// @ts-ignore web-only
|
||||
position: isWeb ? 'fixed' : 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -32,6 +32,7 @@ import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
|
||||
import {
|
||||
useAddSavedFeedsMutation,
|
||||
usePreferencesQuery,
|
||||
UsePreferencesQueryResponse,
|
||||
useRemoveFeedMutation,
|
||||
useUpdateSavedFeedsMutation,
|
||||
} from '#/state/queries/preferences'
|
||||
@@ -67,9 +68,10 @@ import {LoadingScreen} from 'view/com/util/LoadingScreen'
|
||||
import {Text} from 'view/com/util/text/Text'
|
||||
import * as Toast from 'view/com/util/Toast'
|
||||
import {CenteredView} from 'view/com/util/Views'
|
||||
import {ListHiddenScreen} from '#/screens/List/ListHiddenScreen'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {useDialogControl} from '#/components/Dialog'
|
||||
import {ScreenHider} from '#/components/moderation/ScreenHider'
|
||||
import * as Hider from '#/components/moderation/Hider'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog'
|
||||
import {RichText} from '#/components/RichText'
|
||||
@@ -88,6 +90,7 @@ export function ProfileListScreen(props: Props) {
|
||||
const {data: resolvedUri, error: resolveError} = useResolveUriQuery(
|
||||
AtUri.make(handleOrDid, 'app.bsky.graph.list', rkey).toString(),
|
||||
)
|
||||
const {data: preferences} = usePreferencesQuery()
|
||||
const {data: list, error: listError} = useListQuery(resolvedUri?.uri)
|
||||
const moderationOpts = useModerationOpts()
|
||||
|
||||
@@ -110,12 +113,13 @@ export function ProfileListScreen(props: Props) {
|
||||
)
|
||||
}
|
||||
|
||||
return resolvedUri && list && moderationOpts ? (
|
||||
return resolvedUri && list && moderationOpts && preferences ? (
|
||||
<ProfileListScreenLoaded
|
||||
{...props}
|
||||
uri={resolvedUri.uri}
|
||||
list={list}
|
||||
moderationOpts={moderationOpts}
|
||||
preferences={preferences}
|
||||
/>
|
||||
) : (
|
||||
<LoadingScreen />
|
||||
@@ -127,27 +131,32 @@ function ProfileListScreenLoaded({
|
||||
uri,
|
||||
list,
|
||||
moderationOpts,
|
||||
preferences,
|
||||
}: Props & {
|
||||
uri: string
|
||||
list: AppBskyGraphDefs.ListView
|
||||
moderationOpts: ModerationOpts
|
||||
preferences: UsePreferencesQueryResponse
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const queryClient = useQueryClient()
|
||||
const {openComposer} = useComposerControls()
|
||||
const setMinimalShellMode = useSetMinimalShellMode()
|
||||
const {currentAccount} = useSession()
|
||||
const {rkey} = route.params
|
||||
const feedSectionRef = React.useRef<SectionRef>(null)
|
||||
const aboutSectionRef = React.useRef<SectionRef>(null)
|
||||
const {openModal} = useModalControls()
|
||||
const isCurateList = list.purpose === 'app.bsky.graph.defs#curatelist'
|
||||
const isCurateList = list.purpose === AppBskyGraphDefs.CURATELIST
|
||||
const isScreenFocused = useIsFocused()
|
||||
const isHidden = list.labels?.findIndex(l => l.val === '!hide') !== -1
|
||||
const isOwner = currentAccount?.did === list.creator.did
|
||||
|
||||
const moderation = React.useMemo(() => {
|
||||
return moderateUserList(list, moderationOpts)
|
||||
}, [list, moderationOpts])
|
||||
|
||||
useSetTitle(list.name)
|
||||
useSetTitle(isHidden ? _(msg`List Hidden`) : list.name)
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
@@ -179,34 +188,75 @@ function ProfileListScreenLoaded({
|
||||
)
|
||||
|
||||
const renderHeader = useCallback(() => {
|
||||
return <Header rkey={rkey} list={list} />
|
||||
}, [rkey, list])
|
||||
return <Header rkey={rkey} list={list} preferences={preferences} />
|
||||
}, [rkey, list, preferences])
|
||||
|
||||
if (isCurateList) {
|
||||
return (
|
||||
<ScreenHider
|
||||
screenDescription={'list'}
|
||||
modui={moderation.ui('contentView')}>
|
||||
<Hider.Outer modui={moderation.ui('contentView')} allowOverride={isOwner}>
|
||||
<Hider.Mask>
|
||||
<ListHiddenScreen list={list} preferences={preferences} />
|
||||
</Hider.Mask>
|
||||
<Hider.Content>
|
||||
<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>
|
||||
</Hider.Content>
|
||||
</Hider.Outer>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Hider.Outer modui={moderation.ui('contentView')} allowOverride={isOwner}>
|
||||
<Hider.Mask>
|
||||
<ListHiddenScreen list={list} preferences={preferences} />
|
||||
</Hider.Mask>
|
||||
<Hider.Content>
|
||||
<View style={s.hContentRegion}>
|
||||
<PagerWithHeader
|
||||
items={SECTION_TITLES_CURATE}
|
||||
items={SECTION_TITLES_MOD}
|
||||
isHeaderReady={true}
|
||||
renderHeader={renderHeader}
|
||||
onCurrentPageSelected={onCurrentPageSelected}>
|
||||
{({headerHeight, scrollElRef, isFocused}) => (
|
||||
<FeedSection
|
||||
ref={feedSectionRef}
|
||||
feed={`list|${uri}`}
|
||||
scrollElRef={scrollElRef as ListRef}
|
||||
headerHeight={headerHeight}
|
||||
isFocused={isScreenFocused && isFocused}
|
||||
/>
|
||||
)}
|
||||
renderHeader={renderHeader}>
|
||||
{({headerHeight, scrollElRef}) => (
|
||||
<AboutSection
|
||||
ref={aboutSectionRef}
|
||||
scrollElRef={scrollElRef as ListRef}
|
||||
list={list}
|
||||
scrollElRef={scrollElRef as ListRef}
|
||||
onPressAddUser={onPressAddUser}
|
||||
headerHeight={headerHeight}
|
||||
/>
|
||||
@@ -227,47 +277,20 @@ function ProfileListScreenLoaded({
|
||||
accessibilityHint=""
|
||||
/>
|
||||
</View>
|
||||
</ScreenHider>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<ScreenHider
|
||||
screenDescription={_(msg`list`)}
|
||||
modui={moderation.ui('contentView')}>
|
||||
<View style={s.hContentRegion}>
|
||||
<PagerWithHeader
|
||||
items={SECTION_TITLES_MOD}
|
||||
isHeaderReady={true}
|
||||
renderHeader={renderHeader}>
|
||||
{({headerHeight, scrollElRef}) => (
|
||||
<AboutSection
|
||||
list={list}
|
||||
scrollElRef={scrollElRef as ListRef}
|
||||
onPressAddUser={onPressAddUser}
|
||||
headerHeight={headerHeight}
|
||||
/>
|
||||
)}
|
||||
</PagerWithHeader>
|
||||
<FAB
|
||||
testID="composeFAB"
|
||||
onPress={() => openComposer({})}
|
||||
icon={
|
||||
<ComposeIcon2
|
||||
strokeWidth={1.5}
|
||||
size={29}
|
||||
style={{color: 'white'}}
|
||||
/>
|
||||
}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`New post`)}
|
||||
accessibilityHint=""
|
||||
/>
|
||||
</View>
|
||||
</ScreenHider>
|
||||
</Hider.Content>
|
||||
</Hider.Outer>
|
||||
)
|
||||
}
|
||||
|
||||
function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
|
||||
function Header({
|
||||
rkey,
|
||||
list,
|
||||
preferences,
|
||||
}: {
|
||||
rkey: string
|
||||
list: AppBskyGraphDefs.ListView
|
||||
preferences: UsePreferencesQueryResponse
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
const palInverted = usePalette('inverted')
|
||||
const {_} = useLingui()
|
||||
@@ -283,7 +306,6 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
|
||||
const isBlocking = !!list.viewer?.blocked
|
||||
const isMuting = !!list.viewer?.muted
|
||||
const isOwner = list.creator.did === currentAccount?.did
|
||||
const {data: preferences} = usePreferencesQuery()
|
||||
const {track} = useAnalytics()
|
||||
const playHaptic = useHaptics()
|
||||
|
||||
@@ -644,7 +666,7 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
|
||||
cid: list.cid,
|
||||
}}
|
||||
/>
|
||||
{isCurateList || isPinned ? (
|
||||
{isCurateList ? (
|
||||
<Button
|
||||
testID={isPinned ? 'unpinBtn' : 'pinBtn'}
|
||||
type={isPinned ? 'default' : 'inverted'}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user