Merge branch 'main' into hailey/79fab
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
Copyright 2023–2025 Bluesky PBC
|
Copyright 2023–2025 Bluesky Social PBC
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
|||||||
-9
@@ -1,9 +0,0 @@
|
|||||||
export const openPicker = jest
|
|
||||||
.fn()
|
|
||||||
.mockImplementation(() => Promise.resolve({uri: ''}))
|
|
||||||
export const openCamera = jest
|
|
||||||
.fn()
|
|
||||||
.mockImplementation(() => Promise.resolve({uri: ''}))
|
|
||||||
export const openCropper = jest
|
|
||||||
.fn()
|
|
||||||
.mockImplementation(() => Promise.resolve({uri: ''}))
|
|
||||||
@@ -30,7 +30,10 @@ const NativeView: React.ComponentType<
|
|||||||
|
|
||||||
const NativeModule = requireNativeModule('BottomSheet')
|
const NativeModule = requireNativeModule('BottomSheet')
|
||||||
|
|
||||||
const isIOS15 = Platform.OS === 'ios' && Number(Platform.Version) < 16
|
const isIOS15 =
|
||||||
|
Platform.OS === 'ios' &&
|
||||||
|
// semvar - can be 3 segments, so can't use Number(Platform.Version)
|
||||||
|
Number(Platform.Version.split('.').at(0)) < 16
|
||||||
|
|
||||||
export class BottomSheetNativeComponent extends React.Component<
|
export class BottomSheetNativeComponent extends React.Component<
|
||||||
BottomSheetViewProps,
|
BottomSheetViewProps,
|
||||||
|
|||||||
+1
-2
@@ -142,7 +142,7 @@
|
|||||||
"expo-font": "~13.3.0",
|
"expo-font": "~13.3.0",
|
||||||
"expo-haptics": "~14.1.4",
|
"expo-haptics": "~14.1.4",
|
||||||
"expo-image": "~2.1.6",
|
"expo-image": "~2.1.6",
|
||||||
"expo-image-crop-tool": "^0.1.6",
|
"expo-image-crop-tool": "^0.1.8",
|
||||||
"expo-image-manipulator": "~13.1.5",
|
"expo-image-manipulator": "~13.1.5",
|
||||||
"expo-image-picker": "~16.1.4",
|
"expo-image-picker": "~16.1.4",
|
||||||
"expo-linear-gradient": "~14.1.4",
|
"expo-linear-gradient": "~14.1.4",
|
||||||
@@ -193,7 +193,6 @@
|
|||||||
"react-native-keyboard-controller": "^1.17.1",
|
"react-native-keyboard-controller": "^1.17.1",
|
||||||
"react-native-mmkv": "^3.2.0",
|
"react-native-mmkv": "^3.2.0",
|
||||||
"react-native-pager-view": "6.7.1",
|
"react-native-pager-view": "6.7.1",
|
||||||
"react-native-picker-select": "^9.3.1",
|
|
||||||
"react-native-progress": "bluesky-social/react-native-progress",
|
"react-native-progress": "bluesky-social/react-native-progress",
|
||||||
"react-native-qrcode-styled": "^0.3.3",
|
"react-native-qrcode-styled": "^0.3.3",
|
||||||
"react-native-reanimated": "~3.17.5",
|
"react-native-reanimated": "~3.17.5",
|
||||||
|
|||||||
@@ -204,6 +204,9 @@ export const atoms = {
|
|||||||
flex_grow: {
|
flex_grow: {
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
},
|
},
|
||||||
|
flex_grow_0: {
|
||||||
|
flexGrow: 0,
|
||||||
|
},
|
||||||
flex_shrink: {
|
flex_shrink: {
|
||||||
flexShrink: 1,
|
flexShrink: 1,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {View} from 'react-native'
|
import {msg} from '@lingui/macro'
|
||||||
import RNPickerSelect, {PickerSelectProps} from 'react-native-picker-select'
|
import {useLingui} from '@lingui/react'
|
||||||
import {useQueryClient} from '@tanstack/react-query'
|
import {useQueryClient} from '@tanstack/react-query'
|
||||||
|
|
||||||
import {sanitizeAppLanguageSetting} from '#/locale/helpers'
|
import {sanitizeAppLanguageSetting} from '#/locale/helpers'
|
||||||
import {APP_LANGUAGES} from '#/locale/languages'
|
import {APP_LANGUAGES} from '#/locale/languages'
|
||||||
import {useLanguagePrefs, useLanguagePrefsApi} from '#/state/preferences'
|
import {useLanguagePrefs, useLanguagePrefsApi} from '#/state/preferences'
|
||||||
import {resetPostsFeedQueries} from '#/state/queries/post-feed'
|
import {resetPostsFeedQueries} from '#/state/queries/post-feed'
|
||||||
import {atoms as a, useTheme, ViewStyleProp} from '#/alf'
|
import {atoms as a, platform, useTheme} from '#/alf'
|
||||||
import {ChevronBottom_Stroke2_Corner0_Rounded as ChevronDown} from '#/components/icons/Chevron'
|
import * as Select from '#/components/Select'
|
||||||
|
import {Button} from './Button'
|
||||||
|
|
||||||
export function AppLanguageDropdown(_props: ViewStyleProp) {
|
export function AppLanguageDropdown() {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
|
const {_} = useLingui()
|
||||||
|
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const langPrefs = useLanguagePrefs()
|
const langPrefs = useLanguagePrefs()
|
||||||
@@ -19,7 +21,7 @@ export function AppLanguageDropdown(_props: ViewStyleProp) {
|
|||||||
const sanitizedLang = sanitizeAppLanguageSetting(langPrefs.appLanguage)
|
const sanitizedLang = sanitizeAppLanguageSetting(langPrefs.appLanguage)
|
||||||
|
|
||||||
const onChangeAppLanguage = React.useCallback(
|
const onChangeAppLanguage = React.useCallback(
|
||||||
(value: Parameters<PickerSelectProps['onValueChange']>[0]) => {
|
(value: string) => {
|
||||||
if (!value) return
|
if (!value) return
|
||||||
if (sanitizedLang !== value) {
|
if (sanitizedLang !== value) {
|
||||||
setLangPrefs.setAppLanguage(sanitizeAppLanguageSetting(value))
|
setLangPrefs.setAppLanguage(sanitizeAppLanguageSetting(value))
|
||||||
@@ -32,43 +34,48 @@ export function AppLanguageDropdown(_props: ViewStyleProp) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={a.relative}>
|
<Select.Root
|
||||||
<RNPickerSelect
|
value={sanitizeAppLanguageSetting(langPrefs.appLanguage)}
|
||||||
darkTheme={t.scheme === 'dark'}
|
onValueChange={onChangeAppLanguage}>
|
||||||
placeholder={{}}
|
<Select.Trigger label={_(msg`Change app language`)}>
|
||||||
value={sanitizedLang}
|
{({props}) => (
|
||||||
onValueChange={onChangeAppLanguage}
|
<Button
|
||||||
items={APP_LANGUAGES.filter(l => Boolean(l.code2)).map(l => ({
|
{...props}
|
||||||
|
label={props.accessibilityLabel}
|
||||||
|
size={platform({
|
||||||
|
web: 'tiny',
|
||||||
|
native: 'small',
|
||||||
|
})}
|
||||||
|
variant="ghost"
|
||||||
|
color="secondary"
|
||||||
|
style={[
|
||||||
|
a.pr_xs,
|
||||||
|
a.pl_sm,
|
||||||
|
platform({
|
||||||
|
web: [{alignSelf: 'flex-start'}, a.gap_sm],
|
||||||
|
native: [a.gap_xs],
|
||||||
|
}),
|
||||||
|
]}>
|
||||||
|
<Select.ValueText
|
||||||
|
placeholder={_(msg`Select an app language`)}
|
||||||
|
style={[t.atoms.text_contrast_medium]}
|
||||||
|
/>
|
||||||
|
<Select.Icon style={[t.atoms.text_contrast_medium]} />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.Content
|
||||||
|
renderItem={({label, value}) => (
|
||||||
|
<Select.Item value={value} label={label}>
|
||||||
|
<Select.ItemIndicator />
|
||||||
|
<Select.ItemText>{label}</Select.ItemText>
|
||||||
|
</Select.Item>
|
||||||
|
)}
|
||||||
|
items={APP_LANGUAGES.map(l => ({
|
||||||
label: l.name,
|
label: l.name,
|
||||||
value: l.code2,
|
value: l.code2,
|
||||||
key: l.code2,
|
|
||||||
}))}
|
}))}
|
||||||
useNativeAndroidPickerStyle={false}
|
|
||||||
style={{
|
|
||||||
inputAndroid: {
|
|
||||||
color: t.atoms.text_contrast_medium.color,
|
|
||||||
fontSize: 16,
|
|
||||||
paddingRight: 12 + 4,
|
|
||||||
},
|
|
||||||
inputIOS: {
|
|
||||||
color: t.atoms.text.color,
|
|
||||||
fontSize: 16,
|
|
||||||
paddingRight: 12 + 4,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
</Select.Root>
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
a.absolute,
|
|
||||||
a.inset_0,
|
|
||||||
{left: 'auto'},
|
|
||||||
{pointerEvents: 'none'},
|
|
||||||
a.align_center,
|
|
||||||
a.justify_center,
|
|
||||||
]}>
|
|
||||||
<ChevronDown fill={t.atoms.text.color} size="xs" />
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
import {View} from 'react-native'
|
|
||||||
import {useQueryClient} from '@tanstack/react-query'
|
|
||||||
|
|
||||||
import {sanitizeAppLanguageSetting} from '#/locale/helpers'
|
|
||||||
import {APP_LANGUAGES} from '#/locale/languages'
|
|
||||||
import {useLanguagePrefs, useLanguagePrefsApi} from '#/state/preferences'
|
|
||||||
import {resetPostsFeedQueries} from '#/state/queries/post-feed'
|
|
||||||
import {atoms as a, useTheme, ViewStyleProp} from '#/alf'
|
|
||||||
import {ChevronBottom_Stroke2_Corner0_Rounded as ChevronDown} from '#/components/icons/Chevron'
|
|
||||||
import {Text} from '#/components/Typography'
|
|
||||||
|
|
||||||
export function AppLanguageDropdown({style}: ViewStyleProp) {
|
|
||||||
const t = useTheme()
|
|
||||||
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
const langPrefs = useLanguagePrefs()
|
|
||||||
const setLangPrefs = useLanguagePrefsApi()
|
|
||||||
|
|
||||||
const sanitizedLang = sanitizeAppLanguageSetting(langPrefs.appLanguage)
|
|
||||||
|
|
||||||
const onChangeAppLanguage = React.useCallback(
|
|
||||||
(ev: React.ChangeEvent<HTMLSelectElement>) => {
|
|
||||||
const value = ev.target.value
|
|
||||||
|
|
||||||
if (!value) return
|
|
||||||
if (sanitizedLang !== value) {
|
|
||||||
setLangPrefs.setAppLanguage(sanitizeAppLanguageSetting(value))
|
|
||||||
}
|
|
||||||
|
|
||||||
// reset feeds to refetch content
|
|
||||||
resetPostsFeedQueries(queryClient)
|
|
||||||
},
|
|
||||||
[sanitizedLang, setLangPrefs, queryClient],
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
// We don't have hitSlop here to increase the tap region,
|
|
||||||
// alternative is negative margins.
|
|
||||||
{height: 32, marginVertical: -((32 - 14) / 2)},
|
|
||||||
style,
|
|
||||||
]}>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
a.flex_row,
|
|
||||||
a.gap_sm,
|
|
||||||
a.align_center,
|
|
||||||
a.flex_shrink,
|
|
||||||
a.h_full,
|
|
||||||
t.atoms.bg,
|
|
||||||
]}>
|
|
||||||
<Text aria-hidden={true} style={t.atoms.text_contrast_medium}>
|
|
||||||
{APP_LANGUAGES.find(l => l.code2 === sanitizedLang)?.name}
|
|
||||||
</Text>
|
|
||||||
<ChevronDown fill={t.atoms.text.color} size="xs" style={a.flex_0} />
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<select
|
|
||||||
value={sanitizedLang}
|
|
||||||
onChange={onChangeAppLanguage}
|
|
||||||
style={{
|
|
||||||
fontSize: a.text_sm.fontSize,
|
|
||||||
letterSpacing: a.text_sm.letterSpacing,
|
|
||||||
cursor: 'pointer',
|
|
||||||
position: 'absolute',
|
|
||||||
inset: 0,
|
|
||||||
opacity: 0,
|
|
||||||
color: t.atoms.text.color,
|
|
||||||
background: t.atoms.bg.backgroundColor,
|
|
||||||
padding: 4,
|
|
||||||
maxWidth: '100%',
|
|
||||||
}}>
|
|
||||||
{APP_LANGUAGES.filter(l => Boolean(l.code2)).map(l => (
|
|
||||||
<option key={l.code2} value={l.code2}>
|
|
||||||
{l.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,11 @@
|
|||||||
import React from 'react'
|
import {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useId,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
} from 'react'
|
||||||
|
|
||||||
import {useDialogStateContext} from '#/state/dialogs'
|
import {useDialogStateContext} from '#/state/dialogs'
|
||||||
import {
|
import {
|
||||||
@@ -8,7 +15,7 @@ import {
|
|||||||
} from '#/components/Dialog/types'
|
} from '#/components/Dialog/types'
|
||||||
import {BottomSheetSnapPoint} from '../../../modules/bottom-sheet/src/BottomSheet.types'
|
import {BottomSheetSnapPoint} from '../../../modules/bottom-sheet/src/BottomSheet.types'
|
||||||
|
|
||||||
export const Context = React.createContext<DialogContextProps>({
|
export const Context = createContext<DialogContextProps>({
|
||||||
close: () => {},
|
close: () => {},
|
||||||
isNativeDialog: false,
|
isNativeDialog: false,
|
||||||
nativeSnapPoint: BottomSheetSnapPoint.Hidden,
|
nativeSnapPoint: BottomSheetSnapPoint.Hidden,
|
||||||
@@ -18,18 +25,18 @@ export const Context = React.createContext<DialogContextProps>({
|
|||||||
})
|
})
|
||||||
|
|
||||||
export function useDialogContext() {
|
export function useDialogContext() {
|
||||||
return React.useContext(Context)
|
return useContext(Context)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useDialogControl(): DialogOuterProps['control'] {
|
export function useDialogControl(): DialogOuterProps['control'] {
|
||||||
const id = React.useId()
|
const id = useId()
|
||||||
const control = React.useRef<DialogControlRefProps>({
|
const control = useRef<DialogControlRefProps>({
|
||||||
open: () => {},
|
open: () => {},
|
||||||
close: () => {},
|
close: () => {},
|
||||||
})
|
})
|
||||||
const {activeDialogs} = useDialogStateContext()
|
const {activeDialogs} = useDialogStateContext()
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
activeDialogs.current.set(id, control)
|
activeDialogs.current.set(id, control)
|
||||||
return () => {
|
return () => {
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@@ -37,7 +44,7 @@ export function useDialogControl(): DialogOuterProps['control'] {
|
|||||||
}
|
}
|
||||||
}, [id, activeDialogs])
|
}, [id, activeDialogs])
|
||||||
|
|
||||||
return React.useMemo<DialogOuterProps['control']>(
|
return useMemo<DialogOuterProps['control']>(
|
||||||
() => ({
|
() => ({
|
||||||
id,
|
id,
|
||||||
ref: control,
|
ref: control,
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import React from 'react'
|
import {
|
||||||
import {StyleProp, TextStyle, View, ViewStyle} from 'react-native'
|
type LayoutChangeEvent,
|
||||||
|
type StyleProp,
|
||||||
|
type TextStyle,
|
||||||
|
View,
|
||||||
|
type ViewStyle,
|
||||||
|
} from 'react-native'
|
||||||
|
import type React from 'react'
|
||||||
|
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
@@ -9,15 +15,18 @@ export function Header({
|
|||||||
renderRight,
|
renderRight,
|
||||||
children,
|
children,
|
||||||
style,
|
style,
|
||||||
|
onLayout,
|
||||||
}: {
|
}: {
|
||||||
renderLeft?: () => React.ReactNode
|
renderLeft?: () => React.ReactNode
|
||||||
renderRight?: () => React.ReactNode
|
renderRight?: () => React.ReactNode
|
||||||
children?: React.ReactNode
|
children?: React.ReactNode
|
||||||
style?: StyleProp<ViewStyle>
|
style?: StyleProp<ViewStyle>
|
||||||
|
onLayout?: (event: LayoutChangeEvent) => void
|
||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
|
onLayout={onLayout}
|
||||||
style={[
|
style={[
|
||||||
a.relative,
|
a.relative,
|
||||||
a.w_full,
|
a.w_full,
|
||||||
|
|||||||
+24
-14
@@ -1,4 +1,14 @@
|
|||||||
import React from 'react'
|
import {
|
||||||
|
createContext,
|
||||||
|
Fragment,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useId,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react'
|
||||||
|
|
||||||
type Component = React.ReactElement
|
type Component = React.ReactElement
|
||||||
|
|
||||||
@@ -9,32 +19,32 @@ type ContextType = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ComponentMap = {
|
type ComponentMap = {
|
||||||
[id: string]: Component
|
[id: string]: Component | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createPortalGroup() {
|
export function createPortalGroup() {
|
||||||
const Context = React.createContext<ContextType>({
|
const Context = createContext<ContextType>({
|
||||||
outlet: null,
|
outlet: null,
|
||||||
append: () => {},
|
append: () => {},
|
||||||
remove: () => {},
|
remove: () => {},
|
||||||
})
|
})
|
||||||
|
|
||||||
function Provider(props: React.PropsWithChildren<{}>) {
|
function Provider(props: React.PropsWithChildren<{}>) {
|
||||||
const map = React.useRef<ComponentMap>({})
|
const map = useRef<ComponentMap>({})
|
||||||
const [outlet, setOutlet] = React.useState<ContextType['outlet']>(null)
|
const [outlet, setOutlet] = useState<ContextType['outlet']>(null)
|
||||||
|
|
||||||
const append = React.useCallback<ContextType['append']>((id, component) => {
|
const append = useCallback<ContextType['append']>((id, component) => {
|
||||||
if (map.current[id]) return
|
if (map.current[id]) return
|
||||||
map.current[id] = <React.Fragment key={id}>{component}</React.Fragment>
|
map.current[id] = <Fragment key={id}>{component}</Fragment>
|
||||||
setOutlet(<>{Object.values(map.current)}</>)
|
setOutlet(<>{Object.values(map.current)}</>)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const remove = React.useCallback<ContextType['remove']>(id => {
|
const remove = useCallback<ContextType['remove']>(id => {
|
||||||
delete map.current[id]
|
map.current[id] = null
|
||||||
setOutlet(<>{Object.values(map.current)}</>)
|
setOutlet(<>{Object.values(map.current)}</>)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const contextValue = React.useMemo(
|
const contextValue = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
outlet,
|
outlet,
|
||||||
append,
|
append,
|
||||||
@@ -49,14 +59,14 @@ export function createPortalGroup() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Outlet() {
|
function Outlet() {
|
||||||
const ctx = React.useContext(Context)
|
const ctx = useContext(Context)
|
||||||
return ctx.outlet
|
return ctx.outlet
|
||||||
}
|
}
|
||||||
|
|
||||||
function Portal({children}: React.PropsWithChildren<{}>) {
|
function Portal({children}: React.PropsWithChildren<{}>) {
|
||||||
const {append, remove} = React.useContext(Context)
|
const {append, remove} = useContext(Context)
|
||||||
const id = React.useId()
|
const id = useId()
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
append(id, children as Component)
|
append(id, children as Component)
|
||||||
return () => remove(id)
|
return () => remove(id)
|
||||||
}, [id, children, append, remove])
|
}, [id, children, append, remove])
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from 'react'
|
||||||
|
import {View} from 'react-native'
|
||||||
|
import {msg, Trans} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
import {useTheme} from '#/alf'
|
||||||
|
import {atoms as a} from '#/alf'
|
||||||
|
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||||
|
import * as Dialog from '#/components/Dialog'
|
||||||
|
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||||
|
import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
|
||||||
|
import {ChevronTopBottom_Stroke2_Corner0_Rounded as ChevronUpDownIcon} from '#/components/icons/Chevron'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
import {
|
||||||
|
type ContentProps,
|
||||||
|
type IconProps,
|
||||||
|
type ItemIndicatorProps,
|
||||||
|
type ItemProps,
|
||||||
|
type ItemTextProps,
|
||||||
|
type RootProps,
|
||||||
|
type TriggerProps,
|
||||||
|
type ValueProps,
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
type ContextType = {
|
||||||
|
control: Dialog.DialogControlProps
|
||||||
|
} & Pick<RootProps, 'value' | 'onValueChange' | 'disabled'>
|
||||||
|
|
||||||
|
const Context = createContext<ContextType | null>(null)
|
||||||
|
|
||||||
|
const ValueTextContext = createContext<
|
||||||
|
[any, React.Dispatch<React.SetStateAction<any>>]
|
||||||
|
>([undefined, () => {}])
|
||||||
|
|
||||||
|
function useSelectContext() {
|
||||||
|
const ctx = useContext(Context)
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('Select components must must be used within a Select.Root')
|
||||||
|
}
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Root({children, value, onValueChange, disabled}: RootProps) {
|
||||||
|
const control = Dialog.useDialogControl()
|
||||||
|
const valueTextCtx = useState<any>()
|
||||||
|
|
||||||
|
const ctx = useMemo(
|
||||||
|
() => ({
|
||||||
|
control,
|
||||||
|
value,
|
||||||
|
onValueChange,
|
||||||
|
disabled,
|
||||||
|
}),
|
||||||
|
[control, value, onValueChange, disabled],
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<Context.Provider value={ctx}>
|
||||||
|
<ValueTextContext.Provider value={valueTextCtx}>
|
||||||
|
{children}
|
||||||
|
</ValueTextContext.Provider>
|
||||||
|
</Context.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Trigger({children, label}: TriggerProps) {
|
||||||
|
const {control} = useSelectContext()
|
||||||
|
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
|
||||||
|
const {
|
||||||
|
state: pressed,
|
||||||
|
onIn: onPressIn,
|
||||||
|
onOut: onPressOut,
|
||||||
|
} = useInteractionState()
|
||||||
|
|
||||||
|
if (typeof children === 'function') {
|
||||||
|
return children({
|
||||||
|
isNative: true,
|
||||||
|
control,
|
||||||
|
state: {
|
||||||
|
hovered: false,
|
||||||
|
focused,
|
||||||
|
pressed,
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
onPress: control.open,
|
||||||
|
onFocus,
|
||||||
|
onBlur,
|
||||||
|
onPressIn,
|
||||||
|
onPressOut,
|
||||||
|
accessibilityLabel: label,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
label={label}
|
||||||
|
onPress={control.open}
|
||||||
|
style={[a.flex_1, a.justify_between]}
|
||||||
|
color="secondary"
|
||||||
|
size="small"
|
||||||
|
variant="solid">
|
||||||
|
<>{children}</>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ValueText({
|
||||||
|
placeholder,
|
||||||
|
children = value => value.label,
|
||||||
|
style,
|
||||||
|
}: ValueProps) {
|
||||||
|
const [value] = useContext(ValueTextContext)
|
||||||
|
const t = useTheme()
|
||||||
|
|
||||||
|
let text = value && children(value)
|
||||||
|
if (typeof text !== 'string') text = placeholder
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ButtonText style={[t.atoms.text, a.font_normal, style]}>{text}</ButtonText>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Icon({}: IconProps) {
|
||||||
|
return <ButtonIcon icon={ChevronUpDownIcon} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Content<T>({
|
||||||
|
items,
|
||||||
|
valueExtractor = defaultItemValueExtractor,
|
||||||
|
...props
|
||||||
|
}: ContentProps<T>) {
|
||||||
|
const {control, ...context} = useSelectContext()
|
||||||
|
const [, setValue] = useContext(ValueTextContext)
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const item = items.find(item => valueExtractor(item) === context.value)
|
||||||
|
if (item) {
|
||||||
|
setValue(item)
|
||||||
|
}
|
||||||
|
}, [items, context.value, valueExtractor, setValue])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog.Outer control={control}>
|
||||||
|
<ContentInner
|
||||||
|
control={control}
|
||||||
|
items={items}
|
||||||
|
valueExtractor={valueExtractor}
|
||||||
|
{...props}
|
||||||
|
{...context}
|
||||||
|
/>
|
||||||
|
</Dialog.Outer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContentInner<T>({
|
||||||
|
items,
|
||||||
|
renderItem,
|
||||||
|
valueExtractor,
|
||||||
|
...context
|
||||||
|
}: ContentProps<T> & ContextType) {
|
||||||
|
const control = Dialog.useDialogContext()
|
||||||
|
|
||||||
|
const {_} = useLingui()
|
||||||
|
const [headerHeight, setHeaderHeight] = useState(50)
|
||||||
|
|
||||||
|
const render = useCallback(
|
||||||
|
({item, index}: {item: T; index: number}) => {
|
||||||
|
return renderItem(item, index, context.value)
|
||||||
|
},
|
||||||
|
[renderItem, context.value],
|
||||||
|
)
|
||||||
|
|
||||||
|
const doneButton = useCallback(
|
||||||
|
() => (
|
||||||
|
<Button
|
||||||
|
label={_(msg`Done`)}
|
||||||
|
onPress={() => control.close()}
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
variant="ghost"
|
||||||
|
style={[a.rounded_full]}>
|
||||||
|
<ButtonText style={[a.text_md]}>
|
||||||
|
<Trans>Done</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
[control, _],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Context.Provider value={context}>
|
||||||
|
<Dialog.Header
|
||||||
|
renderRight={doneButton}
|
||||||
|
onLayout={evt => setHeaderHeight(evt.nativeEvent.layout.height)}
|
||||||
|
style={[a.absolute, a.top_0, a.left_0, a.right_0, a.z_10]}>
|
||||||
|
<Dialog.HeaderText>
|
||||||
|
<Trans>Select an option</Trans>
|
||||||
|
</Dialog.HeaderText>
|
||||||
|
</Dialog.Header>
|
||||||
|
<Dialog.InnerFlatList
|
||||||
|
headerOffset={headerHeight}
|
||||||
|
data={items}
|
||||||
|
renderItem={render}
|
||||||
|
keyExtractor={valueExtractor}
|
||||||
|
/>
|
||||||
|
</Context.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultItemValueExtractor(item: any) {
|
||||||
|
return item.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const ItemContext = createContext<{
|
||||||
|
selected: boolean
|
||||||
|
hovered: boolean
|
||||||
|
focused: boolean
|
||||||
|
pressed: boolean
|
||||||
|
}>({
|
||||||
|
selected: false,
|
||||||
|
hovered: false,
|
||||||
|
focused: false,
|
||||||
|
pressed: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
export function useItemContext() {
|
||||||
|
return useContext(ItemContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Item({children, value, label, style}: ItemProps) {
|
||||||
|
const t = useTheme()
|
||||||
|
const control = Dialog.useDialogContext()
|
||||||
|
const {value: selected, onValueChange} = useSelectContext()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
role="listitem"
|
||||||
|
label={label}
|
||||||
|
style={[a.flex_1]}
|
||||||
|
onPress={() => {
|
||||||
|
control.close(() => {
|
||||||
|
onValueChange?.(value)
|
||||||
|
})
|
||||||
|
}}>
|
||||||
|
{({hovered, focused, pressed}) => (
|
||||||
|
<ItemContext.Provider
|
||||||
|
value={{selected: value === selected, hovered, focused, pressed}}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.flex_1,
|
||||||
|
a.pl_md,
|
||||||
|
(focused || pressed) && t.atoms.bg_contrast_25,
|
||||||
|
a.flex_row,
|
||||||
|
a.align_center,
|
||||||
|
a.gap_sm,
|
||||||
|
style,
|
||||||
|
]}>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
</ItemContext.Provider>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ItemText({children}: ItemTextProps) {
|
||||||
|
const {selected} = useItemContext()
|
||||||
|
const t = useTheme()
|
||||||
|
|
||||||
|
// eslint-disable-next-line bsky-internal/avoid-unwrapped-text
|
||||||
|
return (
|
||||||
|
<View style={[a.flex_1, a.py_md, a.border_b, t.atoms.border_contrast_low]}>
|
||||||
|
<Text style={[a.text_md, selected && a.font_bold]}>{children}</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ItemIndicator({icon: Icon = CheckIcon}: ItemIndicatorProps) {
|
||||||
|
const {selected} = useItemContext()
|
||||||
|
|
||||||
|
return <View style={{width: 24}}>{selected && <Icon size="md" />}</View>
|
||||||
|
}
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
import {createContext, forwardRef, useContext, useMemo} from 'react'
|
||||||
|
import {View} from 'react-native'
|
||||||
|
import {Select as RadixSelect} from 'radix-ui'
|
||||||
|
|
||||||
|
import {flatten, useTheme} from '#/alf'
|
||||||
|
import {atoms as a} from '#/alf'
|
||||||
|
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||||
|
import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
|
||||||
|
import {
|
||||||
|
ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon,
|
||||||
|
ChevronTop_Stroke2_Corner0_Rounded as ChevronUpIcon,
|
||||||
|
} from '#/components/icons/Chevron'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
import {
|
||||||
|
type ContentProps,
|
||||||
|
type IconProps,
|
||||||
|
type ItemIndicatorProps,
|
||||||
|
type ItemProps,
|
||||||
|
type RadixPassThroughTriggerProps,
|
||||||
|
type RootProps,
|
||||||
|
type TriggerProps,
|
||||||
|
type ValueProps,
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
const SelectedValueContext = createContext<string | undefined | null>(null)
|
||||||
|
|
||||||
|
export function Root(props: RootProps) {
|
||||||
|
return (
|
||||||
|
<SelectedValueContext.Provider value={props.value}>
|
||||||
|
<RadixSelect.Root {...props} />
|
||||||
|
</SelectedValueContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const RadixTriggerPassThrough = forwardRef(
|
||||||
|
(
|
||||||
|
props: {
|
||||||
|
children: (
|
||||||
|
props: RadixPassThroughTriggerProps & {
|
||||||
|
ref: React.Ref<any>
|
||||||
|
},
|
||||||
|
) => React.ReactNode
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
|
// @ts-expect-error Radix provides no types of this stuff
|
||||||
|
|
||||||
|
return props.children?.({...props, ref})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
RadixTriggerPassThrough.displayName = 'RadixTriggerPassThrough'
|
||||||
|
|
||||||
|
export function Trigger({children, label}: TriggerProps) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {
|
||||||
|
state: hovered,
|
||||||
|
onIn: onMouseEnter,
|
||||||
|
onOut: onMouseLeave,
|
||||||
|
} = useInteractionState()
|
||||||
|
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
|
||||||
|
|
||||||
|
if (typeof children === 'function') {
|
||||||
|
return (
|
||||||
|
<RadixSelect.Trigger asChild>
|
||||||
|
<RadixTriggerPassThrough>
|
||||||
|
{props =>
|
||||||
|
children({
|
||||||
|
isNative: false,
|
||||||
|
state: {
|
||||||
|
hovered,
|
||||||
|
focused,
|
||||||
|
pressed: false,
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
...props,
|
||||||
|
onFocus: onFocus,
|
||||||
|
onBlur: onBlur,
|
||||||
|
onMouseEnter,
|
||||||
|
onMouseLeave,
|
||||||
|
accessibilityLabel: label,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</RadixTriggerPassThrough>
|
||||||
|
</RadixSelect.Trigger>
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<RadixSelect.Trigger
|
||||||
|
onFocus={onFocus}
|
||||||
|
onBlur={onBlur}
|
||||||
|
onMouseEnter={onMouseEnter}
|
||||||
|
onMouseLeave={onMouseLeave}
|
||||||
|
style={flatten([
|
||||||
|
a.flex,
|
||||||
|
a.relative,
|
||||||
|
t.atoms.bg_contrast_25,
|
||||||
|
a.rounded_sm,
|
||||||
|
a.w_full,
|
||||||
|
{maxWidth: 400},
|
||||||
|
a.align_center,
|
||||||
|
a.gap_sm,
|
||||||
|
a.justify_between,
|
||||||
|
a.py_sm,
|
||||||
|
a.px_md,
|
||||||
|
{
|
||||||
|
outline: 0,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderStyle: 'solid',
|
||||||
|
borderColor: focused
|
||||||
|
? t.palette.primary_500
|
||||||
|
: hovered
|
||||||
|
? t.palette.contrast_100
|
||||||
|
: t.palette.contrast_25,
|
||||||
|
},
|
||||||
|
])}>
|
||||||
|
{children}
|
||||||
|
</RadixSelect.Trigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ValueText({children: _, style, ...props}: ValueProps) {
|
||||||
|
return (
|
||||||
|
<Text style={style}>
|
||||||
|
<RadixSelect.Value {...props} />
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Icon({style}: IconProps) {
|
||||||
|
const t = useTheme()
|
||||||
|
return (
|
||||||
|
<RadixSelect.Icon>
|
||||||
|
<ChevronDownIcon style={[t.atoms.text, style]} size="xs" />
|
||||||
|
</RadixSelect.Icon>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Content<T>({items, renderItem}: ContentProps<T>) {
|
||||||
|
const t = useTheme()
|
||||||
|
const selectedValue = useContext(SelectedValueContext)
|
||||||
|
|
||||||
|
const scrollBtnStyles: React.CSSProperties[] = [
|
||||||
|
a.absolute,
|
||||||
|
a.flex,
|
||||||
|
a.align_center,
|
||||||
|
a.justify_center,
|
||||||
|
a.rounded_sm,
|
||||||
|
a.z_10,
|
||||||
|
]
|
||||||
|
const up: React.CSSProperties[] = [
|
||||||
|
...scrollBtnStyles,
|
||||||
|
a.pt_sm,
|
||||||
|
a.pb_lg,
|
||||||
|
{
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
borderBottomLeftRadius: 0,
|
||||||
|
borderBottomRightRadius: 0,
|
||||||
|
background: `linear-gradient(to bottom, ${t.atoms.bg.backgroundColor} 0%, transparent 100%)`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const down: React.CSSProperties[] = [
|
||||||
|
...scrollBtnStyles,
|
||||||
|
a.pt_lg,
|
||||||
|
a.pb_sm,
|
||||||
|
{
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
borderBottomLeftRadius: 0,
|
||||||
|
borderBottomRightRadius: 0,
|
||||||
|
background: `linear-gradient(to top, ${t.atoms.bg.backgroundColor} 0%, transparent 100%)`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RadixSelect.Portal>
|
||||||
|
<RadixSelect.Content
|
||||||
|
style={flatten([t.atoms.bg, a.rounded_sm, a.overflow_hidden])}
|
||||||
|
position="popper"
|
||||||
|
sideOffset={5}
|
||||||
|
className="radix-select-content">
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.flex_1,
|
||||||
|
a.border,
|
||||||
|
t.atoms.border_contrast_low,
|
||||||
|
a.rounded_sm,
|
||||||
|
]}>
|
||||||
|
<RadixSelect.ScrollUpButton style={flatten(up)}>
|
||||||
|
<ChevronUpIcon style={[t.atoms.text]} size="xs" />
|
||||||
|
</RadixSelect.ScrollUpButton>
|
||||||
|
<RadixSelect.Viewport style={flatten([a.p_xs])}>
|
||||||
|
{items.map((item, index) => renderItem(item, index, selectedValue))}
|
||||||
|
</RadixSelect.Viewport>
|
||||||
|
<RadixSelect.ScrollDownButton style={flatten(down)}>
|
||||||
|
<ChevronDownIcon style={[t.atoms.text]} size="xs" />
|
||||||
|
</RadixSelect.ScrollDownButton>
|
||||||
|
</View>
|
||||||
|
</RadixSelect.Content>
|
||||||
|
</RadixSelect.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ItemContext = createContext<{
|
||||||
|
hovered: boolean
|
||||||
|
focused: boolean
|
||||||
|
pressed: boolean
|
||||||
|
selected: boolean
|
||||||
|
}>({
|
||||||
|
hovered: false,
|
||||||
|
focused: false,
|
||||||
|
pressed: false,
|
||||||
|
selected: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
export function useItemContext() {
|
||||||
|
return useContext(ItemContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Item({ref, value, style, children}: ItemProps) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {
|
||||||
|
state: hovered,
|
||||||
|
onIn: onMouseEnter,
|
||||||
|
onOut: onMouseLeave,
|
||||||
|
} = useInteractionState()
|
||||||
|
const selected = useContext(SelectedValueContext) === value
|
||||||
|
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
|
||||||
|
const ctx = useMemo(
|
||||||
|
() => ({hovered, focused, pressed: false, selected}),
|
||||||
|
[hovered, focused, selected],
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<RadixSelect.Item
|
||||||
|
ref={ref}
|
||||||
|
value={value}
|
||||||
|
onMouseEnter={onMouseEnter}
|
||||||
|
onMouseLeave={onMouseLeave}
|
||||||
|
onFocus={onFocus}
|
||||||
|
onBlur={onBlur}
|
||||||
|
style={flatten([
|
||||||
|
a.relative,
|
||||||
|
a.flex,
|
||||||
|
{minHeight: 25, paddingLeft: 30, paddingRight: 35},
|
||||||
|
a.user_select_none,
|
||||||
|
a.align_center,
|
||||||
|
a.rounded_xs,
|
||||||
|
a.py_2xs,
|
||||||
|
a.text_sm,
|
||||||
|
{outline: 0},
|
||||||
|
(hovered || focused) && {backgroundColor: t.palette.primary_50},
|
||||||
|
selected && [a.font_bold],
|
||||||
|
a.transition_color,
|
||||||
|
style,
|
||||||
|
])}>
|
||||||
|
<ItemContext.Provider value={ctx}>{children}</ItemContext.Provider>
|
||||||
|
</RadixSelect.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ItemText = RadixSelect.ItemText
|
||||||
|
|
||||||
|
export function ItemIndicator({icon: Icon = CheckIcon}: ItemIndicatorProps) {
|
||||||
|
return (
|
||||||
|
<RadixSelect.ItemIndicator
|
||||||
|
style={flatten([
|
||||||
|
a.absolute,
|
||||||
|
{left: 0, width: 30},
|
||||||
|
a.flex,
|
||||||
|
a.align_center,
|
||||||
|
a.justify_center,
|
||||||
|
])}>
|
||||||
|
<Icon size="sm" />
|
||||||
|
</RadixSelect.ItemIndicator>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import {
|
||||||
|
type AccessibilityProps,
|
||||||
|
type StyleProp,
|
||||||
|
type TextStyle,
|
||||||
|
type ViewStyle,
|
||||||
|
} from 'react-native'
|
||||||
|
|
||||||
|
import {type TextStyleProp} from '#/alf'
|
||||||
|
import {type DialogControlProps} from '#/components/Dialog'
|
||||||
|
import {type Props as SVGIconProps} from '#/components/icons/common'
|
||||||
|
|
||||||
|
export type RootProps = {
|
||||||
|
children?: React.ReactNode
|
||||||
|
value?: string
|
||||||
|
onValueChange?: (value: string) => void
|
||||||
|
disabled?: boolean
|
||||||
|
/**
|
||||||
|
* @platform web
|
||||||
|
*/
|
||||||
|
defaultValue?: string
|
||||||
|
/**
|
||||||
|
* @platform web
|
||||||
|
*/
|
||||||
|
open?: boolean
|
||||||
|
/**
|
||||||
|
* @platform web
|
||||||
|
*/
|
||||||
|
defaultOpen?: boolean
|
||||||
|
/**
|
||||||
|
* @platform web
|
||||||
|
*/
|
||||||
|
onOpenChange?(open: boolean): void
|
||||||
|
/**
|
||||||
|
* @platform web
|
||||||
|
*/
|
||||||
|
name?: string
|
||||||
|
/**
|
||||||
|
* @platform web
|
||||||
|
*/
|
||||||
|
autoComplete?: string
|
||||||
|
/**
|
||||||
|
* @platform web
|
||||||
|
*/
|
||||||
|
required?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RadixPassThroughTriggerProps = {
|
||||||
|
id: string
|
||||||
|
type: 'button'
|
||||||
|
disabled: boolean
|
||||||
|
['data-disabled']: boolean
|
||||||
|
['data-state']: string
|
||||||
|
['aria-controls']?: string
|
||||||
|
['aria-haspopup']?: boolean
|
||||||
|
['aria-expanded']?: AccessibilityProps['aria-expanded']
|
||||||
|
onPress: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TriggerProps = {
|
||||||
|
children: React.ReactNode | ((props: TriggerChildProps) => React.ReactNode)
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TriggerChildProps =
|
||||||
|
| {
|
||||||
|
isNative: true
|
||||||
|
control: DialogControlProps
|
||||||
|
state: {
|
||||||
|
/**
|
||||||
|
* Web only, `false` on native
|
||||||
|
*/
|
||||||
|
hovered: false
|
||||||
|
focused: boolean
|
||||||
|
pressed: boolean
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* We don't necessarily know what these will be spread on to, so we
|
||||||
|
* should add props one-by-one.
|
||||||
|
*
|
||||||
|
* On web, these properties are applied to a parent `Pressable`, so this
|
||||||
|
* object is empty.
|
||||||
|
*/
|
||||||
|
props: {
|
||||||
|
onPress: () => void
|
||||||
|
onFocus: () => void
|
||||||
|
onBlur: () => void
|
||||||
|
onPressIn: () => void
|
||||||
|
onPressOut: () => void
|
||||||
|
accessibilityLabel: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
isNative: false
|
||||||
|
state: {
|
||||||
|
hovered: boolean
|
||||||
|
focused: boolean
|
||||||
|
/**
|
||||||
|
* Native only, `false` on web
|
||||||
|
*/
|
||||||
|
pressed: false
|
||||||
|
}
|
||||||
|
props: RadixPassThroughTriggerProps & {
|
||||||
|
onPress: () => void
|
||||||
|
onFocus: () => void
|
||||||
|
onBlur: () => void
|
||||||
|
onMouseEnter: () => void
|
||||||
|
onMouseLeave: () => void
|
||||||
|
accessibilityLabel: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* For use within the `Select.Trigger` component.
|
||||||
|
* Shows the currently selected value. You can also
|
||||||
|
* provide a placeholder to show when no value is selected.
|
||||||
|
*
|
||||||
|
* If you're passing items of a different shape than {value: string, label: string},
|
||||||
|
* you'll need to pass a function to `children` that extracts the label from an item.
|
||||||
|
*/
|
||||||
|
export type ValueProps = {
|
||||||
|
/**
|
||||||
|
* Only needed for native. Extracts the label from an item. Defaults to `item => item.label`
|
||||||
|
*/
|
||||||
|
children?: (value: any) => string
|
||||||
|
placeholder?: string
|
||||||
|
style?: StyleProp<TextStyle>
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Icon for use within the `Select.Trigger` component.
|
||||||
|
* Changes based on platform - chevron down on web, up/down chevrons on native
|
||||||
|
*
|
||||||
|
* `style` prop is web only
|
||||||
|
*/
|
||||||
|
export type IconProps = TextStyleProp
|
||||||
|
|
||||||
|
export type ContentProps<T> = {
|
||||||
|
/**
|
||||||
|
* Items to render. Recommended to be in the form {value: string, label: string} - if not,
|
||||||
|
* you need to provide a `valueExtractor` function to extract the value from an item and
|
||||||
|
* customise the `Select.ValueText` component.
|
||||||
|
*/
|
||||||
|
items: T[]
|
||||||
|
/**
|
||||||
|
* Renders an item. You should probably use the `Select.Item` component.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* renderItem={({label, value}) => (
|
||||||
|
* <Select.Item value={value} label={label}>
|
||||||
|
* <Select.ItemIndicator />
|
||||||
|
* <Select.ItemText>{label}</Select.ItemText>
|
||||||
|
* </Select.Item>
|
||||||
|
* )}
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
renderItem: (
|
||||||
|
item: T,
|
||||||
|
index: number,
|
||||||
|
selectedValue?: string | null,
|
||||||
|
) => React.ReactElement
|
||||||
|
/*
|
||||||
|
* Extracts the value from an item. Defaults to `item => item.value`
|
||||||
|
*/
|
||||||
|
valueExtractor?: (item: T) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* An item within the select dropdown
|
||||||
|
*/
|
||||||
|
export type ItemProps = {
|
||||||
|
ref?: React.Ref<HTMLDivElement>
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
children: React.ReactNode
|
||||||
|
style?: StyleProp<ViewStyle>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ItemTextProps = {
|
||||||
|
children: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ItemIndicatorProps = {
|
||||||
|
icon?: React.ComponentType<SVGIconProps>
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@ import {type Dimensions} from './types'
|
|||||||
|
|
||||||
export async function compressIfNeeded(
|
export async function compressIfNeeded(
|
||||||
img: PickerImage,
|
img: PickerImage,
|
||||||
maxSize: number = 1000000,
|
maxSize: number = POST_IMG_MAX.size,
|
||||||
): Promise<PickerImage> {
|
): Promise<PickerImage> {
|
||||||
if (img.size < maxSize) {
|
if (img.size < maxSize) {
|
||||||
return img
|
return img
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
/// <reference lib="dom" />
|
||||||
|
|
||||||
import {type PickerImage} from './picker.shared'
|
import {type PickerImage} from './picker.shared'
|
||||||
import {type Dimensions} from './types'
|
import {type Dimensions} from './types'
|
||||||
import {blobToDataUri, getDataUriSize} from './util'
|
import {blobToDataUri, getDataUriSize} from './util'
|
||||||
|
|||||||
@@ -1,25 +1,21 @@
|
|||||||
import {
|
import {
|
||||||
type ImagePickerOptions,
|
type ImagePickerOptions,
|
||||||
launchImageLibraryAsync,
|
launchImageLibraryAsync,
|
||||||
MediaTypeOptions,
|
|
||||||
} from 'expo-image-picker'
|
} from 'expo-image-picker'
|
||||||
import {t} from '@lingui/macro'
|
import {t} from '@lingui/macro'
|
||||||
|
|
||||||
|
import {type ImageMeta} from '#/state/gallery'
|
||||||
import * as Toast from '#/view/com/util/Toast'
|
import * as Toast from '#/view/com/util/Toast'
|
||||||
import {getDataUriSize} from './util'
|
import {getDataUriSize} from './util'
|
||||||
|
|
||||||
export type PickerImage = {
|
export type PickerImage = ImageMeta & {
|
||||||
mime: string
|
|
||||||
height: number
|
|
||||||
width: number
|
|
||||||
path: string
|
|
||||||
size: number
|
size: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openPicker(opts?: ImagePickerOptions) {
|
export async function openPicker(opts?: ImagePickerOptions) {
|
||||||
const response = await launchImageLibraryAsync({
|
const response = await launchImageLibraryAsync({
|
||||||
exif: false,
|
exif: false,
|
||||||
mediaTypes: MediaTypeOptions.Images,
|
mediaTypes: ['images'],
|
||||||
quality: 1,
|
quality: 1,
|
||||||
...opts,
|
...opts,
|
||||||
legacy: true,
|
legacy: true,
|
||||||
|
|||||||
@@ -1,35 +1,18 @@
|
|||||||
/// <reference lib="dom" />
|
|
||||||
|
|
||||||
import {type OpenCropperOptions} from 'expo-image-crop-tool'
|
import {type OpenCropperOptions} from 'expo-image-crop-tool'
|
||||||
|
|
||||||
import {unstable__openModal} from '#/state/modals'
|
|
||||||
import {type PickerImage} from './picker.shared'
|
import {type PickerImage} from './picker.shared'
|
||||||
import {type CameraOpts} from './types'
|
import {type CameraOpts} from './types'
|
||||||
|
|
||||||
export {openPicker, type PickerImage as RNImage} from './picker.shared'
|
export {openPicker} from './picker.shared'
|
||||||
|
|
||||||
export async function openCamera(_opts: CameraOpts): Promise<PickerImage> {
|
export async function openCamera(_opts: CameraOpts): Promise<PickerImage> {
|
||||||
// const mediaType = opts.mediaType || 'photo' TODO
|
throw new Error('openCamera is not supported on web')
|
||||||
throw new Error('TODO')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openCropper(
|
export async function openCropper(
|
||||||
opts: OpenCropperOptions,
|
_opts: OpenCropperOptions,
|
||||||
): Promise<PickerImage> {
|
): Promise<PickerImage> {
|
||||||
// TODO handle more opts
|
throw new Error(
|
||||||
return new Promise((resolve, reject) => {
|
'openCropper is not supported on web. Use EditImageDialog instead.',
|
||||||
unstable__openModal({
|
)
|
||||||
name: 'crop-image',
|
|
||||||
uri: opts.imageUri,
|
|
||||||
aspect: opts.aspectRatio,
|
|
||||||
circular: opts.shape === 'circle',
|
|
||||||
onSelect: (img?: PickerImage) => {
|
|
||||||
if (img) {
|
|
||||||
resolve(img)
|
|
||||||
} else {
|
|
||||||
reject(new Error('Canceled'))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,11 @@ import {msg, Plural, Trans} from '@lingui/macro'
|
|||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
import {urls} from '#/lib/constants'
|
import {urls} from '#/lib/constants'
|
||||||
import {compressIfNeeded} from '#/lib/media/manip'
|
|
||||||
import {type PickerImage} from '#/lib/media/picker.shared'
|
|
||||||
import {cleanError} from '#/lib/strings/errors'
|
import {cleanError} from '#/lib/strings/errors'
|
||||||
import {useWarnMaxGraphemeCount} from '#/lib/strings/helpers'
|
import {useWarnMaxGraphemeCount} from '#/lib/strings/helpers'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {isWeb} from '#/platform/detection'
|
import {isWeb} from '#/platform/detection'
|
||||||
|
import {type ImageMeta} from '#/state/gallery'
|
||||||
import {useProfileUpdateMutation} from '#/state/queries/profile'
|
import {useProfileUpdateMutation} from '#/state/queries/profile'
|
||||||
import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
|
import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
|
||||||
import * as Toast from '#/view/com/util/Toast'
|
import * as Toast from '#/view/com/util/Toast'
|
||||||
@@ -18,10 +17,11 @@ import {EditableUserAvatar} from '#/view/com/util/UserAvatar'
|
|||||||
import {UserBanner} from '#/view/com/util/UserBanner'
|
import {UserBanner} from '#/view/com/util/UserBanner'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {Admonition} from '#/components/Admonition'
|
import {Admonition} from '#/components/Admonition'
|
||||||
import {Button, ButtonText} from '#/components/Button'
|
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||||
import * as Dialog from '#/components/Dialog'
|
import * as Dialog from '#/components/Dialog'
|
||||||
import * as TextField from '#/components/forms/TextField'
|
import * as TextField from '#/components/forms/TextField'
|
||||||
import {InlineLinkText} from '#/components/Link'
|
import {InlineLinkText} from '#/components/Link'
|
||||||
|
import {Loader} from '#/components/Loader'
|
||||||
import * as Prompt from '#/components/Prompt'
|
import * as Prompt from '#/components/Prompt'
|
||||||
import {useSimpleVerificationState} from '#/components/verification'
|
import {useSimpleVerificationState} from '#/components/verification'
|
||||||
|
|
||||||
@@ -127,10 +127,10 @@ function DialogInner({
|
|||||||
profile.avatar,
|
profile.avatar,
|
||||||
)
|
)
|
||||||
const [newUserBanner, setNewUserBanner] = useState<
|
const [newUserBanner, setNewUserBanner] = useState<
|
||||||
PickerImage | undefined | null
|
ImageMeta | undefined | null
|
||||||
>()
|
>()
|
||||||
const [newUserAvatar, setNewUserAvatar] = useState<
|
const [newUserAvatar, setNewUserAvatar] = useState<
|
||||||
PickerImage | undefined | null
|
ImageMeta | undefined | null
|
||||||
>()
|
>()
|
||||||
|
|
||||||
const dirty =
|
const dirty =
|
||||||
@@ -144,7 +144,7 @@ function DialogInner({
|
|||||||
}, [dirty, setDirty])
|
}, [dirty, setDirty])
|
||||||
|
|
||||||
const onSelectNewAvatar = useCallback(
|
const onSelectNewAvatar = useCallback(
|
||||||
async (img: PickerImage | null) => {
|
(img: ImageMeta | null) => {
|
||||||
setImageError('')
|
setImageError('')
|
||||||
if (img === null) {
|
if (img === null) {
|
||||||
setNewUserAvatar(null)
|
setNewUserAvatar(null)
|
||||||
@@ -152,9 +152,8 @@ function DialogInner({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const finalImg = await compressIfNeeded(img, 1000000)
|
setNewUserAvatar(img)
|
||||||
setNewUserAvatar(finalImg)
|
setUserAvatar(img.path)
|
||||||
setUserAvatar(finalImg.path)
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setImageError(cleanError(e))
|
setImageError(cleanError(e))
|
||||||
}
|
}
|
||||||
@@ -163,7 +162,7 @@ function DialogInner({
|
|||||||
)
|
)
|
||||||
|
|
||||||
const onSelectNewBanner = useCallback(
|
const onSelectNewBanner = useCallback(
|
||||||
async (img: PickerImage | null) => {
|
(img: ImageMeta | null) => {
|
||||||
setImageError('')
|
setImageError('')
|
||||||
if (!img) {
|
if (!img) {
|
||||||
setNewUserBanner(null)
|
setNewUserBanner(null)
|
||||||
@@ -171,9 +170,8 @@ function DialogInner({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const finalImg = await compressIfNeeded(img, 1000000)
|
setNewUserBanner(img)
|
||||||
setNewUserBanner(finalImg)
|
setUserBanner(img.path)
|
||||||
setUserBanner(finalImg.path)
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setImageError(cleanError(e))
|
setImageError(cleanError(e))
|
||||||
}
|
}
|
||||||
@@ -258,6 +256,7 @@ function DialogInner({
|
|||||||
<ButtonText style={[a.text_md, !dirty && t.atoms.text_contrast_low]}>
|
<ButtonText style={[a.text_md, !dirty && t.atoms.text_contrast_low]}>
|
||||||
<Trans>Save</Trans>
|
<Trans>Save</Trans>
|
||||||
</ButtonText>
|
</ButtonText>
|
||||||
|
{isUpdatingProfile && <ButtonIcon icon={Loader} />}
|
||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import React, {memo, useMemo} from 'react'
|
import React, {memo, useMemo} from 'react'
|
||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import {
|
import {
|
||||||
AppBskyActorDefs,
|
type AppBskyActorDefs,
|
||||||
AppBskyLabelerDefs,
|
type AppBskyLabelerDefs,
|
||||||
moderateProfile,
|
moderateProfile,
|
||||||
ModerationOpts,
|
type ModerationOpts,
|
||||||
RichText as RichTextAPI,
|
type RichText as RichTextAPI,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
import {msg, Plural, plural, Trans} from '@lingui/macro'
|
import {msg, Plural, plural, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
@@ -15,10 +15,9 @@ import {MAX_LABELERS} from '#/lib/constants'
|
|||||||
import {useHaptics} from '#/lib/haptics'
|
import {useHaptics} from '#/lib/haptics'
|
||||||
import {isAppLabeler} from '#/lib/moderation'
|
import {isAppLabeler} from '#/lib/moderation'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {isIOS, isWeb} from '#/platform/detection'
|
import {isIOS} from '#/platform/detection'
|
||||||
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
||||||
import {Shadow} from '#/state/cache/types'
|
import {type Shadow} from '#/state/cache/types'
|
||||||
import {useModalControls} from '#/state/modals'
|
|
||||||
import {useLabelerSubscriptionMutation} from '#/state/queries/labeler'
|
import {useLabelerSubscriptionMutation} from '#/state/queries/labeler'
|
||||||
import {useLikeMutation, useUnlikeMutation} from '#/state/queries/like'
|
import {useLikeMutation, useUnlikeMutation} from '#/state/queries/like'
|
||||||
import {usePreferencesQuery} from '#/state/queries/preferences'
|
import {usePreferencesQuery} from '#/state/queries/preferences'
|
||||||
@@ -27,7 +26,7 @@ import {ProfileMenu} from '#/view/com/profile/ProfileMenu'
|
|||||||
import * as Toast from '#/view/com/util/Toast'
|
import * as Toast from '#/view/com/util/Toast'
|
||||||
import {atoms as a, tokens, useTheme} from '#/alf'
|
import {atoms as a, tokens, useTheme} from '#/alf'
|
||||||
import {Button, ButtonText} from '#/components/Button'
|
import {Button, ButtonText} from '#/components/Button'
|
||||||
import {DialogOuterProps, useDialogControl} from '#/components/Dialog'
|
import {type DialogOuterProps, useDialogControl} from '#/components/Dialog'
|
||||||
import {
|
import {
|
||||||
Heart2_Filled_Stroke2_Corner0_Rounded as HeartFilled,
|
Heart2_Filled_Stroke2_Corner0_Rounded as HeartFilled,
|
||||||
Heart2_Stroke2_Corner0_Rounded as Heart,
|
Heart2_Stroke2_Corner0_Rounded as Heart,
|
||||||
@@ -117,19 +116,7 @@ let ProfileHeaderLabeler = ({
|
|||||||
}
|
}
|
||||||
}, [labeler, playHaptic, likeUri, unlikeMod, likeMod, _])
|
}, [labeler, playHaptic, likeUri, unlikeMod, likeMod, _])
|
||||||
|
|
||||||
const {openModal} = useModalControls()
|
|
||||||
const editProfileControl = useDialogControl()
|
const editProfileControl = useDialogControl()
|
||||||
const onPressEditProfile = React.useCallback(() => {
|
|
||||||
if (isWeb) {
|
|
||||||
// temp, while we figure out the nested dialog bug
|
|
||||||
openModal({
|
|
||||||
name: 'edit-profile',
|
|
||||||
profile,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
editProfileControl.open()
|
|
||||||
}
|
|
||||||
}, [editProfileControl, openModal, profile])
|
|
||||||
|
|
||||||
const onPressSubscribe = React.useCallback(
|
const onPressSubscribe = React.useCallback(
|
||||||
() =>
|
() =>
|
||||||
@@ -192,7 +179,7 @@ let ProfileHeaderLabeler = ({
|
|||||||
size="small"
|
size="small"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
variant="solid"
|
variant="solid"
|
||||||
onPress={onPressEditProfile}
|
onPress={editProfileControl.open}
|
||||||
label={_(msg`Edit profile`)}
|
label={_(msg`Edit profile`)}
|
||||||
style={a.rounded_full}>
|
style={a.rounded_full}>
|
||||||
<ButtonText>
|
<ButtonText>
|
||||||
|
|||||||
@@ -12,10 +12,9 @@ import {useLingui} from '@lingui/react'
|
|||||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {isIOS, isWeb} from '#/platform/detection'
|
import {isIOS} from '#/platform/detection'
|
||||||
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
||||||
import {type Shadow} from '#/state/cache/types'
|
import {type Shadow} from '#/state/cache/types'
|
||||||
import {useModalControls} from '#/state/modals'
|
|
||||||
import {
|
import {
|
||||||
useProfileBlockMutationQueue,
|
useProfileBlockMutationQueue,
|
||||||
useProfileFollowMutationQueue,
|
useProfileFollowMutationQueue,
|
||||||
@@ -78,19 +77,7 @@ let ProfileHeaderStandard = ({
|
|||||||
profile.viewer?.blockedBy ||
|
profile.viewer?.blockedBy ||
|
||||||
profile.viewer?.blockingByList
|
profile.viewer?.blockingByList
|
||||||
|
|
||||||
const {openModal} = useModalControls()
|
|
||||||
const editProfileControl = useDialogControl()
|
const editProfileControl = useDialogControl()
|
||||||
const onPressEditProfile = React.useCallback(() => {
|
|
||||||
if (isWeb) {
|
|
||||||
// temp, while we figure out the nested dialog bug
|
|
||||||
openModal({
|
|
||||||
name: 'edit-profile',
|
|
||||||
profile,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
editProfileControl.open()
|
|
||||||
}
|
|
||||||
}, [editProfileControl, openModal, profile])
|
|
||||||
|
|
||||||
const onPressFollow = () => {
|
const onPressFollow = () => {
|
||||||
requireAuth(async () => {
|
requireAuth(async () => {
|
||||||
@@ -178,7 +165,7 @@ let ProfileHeaderStandard = ({
|
|||||||
size="small"
|
size="small"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
variant="solid"
|
variant="solid"
|
||||||
onPress={onPressEditProfile}
|
onPress={editProfileControl.open}
|
||||||
label={_(msg`Edit profile`)}
|
label={_(msg`Edit profile`)}
|
||||||
style={[a.rounded_full]}>
|
style={[a.rounded_full]}>
|
||||||
<ButtonText>
|
<ButtonText>
|
||||||
|
|||||||
@@ -1,23 +1,30 @@
|
|||||||
import {useCallback, useMemo} from 'react'
|
import {useCallback, useMemo} from 'react'
|
||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import RNPickerSelect, {PickerSelectProps} from 'react-native-picker-select'
|
|
||||||
import {msg, Trans} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
import {APP_LANGUAGES, LANGUAGES} from '#/lib/../locale/languages'
|
import {APP_LANGUAGES, LANGUAGES} from '#/lib/../locale/languages'
|
||||||
import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
|
import {
|
||||||
|
type CommonNavigatorParams,
|
||||||
|
type NativeStackScreenProps,
|
||||||
|
} from '#/lib/routes/types'
|
||||||
import {languageName, sanitizeAppLanguageSetting} from '#/locale/helpers'
|
import {languageName, sanitizeAppLanguageSetting} from '#/locale/helpers'
|
||||||
import {useModalControls} from '#/state/modals'
|
import {useModalControls} from '#/state/modals'
|
||||||
import {useLanguagePrefs, useLanguagePrefsApi} from '#/state/preferences'
|
import {useLanguagePrefs, useLanguagePrefsApi} from '#/state/preferences'
|
||||||
import {atoms as a, useTheme, web} from '#/alf'
|
import {atoms as a, useTheme, web} from '#/alf'
|
||||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||||
import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
|
import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
|
||||||
import {ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon} from '#/components/icons/Chevron'
|
|
||||||
import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus'
|
import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus'
|
||||||
import * as Layout from '#/components/Layout'
|
import * as Layout from '#/components/Layout'
|
||||||
|
import * as Select from '#/components/Select'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
import * as SettingsList from './components/SettingsList'
|
import * as SettingsList from './components/SettingsList'
|
||||||
|
|
||||||
|
const DEDUPED_LANGUAGES = LANGUAGES.filter(
|
||||||
|
(lang, i, arr) =>
|
||||||
|
lang.code2 && arr.findIndex(l => l.code2 === lang.code2) === i,
|
||||||
|
)
|
||||||
|
|
||||||
type Props = NativeStackScreenProps<CommonNavigatorParams, 'LanguageSettings'>
|
type Props = NativeStackScreenProps<CommonNavigatorParams, 'LanguageSettings'>
|
||||||
export function LanguageSettingsScreen({}: Props) {
|
export function LanguageSettingsScreen({}: Props) {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
@@ -32,7 +39,7 @@ export function LanguageSettingsScreen({}: Props) {
|
|||||||
}, [openModal])
|
}, [openModal])
|
||||||
|
|
||||||
const onChangePrimaryLanguage = useCallback(
|
const onChangePrimaryLanguage = useCallback(
|
||||||
(value: Parameters<PickerSelectProps['onValueChange']>[0]) => {
|
(value: string) => {
|
||||||
if (!value) return
|
if (!value) return
|
||||||
if (langPrefs.primaryLanguage !== value) {
|
if (langPrefs.primaryLanguage !== value) {
|
||||||
setLangPrefs.setPrimaryLanguage(value)
|
setLangPrefs.setPrimaryLanguage(value)
|
||||||
@@ -42,7 +49,7 @@ export function LanguageSettingsScreen({}: Props) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const onChangeAppLanguage = useCallback(
|
const onChangeAppLanguage = useCallback(
|
||||||
(value: Parameters<PickerSelectProps['onValueChange']>[0]) => {
|
(value: string) => {
|
||||||
if (!value) return
|
if (!value) return
|
||||||
if (langPrefs.appLanguage !== value) {
|
if (langPrefs.appLanguage !== value) {
|
||||||
setLangPrefs.setAppLanguage(sanitizeAppLanguageSetting(value))
|
setLangPrefs.setAppLanguage(sanitizeAppLanguageSetting(value))
|
||||||
@@ -85,79 +92,26 @@ export function LanguageSettingsScreen({}: Props) {
|
|||||||
Select which language to use for the app's user interface.
|
Select which language to use for the app's user interface.
|
||||||
</Trans>
|
</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<View style={[a.relative, web([a.w_full, {maxWidth: 400}])]}>
|
<Select.Root
|
||||||
<RNPickerSelect
|
|
||||||
darkTheme={t.scheme === 'dark'}
|
|
||||||
placeholder={{}}
|
|
||||||
value={sanitizeAppLanguageSetting(langPrefs.appLanguage)}
|
value={sanitizeAppLanguageSetting(langPrefs.appLanguage)}
|
||||||
onValueChange={onChangeAppLanguage}
|
onValueChange={onChangeAppLanguage}>
|
||||||
items={APP_LANGUAGES.filter(l => Boolean(l.code2)).map(l => ({
|
<Select.Trigger label={_(msg`Select app language`)}>
|
||||||
|
<Select.ValueText />
|
||||||
|
<Select.Icon />
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.Content
|
||||||
|
renderItem={({label, value}) => (
|
||||||
|
<Select.Item value={value} label={label}>
|
||||||
|
<Select.ItemIndicator />
|
||||||
|
<Select.ItemText>{label}</Select.ItemText>
|
||||||
|
</Select.Item>
|
||||||
|
)}
|
||||||
|
items={APP_LANGUAGES.map(l => ({
|
||||||
label: l.name,
|
label: l.name,
|
||||||
value: l.code2,
|
value: l.code2,
|
||||||
key: l.code2,
|
|
||||||
}))}
|
}))}
|
||||||
style={{
|
|
||||||
inputAndroid: {
|
|
||||||
backgroundColor: t.atoms.bg_contrast_25.backgroundColor,
|
|
||||||
color: t.atoms.text.color,
|
|
||||||
fontSize: 14,
|
|
||||||
letterSpacing: 0.5,
|
|
||||||
fontWeight: a.font_bold.fontWeight,
|
|
||||||
paddingHorizontal: 14,
|
|
||||||
paddingVertical: 8,
|
|
||||||
borderRadius: a.rounded_xs.borderRadius,
|
|
||||||
},
|
|
||||||
inputIOS: {
|
|
||||||
backgroundColor: t.atoms.bg_contrast_25.backgroundColor,
|
|
||||||
color: t.atoms.text.color,
|
|
||||||
fontSize: 14,
|
|
||||||
letterSpacing: 0.5,
|
|
||||||
fontWeight: a.font_bold.fontWeight,
|
|
||||||
paddingHorizontal: 14,
|
|
||||||
paddingVertical: 8,
|
|
||||||
borderRadius: a.rounded_xs.borderRadius,
|
|
||||||
},
|
|
||||||
inputWeb: {
|
|
||||||
flex: 1,
|
|
||||||
width: '100%',
|
|
||||||
cursor: 'pointer',
|
|
||||||
// @ts-ignore web only
|
|
||||||
'-moz-appearance': 'none',
|
|
||||||
'-webkit-appearance': 'none',
|
|
||||||
appearance: 'none',
|
|
||||||
outline: 0,
|
|
||||||
borderWidth: 0,
|
|
||||||
backgroundColor: t.atoms.bg_contrast_25.backgroundColor,
|
|
||||||
color: t.atoms.text.color,
|
|
||||||
fontSize: 14,
|
|
||||||
fontFamily: 'inherit',
|
|
||||||
letterSpacing: 0.5,
|
|
||||||
fontWeight: a.font_bold.fontWeight,
|
|
||||||
paddingHorizontal: 14,
|
|
||||||
paddingVertical: 8,
|
|
||||||
borderRadius: a.rounded_xs.borderRadius,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
</Select.Root>
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
a.absolute,
|
|
||||||
t.atoms.bg_contrast_25,
|
|
||||||
a.rounded_xs,
|
|
||||||
a.pointer_events_none,
|
|
||||||
a.align_center,
|
|
||||||
a.justify_center,
|
|
||||||
{
|
|
||||||
top: 1,
|
|
||||||
right: 1,
|
|
||||||
bottom: 1,
|
|
||||||
width: 40,
|
|
||||||
},
|
|
||||||
]}>
|
|
||||||
<ChevronDownIcon style={[t.atoms.text]} />
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
</SettingsList.Group>
|
</SettingsList.Group>
|
||||||
<SettingsList.Divider />
|
<SettingsList.Divider />
|
||||||
@@ -171,77 +125,26 @@ export function LanguageSettingsScreen({}: Props) {
|
|||||||
Select your preferred language for translations in your feed.
|
Select your preferred language for translations in your feed.
|
||||||
</Trans>
|
</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
<View style={[a.relative, web([a.w_full, {maxWidth: 400}])]}>
|
<Select.Root
|
||||||
<RNPickerSelect
|
|
||||||
darkTheme={t.scheme === 'dark'}
|
|
||||||
placeholder={{}}
|
|
||||||
value={langPrefs.primaryLanguage}
|
value={langPrefs.primaryLanguage}
|
||||||
onValueChange={onChangePrimaryLanguage}
|
onValueChange={onChangePrimaryLanguage}>
|
||||||
items={LANGUAGES.filter(l => Boolean(l.code2)).map(l => ({
|
<Select.Trigger label={_(msg`Select primary language`)}>
|
||||||
|
<Select.ValueText />
|
||||||
|
<Select.Icon />
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.Content
|
||||||
|
renderItem={({label, value}) => (
|
||||||
|
<Select.Item value={value} label={label}>
|
||||||
|
<Select.ItemIndicator />
|
||||||
|
<Select.ItemText>{label}</Select.ItemText>
|
||||||
|
</Select.Item>
|
||||||
|
)}
|
||||||
|
items={DEDUPED_LANGUAGES.map(l => ({
|
||||||
label: languageName(l, langPrefs.appLanguage),
|
label: languageName(l, langPrefs.appLanguage),
|
||||||
value: l.code2,
|
value: l.code2,
|
||||||
key: l.code2 + l.code3,
|
|
||||||
}))}
|
}))}
|
||||||
style={{
|
|
||||||
inputAndroid: {
|
|
||||||
backgroundColor: t.atoms.bg_contrast_25.backgroundColor,
|
|
||||||
color: t.atoms.text.color,
|
|
||||||
fontSize: 14,
|
|
||||||
letterSpacing: 0.5,
|
|
||||||
fontWeight: a.font_bold.fontWeight,
|
|
||||||
paddingHorizontal: 14,
|
|
||||||
paddingVertical: 8,
|
|
||||||
borderRadius: a.rounded_xs.borderRadius,
|
|
||||||
},
|
|
||||||
inputIOS: {
|
|
||||||
backgroundColor: t.atoms.bg_contrast_25.backgroundColor,
|
|
||||||
color: t.atoms.text.color,
|
|
||||||
fontSize: 14,
|
|
||||||
letterSpacing: 0.5,
|
|
||||||
fontWeight: a.font_bold.fontWeight,
|
|
||||||
paddingHorizontal: 14,
|
|
||||||
paddingVertical: 8,
|
|
||||||
borderRadius: a.rounded_xs.borderRadius,
|
|
||||||
},
|
|
||||||
inputWeb: {
|
|
||||||
flex: 1,
|
|
||||||
width: '100%',
|
|
||||||
cursor: 'pointer',
|
|
||||||
// @ts-ignore web only
|
|
||||||
'-moz-appearance': 'none',
|
|
||||||
'-webkit-appearance': 'none',
|
|
||||||
appearance: 'none',
|
|
||||||
outline: 0,
|
|
||||||
borderWidth: 0,
|
|
||||||
backgroundColor: t.atoms.bg_contrast_25.backgroundColor,
|
|
||||||
color: t.atoms.text.color,
|
|
||||||
fontSize: 14,
|
|
||||||
fontFamily: 'inherit',
|
|
||||||
letterSpacing: 0.5,
|
|
||||||
fontWeight: a.font_bold.fontWeight,
|
|
||||||
paddingHorizontal: 14,
|
|
||||||
paddingVertical: 8,
|
|
||||||
borderRadius: a.rounded_xs.borderRadius,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
</Select.Root>
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 1,
|
|
||||||
right: 1,
|
|
||||||
bottom: 1,
|
|
||||||
width: 40,
|
|
||||||
backgroundColor: t.atoms.bg_contrast_25.backgroundColor,
|
|
||||||
borderRadius: a.rounded_xs.borderRadius,
|
|
||||||
pointerEvents: 'none',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
}}>
|
|
||||||
<ChevronDownIcon style={t.atoms.text} />
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
</SettingsList.Group>
|
</SettingsList.Group>
|
||||||
<SettingsList.Divider />
|
<SettingsList.Divider />
|
||||||
|
|||||||
@@ -184,10 +184,14 @@ export function Signup({onPressBack}: {onPressBack: () => void}) {
|
|||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
<View
|
<View
|
||||||
style={[a.w_full, a.py_lg, a.flex_row, a.gap_lg, a.align_center]}>
|
style={[a.w_full, a.py_lg, a.flex_row, a.gap_md, a.align_center]}>
|
||||||
<AppLanguageDropdown />
|
<AppLanguageDropdown />
|
||||||
<Text
|
<Text
|
||||||
style={[t.atoms.text_contrast_medium, !gtMobile && a.text_md]}>
|
style={[
|
||||||
|
a.flex_1,
|
||||||
|
t.atoms.text_contrast_medium,
|
||||||
|
!gtMobile && a.text_md,
|
||||||
|
]}>
|
||||||
<Trans>Having trouble?</Trans>{' '}
|
<Trans>Having trouble?</Trans>{' '}
|
||||||
<InlineLinkText
|
<InlineLinkText
|
||||||
label={_(msg`Contact support`)}
|
label={_(msg`Contact support`)}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {nanoid} from 'nanoid/non-secure'
|
|||||||
import {POST_IMG_MAX} from '#/lib/constants'
|
import {POST_IMG_MAX} from '#/lib/constants'
|
||||||
import {getImageDim} from '#/lib/media/manip'
|
import {getImageDim} from '#/lib/media/manip'
|
||||||
import {openCropper} from '#/lib/media/picker'
|
import {openCropper} from '#/lib/media/picker'
|
||||||
|
import {type PickerImage} from '#/lib/media/picker.shared'
|
||||||
import {getDataUriSize} from '#/lib/media/util'
|
import {getDataUriSize} from '#/lib/media/util'
|
||||||
import {isNative} from '#/platform/detection'
|
import {isNative} from '#/platform/detection'
|
||||||
|
|
||||||
@@ -194,7 +195,7 @@ export function resetImageManipulation(
|
|||||||
return img
|
return img
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function compressImage(img: ComposerImage): Promise<ImageMeta> {
|
export async function compressImage(img: ComposerImage): Promise<PickerImage> {
|
||||||
const source = img.transformed || img.source
|
const source = img.transformed || img.source
|
||||||
|
|
||||||
const [w, h] = containImageRes(source.width, source.height, POST_IMG_MAX)
|
const [w, h] = containImageRes(source.width, source.height, POST_IMG_MAX)
|
||||||
@@ -219,14 +220,15 @@ export async function compressImage(img: ComposerImage): Promise<ImageMeta> {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const base64 = res.base64
|
const base64 = res.base64
|
||||||
|
const size = base64 ? getDataUriSize(base64) : 0
|
||||||
if (base64 !== undefined && getDataUriSize(base64) <= POST_IMG_MAX.size) {
|
if (base64 && size <= POST_IMG_MAX.size) {
|
||||||
minQualityPercentage = qualityPercentage
|
minQualityPercentage = qualityPercentage
|
||||||
newDataUri = {
|
newDataUri = {
|
||||||
path: await moveIfNecessary(res.uri),
|
path: await moveIfNecessary(res.uri),
|
||||||
width: res.width,
|
width: res.width,
|
||||||
height: res.height,
|
height: res.height,
|
||||||
mime: 'image/jpeg',
|
mime: 'image/jpeg',
|
||||||
|
size,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
maxQualityPercentage = qualityPercentage
|
maxQualityPercentage = qualityPercentage
|
||||||
|
|||||||
@@ -1,14 +1,7 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {type AppBskyActorDefs, type AppBskyGraphDefs} from '@atproto/api'
|
import {type AppBskyGraphDefs} from '@atproto/api'
|
||||||
|
|
||||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||||
import {type PickerImage} from '#/lib/media/picker.shared'
|
|
||||||
|
|
||||||
export interface EditProfileModal {
|
|
||||||
name: 'edit-profile'
|
|
||||||
profile: AppBskyActorDefs.ProfileViewDetailed
|
|
||||||
onUpdate?: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateOrEditListModal {
|
export interface CreateOrEditListModal {
|
||||||
name: 'create-or-edit-list'
|
name: 'create-or-edit-list'
|
||||||
@@ -26,15 +19,6 @@ export interface UserAddRemoveListsModal {
|
|||||||
onRemove?: (listUri: string) => void
|
onRemove?: (listUri: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CropImageModal {
|
|
||||||
name: 'crop-image'
|
|
||||||
uri: string
|
|
||||||
dimensions?: {width: number; height: number}
|
|
||||||
aspect?: number
|
|
||||||
circular?: boolean
|
|
||||||
onSelect: (img?: PickerImage) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DeleteAccountModal {
|
export interface DeleteAccountModal {
|
||||||
name: 'delete-account'
|
name: 'delete-account'
|
||||||
}
|
}
|
||||||
@@ -71,9 +55,6 @@ export type Modal =
|
|||||||
| DeleteAccountModal
|
| DeleteAccountModal
|
||||||
| ChangePasswordModal
|
| ChangePasswordModal
|
||||||
|
|
||||||
// Temp
|
|
||||||
| EditProfileModal
|
|
||||||
|
|
||||||
// Curation
|
// Curation
|
||||||
| ContentLanguagesSettingsModal
|
| ContentLanguagesSettingsModal
|
||||||
| PostLanguagesSettingsModal
|
| PostLanguagesSettingsModal
|
||||||
@@ -82,9 +63,6 @@ export type Modal =
|
|||||||
| CreateOrEditListModal
|
| CreateOrEditListModal
|
||||||
| UserAddRemoveListsModal
|
| UserAddRemoveListsModal
|
||||||
|
|
||||||
// Posts
|
|
||||||
| CropImageModal
|
|
||||||
|
|
||||||
// Bluesky access
|
// Bluesky access
|
||||||
| WaitlistModal
|
| WaitlistModal
|
||||||
| InviteCodesModal
|
| InviteCodesModal
|
||||||
@@ -110,20 +88,6 @@ const ModalControlContext = React.createContext<{
|
|||||||
closeAllModals: () => false,
|
closeAllModals: () => false,
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated DO NOT USE THIS unless you have no other choice.
|
|
||||||
*/
|
|
||||||
export let unstable__openModal: (modal: Modal) => void = () => {
|
|
||||||
throw new Error(`ModalContext is not initialized`)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated DO NOT USE THIS unless you have no other choice.
|
|
||||||
*/
|
|
||||||
export let unstable__closeModal: () => boolean = () => {
|
|
||||||
throw new Error(`ModalContext is not initialized`)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Provider({children}: React.PropsWithChildren<{}>) {
|
export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||||
const [activeModals, setActiveModals] = React.useState<Modal[]>([])
|
const [activeModals, setActiveModals] = React.useState<Modal[]>([])
|
||||||
|
|
||||||
@@ -145,9 +109,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
return wasActive
|
return wasActive
|
||||||
})
|
})
|
||||||
|
|
||||||
unstable__openModal = openModal
|
|
||||||
unstable__closeModal = closeModal
|
|
||||||
|
|
||||||
const state = React.useMemo(
|
const state = React.useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
isModalActive: activeModals.length > 0,
|
isModalActive: activeModals.length > 0,
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ import chunk from 'lodash.chunk'
|
|||||||
|
|
||||||
import {uploadBlob} from '#/lib/api'
|
import {uploadBlob} from '#/lib/api'
|
||||||
import {until} from '#/lib/async/until'
|
import {until} from '#/lib/async/until'
|
||||||
import {type PickerImage} from '#/lib/media/picker.shared'
|
import {type ImageMeta} from '#/state/gallery'
|
||||||
import {STALE} from '#/state/queries'
|
import {STALE} from '#/state/queries'
|
||||||
import {useAgent, useSession} from '../session'
|
import {useAgent, useSession} from '#/state/session'
|
||||||
import {invalidate as invalidateMyLists} from './my-lists'
|
import {invalidate as invalidateMyLists} from './my-lists'
|
||||||
import {RQKEY as PROFILE_LISTS_RQKEY} from './profile-lists'
|
import {RQKEY as PROFILE_LISTS_RQKEY} from './profile-lists'
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@ export interface ListCreateMutateParams {
|
|||||||
name: string
|
name: string
|
||||||
description: string
|
description: string
|
||||||
descriptionFacets: Facet[] | undefined
|
descriptionFacets: Facet[] | undefined
|
||||||
avatar: PickerImage | null | undefined
|
avatar: ImageMeta | null | undefined
|
||||||
}
|
}
|
||||||
export function useListCreateMutation() {
|
export function useListCreateMutation() {
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
@@ -115,7 +115,7 @@ export interface ListMetadataMutateParams {
|
|||||||
name: string
|
name: string
|
||||||
description: string
|
description: string
|
||||||
descriptionFacets: Facet[] | undefined
|
descriptionFacets: Facet[] | undefined
|
||||||
avatar: PickerImage | null | undefined
|
avatar: ImageMeta | null | undefined
|
||||||
}
|
}
|
||||||
export function useListMetadataMutation() {
|
export function useListMetadataMutation() {
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
|
|||||||
@@ -20,9 +20,10 @@ import {
|
|||||||
import {uploadBlob} from '#/lib/api'
|
import {uploadBlob} from '#/lib/api'
|
||||||
import {until} from '#/lib/async/until'
|
import {until} from '#/lib/async/until'
|
||||||
import {useToggleMutationQueue} from '#/lib/hooks/useToggleMutationQueue'
|
import {useToggleMutationQueue} from '#/lib/hooks/useToggleMutationQueue'
|
||||||
import {type PickerImage} from '#/lib/media/picker.shared'
|
|
||||||
import {logEvent, type LogEvents, toClout} from '#/lib/statsig/statsig'
|
import {logEvent, type LogEvents, toClout} from '#/lib/statsig/statsig'
|
||||||
|
import {updateProfileShadow} from '#/state/cache/profile-shadow'
|
||||||
import {type Shadow} from '#/state/cache/types'
|
import {type Shadow} from '#/state/cache/types'
|
||||||
|
import {type ImageMeta} from '#/state/gallery'
|
||||||
import {STALE} from '#/state/queries'
|
import {STALE} from '#/state/queries'
|
||||||
import {resetProfilePostsQueries} from '#/state/queries/post-feed'
|
import {resetProfilePostsQueries} from '#/state/queries/post-feed'
|
||||||
import {
|
import {
|
||||||
@@ -30,10 +31,9 @@ import {
|
|||||||
useUnstableProfileViewCache,
|
useUnstableProfileViewCache,
|
||||||
} from '#/state/queries/unstable-profile-cache'
|
} from '#/state/queries/unstable-profile-cache'
|
||||||
import {useUpdateProfileVerificationCache} from '#/state/queries/verification/useUpdateProfileVerificationCache'
|
import {useUpdateProfileVerificationCache} from '#/state/queries/verification/useUpdateProfileVerificationCache'
|
||||||
|
import {useAgent, useSession} from '#/state/session'
|
||||||
import * as userActionHistory from '#/state/userActionHistory'
|
import * as userActionHistory from '#/state/userActionHistory'
|
||||||
import type * as bsky from '#/types/bsky'
|
import type * as bsky from '#/types/bsky'
|
||||||
import {updateProfileShadow} from '../cache/profile-shadow'
|
|
||||||
import {useAgent, useSession} from '../session'
|
|
||||||
import {
|
import {
|
||||||
ProgressGuideAction,
|
ProgressGuideAction,
|
||||||
useProgressGuideControls,
|
useProgressGuideControls,
|
||||||
@@ -131,8 +131,8 @@ interface ProfileUpdateParams {
|
|||||||
| ((
|
| ((
|
||||||
existing: Un$Typed<AppBskyActorProfile.Record>,
|
existing: Un$Typed<AppBskyActorProfile.Record>,
|
||||||
) => Un$Typed<AppBskyActorProfile.Record>)
|
) => Un$Typed<AppBskyActorProfile.Record>)
|
||||||
newUserAvatar?: PickerImage | undefined | null
|
newUserAvatar?: ImageMeta | undefined | null
|
||||||
newUserBanner?: PickerImage | undefined | null
|
newUserBanner?: ImageMeta | undefined | null
|
||||||
checkCommitted?: (res: AppBskyActorGetProfile.Response) => boolean
|
checkCommitted?: (res: AppBskyActorGetProfile.Response) => boolean
|
||||||
}
|
}
|
||||||
export function useProfileUpdateMutation() {
|
export function useProfileUpdateMutation() {
|
||||||
|
|||||||
@@ -325,3 +325,11 @@ input[type='range'][orient='vertical']::-moz-range-thumb {
|
|||||||
background-color: var(--backgroundLight);
|
background-color: var(--backgroundLight);
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* #/components/Select/index.web.tsx */
|
||||||
|
.radix-select-content {
|
||||||
|
box-shadow: 0px 6px 24px -10px rgba(22, 23, 24, 0.25),
|
||||||
|
0px 6px 12px -12px rgba(22, 23, 24, 0.15);
|
||||||
|
min-width: var(--radix-select-trigger-width);
|
||||||
|
max-height: var(--radix-select-content-available-height);
|
||||||
|
}
|
||||||
|
|||||||
@@ -78,8 +78,10 @@ export const SplashScreen = ({
|
|||||||
a.justify_center,
|
a.justify_center,
|
||||||
a.align_center,
|
a.align_center,
|
||||||
]}>
|
]}>
|
||||||
|
<View>
|
||||||
<AppLanguageDropdown />
|
<AppLanguageDropdown />
|
||||||
</View>
|
</View>
|
||||||
|
</View>
|
||||||
<View style={{height: insets.bottom}} />
|
<View style={{height: insets.bottom}} />
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</CenteredView>
|
</CenteredView>
|
||||||
|
|||||||
@@ -154,9 +154,11 @@ function Footer() {
|
|||||||
a.absolute,
|
a.absolute,
|
||||||
a.inset_0,
|
a.inset_0,
|
||||||
{top: 'auto'},
|
{top: 'auto'},
|
||||||
a.p_xl,
|
a.px_xl,
|
||||||
|
a.py_lg,
|
||||||
a.border_t,
|
a.border_t,
|
||||||
a.flex_row,
|
a.flex_row,
|
||||||
|
a.align_center,
|
||||||
a.flex_wrap,
|
a.flex_wrap,
|
||||||
a.gap_xl,
|
a.gap_xl,
|
||||||
a.flex_1,
|
a.flex_1,
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import React from 'react'
|
import type React from 'react'
|
||||||
|
|
||||||
import {ComposerImage} from '#/state/gallery'
|
import {type ComposerImage} from '#/state/gallery'
|
||||||
import * as Dialog from '#/components/Dialog'
|
import type * as Dialog from '#/components/Dialog'
|
||||||
|
|
||||||
export type EditImageDialogProps = {
|
export type EditImageDialogProps = {
|
||||||
control: Dialog.DialogOuterProps['control']
|
control: Dialog.DialogOuterProps['control']
|
||||||
image: ComposerImage
|
image?: ComposerImage
|
||||||
onChange: (next: ComposerImage) => void
|
onChange: (next: ComposerImage) => void
|
||||||
|
aspectRatio?: number
|
||||||
|
circularCrop?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EditImageDialog = ({}: EditImageDialogProps): React.ReactNode => {
|
export const EditImageDialog = ({}: EditImageDialogProps): React.ReactNode => {
|
||||||
|
|||||||
@@ -1,43 +1,130 @@
|
|||||||
import 'react-image-crop/dist/ReactCrop.css'
|
import 'react-image-crop/dist/ReactCrop.css'
|
||||||
|
|
||||||
import React from 'react'
|
import {useCallback, useImperativeHandle, useRef, useState} from 'react'
|
||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import {msg, Trans} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import ReactCrop, {PercentCrop} from 'react-image-crop'
|
import ReactCrop, {type PercentCrop} from 'react-image-crop'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ImageSource,
|
type ImageSource,
|
||||||
ImageTransformation,
|
type ImageTransformation,
|
||||||
manipulateImage,
|
manipulateImage,
|
||||||
} from '#/state/gallery'
|
} from '#/state/gallery'
|
||||||
import {atoms as a} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {Button, ButtonText} from '#/components/Button'
|
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||||
import * as Dialog from '#/components/Dialog'
|
import * as Dialog from '#/components/Dialog'
|
||||||
import {Text} from '#/components/Typography'
|
import {Loader} from '#/components/Loader'
|
||||||
import {EditImageDialogProps} from './EditImageDialog'
|
import {type EditImageDialogProps} from './EditImageDialog'
|
||||||
|
|
||||||
export const EditImageDialog = (props: EditImageDialogProps) => {
|
export function EditImageDialog(props: EditImageDialogProps) {
|
||||||
return (
|
return (
|
||||||
<Dialog.Outer control={props.control}>
|
<Dialog.Outer control={props.control}>
|
||||||
<Dialog.Handle />
|
<Dialog.Handle />
|
||||||
<EditImageInner key={props.image.source.id} {...props} />
|
<DialogInner {...props} />
|
||||||
</Dialog.Outer>
|
</Dialog.Outer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const EditImageInner = ({control, image, onChange}: EditImageDialogProps) => {
|
function DialogInner({
|
||||||
|
control,
|
||||||
|
image,
|
||||||
|
onChange,
|
||||||
|
circularCrop,
|
||||||
|
aspectRatio,
|
||||||
|
}: EditImageDialogProps) {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
|
const [pending, setPending] = useState(false)
|
||||||
|
const ref = useRef<{save: () => Promise<void>}>(null)
|
||||||
|
|
||||||
|
const cancelButton = useCallback(
|
||||||
|
() => (
|
||||||
|
<Button
|
||||||
|
label={_(msg`Cancel`)}
|
||||||
|
disabled={pending}
|
||||||
|
onPress={() => control.close()}
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
variant="ghost"
|
||||||
|
style={[a.rounded_full]}
|
||||||
|
testID="cropImageCancelBtn">
|
||||||
|
<ButtonText style={[a.text_md]}>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
[control, _, pending],
|
||||||
|
)
|
||||||
|
|
||||||
|
const saveButton = useCallback(
|
||||||
|
() => (
|
||||||
|
<Button
|
||||||
|
label={_(msg`Save`)}
|
||||||
|
onPress={async () => {
|
||||||
|
setPending(true)
|
||||||
|
await ref.current?.save()
|
||||||
|
setPending(false)
|
||||||
|
}}
|
||||||
|
disabled={pending}
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
variant="ghost"
|
||||||
|
style={[a.rounded_full]}
|
||||||
|
testID="cropImageSaveBtn">
|
||||||
|
<ButtonText style={[a.text_md]}>
|
||||||
|
<Trans>Save</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
{pending && <ButtonIcon icon={Loader} />}
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
[_, pending],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog.Inner
|
||||||
|
label={_(msg`Edit image`)}
|
||||||
|
header={
|
||||||
|
<Dialog.Header renderLeft={cancelButton} renderRight={saveButton}>
|
||||||
|
<Dialog.HeaderText>
|
||||||
|
<Trans>Edit image</Trans>
|
||||||
|
</Dialog.HeaderText>
|
||||||
|
</Dialog.Header>
|
||||||
|
}>
|
||||||
|
{image && (
|
||||||
|
<EditImageInner
|
||||||
|
saveRef={ref}
|
||||||
|
key={image.source.id}
|
||||||
|
image={image}
|
||||||
|
onChange={onChange}
|
||||||
|
circularCrop={circularCrop}
|
||||||
|
aspectRatio={aspectRatio}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Dialog.Inner>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditImageInner({
|
||||||
|
image,
|
||||||
|
onChange,
|
||||||
|
saveRef,
|
||||||
|
circularCrop = false,
|
||||||
|
aspectRatio,
|
||||||
|
}: Required<Pick<EditImageDialogProps, 'image'>> &
|
||||||
|
Omit<EditImageDialogProps, 'control' | 'image'> & {
|
||||||
|
saveRef: React.RefObject<{save: () => Promise<void>}>
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const [isDragging, setIsDragging] = useState(false)
|
||||||
|
const {_} = useLingui()
|
||||||
|
const control = Dialog.useDialogContext()
|
||||||
|
|
||||||
const source = image.source
|
const source = image.source
|
||||||
|
|
||||||
const initialCrop = getInitialCrop(source, image.manips)
|
const initialCrop = getInitialCrop(source, image.manips)
|
||||||
const [crop, setCrop] = React.useState(initialCrop)
|
const [crop, setCrop] = useState(initialCrop)
|
||||||
|
|
||||||
const isEmpty = !crop || (crop.width || crop.height) === 0
|
const onPressSubmit = useCallback(async () => {
|
||||||
const isNew = initialCrop ? true : !isEmpty
|
|
||||||
|
|
||||||
const onPressSubmit = React.useCallback(async () => {
|
|
||||||
const result = await manipulateImage(image, {
|
const result = await manipulateImage(image, {
|
||||||
crop:
|
crop:
|
||||||
crop && (crop.width || crop.height) !== 0
|
crop && (crop.width || crop.height) !== 0
|
||||||
@@ -50,41 +137,43 @@ const EditImageInner = ({control, image, onChange}: EditImageDialogProps) => {
|
|||||||
: undefined,
|
: undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
control.close(() => {
|
||||||
onChange(result)
|
onChange(result)
|
||||||
control.close()
|
})
|
||||||
}, [crop, image, source, control, onChange])
|
}, [crop, image, source, control, onChange])
|
||||||
|
|
||||||
|
useImperativeHandle(
|
||||||
|
saveRef,
|
||||||
|
() => ({
|
||||||
|
save: onPressSubmit,
|
||||||
|
}),
|
||||||
|
[onPressSubmit],
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog.Inner label={_(msg`Edit image`)}>
|
<View
|
||||||
<Dialog.Close />
|
style={[
|
||||||
|
a.mx_auto,
|
||||||
<Text style={[a.text_2xl, a.font_bold, a.leading_tight, a.pb_sm]}>
|
a.border,
|
||||||
<Trans>Edit image</Trans>
|
t.atoms.border_contrast_low,
|
||||||
</Text>
|
a.rounded_xs,
|
||||||
|
a.overflow_hidden,
|
||||||
<View style={[a.align_center]}>
|
a.align_center,
|
||||||
|
]}>
|
||||||
<ReactCrop
|
<ReactCrop
|
||||||
crop={crop}
|
crop={crop}
|
||||||
|
aspect={aspectRatio}
|
||||||
|
circularCrop={circularCrop}
|
||||||
onChange={(_pixelCrop, percentCrop) => setCrop(percentCrop)}
|
onChange={(_pixelCrop, percentCrop) => setCrop(percentCrop)}
|
||||||
className="ReactCrop--no-animate">
|
className="ReactCrop--no-animate"
|
||||||
|
onDragStart={() => setIsDragging(true)}
|
||||||
|
onDragEnd={() => setIsDragging(false)}>
|
||||||
<img src={source.path} style={{maxHeight: `50vh`}} />
|
<img src={source.path} style={{maxHeight: `50vh`}} />
|
||||||
</ReactCrop>
|
</ReactCrop>
|
||||||
|
{/* Eat clicks when dragging, otherwise mousing up over the backdrop
|
||||||
|
causes the dialog to close */}
|
||||||
|
{isDragging && <View style={[a.fixed, a.inset_0]} />}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={[a.mt_md, a.gap_md]}>
|
|
||||||
<Button
|
|
||||||
disabled={!isNew}
|
|
||||||
label={_(msg`Save`)}
|
|
||||||
size="large"
|
|
||||||
color="primary"
|
|
||||||
variant="solid"
|
|
||||||
onPress={onPressSubmit}>
|
|
||||||
<ButtonText>
|
|
||||||
<Trans>Save</Trans>
|
|
||||||
</ButtonText>
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
</Dialog.Inner>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import {useCallback, useState} from 'react'
|
import {useCallback, useState} from 'react'
|
||||||
import {Keyboard, StyleProp, View, ViewStyle} from 'react-native'
|
import {Keyboard, type StyleProp, View, type ViewStyle} from 'react-native'
|
||||||
import RNPickerSelect from 'react-native-picker-select'
|
|
||||||
import {msg, Trans} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
@@ -240,19 +239,21 @@ function SubtitleFileRow({
|
|||||||
numberOfLines={1}>
|
numberOfLines={1}>
|
||||||
{file.name}
|
{file.name}
|
||||||
</Text>
|
</Text>
|
||||||
<RNPickerSelect
|
<select
|
||||||
placeholder={{
|
|
||||||
label: _(msg`Select language...`),
|
|
||||||
value: '',
|
|
||||||
}}
|
|
||||||
value={language}
|
value={language}
|
||||||
onValueChange={handleValueChange}
|
onChange={evt => handleValueChange(evt.target.value)}
|
||||||
items={otherLanguages.map(lang => ({
|
style={{maxWidth: 200, flex: 1}}>
|
||||||
label: `${lang.name} (${langCode(lang)})`,
|
<option value="" disabled selected hidden>
|
||||||
value: langCode(lang),
|
{/* eslint-disable-next-line bsky-internal/avoid-unwrapped-text */}
|
||||||
}))}
|
<Trans>Select language...</Trans>
|
||||||
style={{viewContainer: {maxWidth: 200, flex: 1}}}
|
</option>
|
||||||
/>
|
{otherLanguages.map(lang => (
|
||||||
|
<option key={langCode(lang)} value={langCode(lang)}>
|
||||||
|
{/* eslint-disable-next-line bsky-internal/avoid-unwrapped-text */}
|
||||||
|
{`${lang.name} (${langCode(lang)})`}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|||||||
@@ -15,24 +15,23 @@ import {useLingui} from '@lingui/react'
|
|||||||
|
|
||||||
import {usePalette} from '#/lib/hooks/usePalette'
|
import {usePalette} from '#/lib/hooks/usePalette'
|
||||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||||
import {compressIfNeeded} from '#/lib/media/manip'
|
|
||||||
import {type PickerImage} from '#/lib/media/picker.shared'
|
|
||||||
import {cleanError, isNetworkError} from '#/lib/strings/errors'
|
import {cleanError, isNetworkError} from '#/lib/strings/errors'
|
||||||
import {enforceLen} from '#/lib/strings/helpers'
|
import {enforceLen} from '#/lib/strings/helpers'
|
||||||
import {richTextToString} from '#/lib/strings/rich-text-helpers'
|
import {richTextToString} from '#/lib/strings/rich-text-helpers'
|
||||||
import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'
|
import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'
|
||||||
import {colors, gradients, s} from '#/lib/styles'
|
import {colors, gradients, s} from '#/lib/styles'
|
||||||
import {useTheme} from '#/lib/ThemeContext'
|
import {useTheme} from '#/lib/ThemeContext'
|
||||||
|
import {type ImageMeta} from '#/state/gallery'
|
||||||
import {useModalControls} from '#/state/modals'
|
import {useModalControls} from '#/state/modals'
|
||||||
import {
|
import {
|
||||||
useListCreateMutation,
|
useListCreateMutation,
|
||||||
useListMetadataMutation,
|
useListMetadataMutation,
|
||||||
} from '#/state/queries/list'
|
} from '#/state/queries/list'
|
||||||
import {useAgent} from '#/state/session'
|
import {useAgent} from '#/state/session'
|
||||||
import {ErrorMessage} from '../util/error/ErrorMessage'
|
import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
|
||||||
import {Text} from '../util/text/Text'
|
import {Text} from '#/view/com/util/text/Text'
|
||||||
import * as Toast from '../util/Toast'
|
import * as Toast from '#/view/com/util/Toast'
|
||||||
import {EditableUserAvatar} from '../util/UserAvatar'
|
import {EditableUserAvatar} from '#/view/com/util/UserAvatar'
|
||||||
|
|
||||||
const MAX_NAME = 64 // todo
|
const MAX_NAME = 64 // todo
|
||||||
const MAX_DESCRIPTION = 300 // todo
|
const MAX_DESCRIPTION = 300 // todo
|
||||||
@@ -95,7 +94,7 @@ export function Component({
|
|||||||
const isDescriptionOver = graphemeLength > MAX_DESCRIPTION
|
const isDescriptionOver = graphemeLength > MAX_DESCRIPTION
|
||||||
|
|
||||||
const [avatar, setAvatar] = useState<string | undefined>(list?.avatar)
|
const [avatar, setAvatar] = useState<string | undefined>(list?.avatar)
|
||||||
const [newAvatar, setNewAvatar] = useState<PickerImage | undefined | null>()
|
const [newAvatar, setNewAvatar] = useState<ImageMeta | undefined | null>()
|
||||||
|
|
||||||
const onDescriptionChange = useCallback(
|
const onDescriptionChange = useCallback(
|
||||||
(newText: string) => {
|
(newText: string) => {
|
||||||
@@ -112,16 +111,15 @@ export function Component({
|
|||||||
}, [closeModal])
|
}, [closeModal])
|
||||||
|
|
||||||
const onSelectNewAvatar = useCallback(
|
const onSelectNewAvatar = useCallback(
|
||||||
async (img: PickerImage | null) => {
|
(img: ImageMeta | null) => {
|
||||||
if (!img) {
|
if (!img) {
|
||||||
setNewAvatar(null)
|
setNewAvatar(null)
|
||||||
setAvatar(undefined)
|
setAvatar(undefined)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const finalImg = await compressIfNeeded(img, 1000000)
|
setNewAvatar(img)
|
||||||
setNewAvatar(finalImg)
|
setAvatar(img.path)
|
||||||
setAvatar(finalImg.path)
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(cleanError(e))
|
setError(cleanError(e))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {createCustomBackdrop} from '../util/BottomSheetCustomBackdrop'
|
|||||||
import * as ChangePasswordModal from './ChangePassword'
|
import * as ChangePasswordModal from './ChangePassword'
|
||||||
import * as CreateOrEditListModal from './CreateOrEditList'
|
import * as CreateOrEditListModal from './CreateOrEditList'
|
||||||
import * as DeleteAccountModal from './DeleteAccount'
|
import * as DeleteAccountModal from './DeleteAccount'
|
||||||
import * as EditProfileModal from './EditProfile'
|
|
||||||
import * as InviteCodesModal from './InviteCodes'
|
import * as InviteCodesModal from './InviteCodes'
|
||||||
import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
|
import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
|
||||||
import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
|
import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
|
||||||
@@ -48,10 +47,7 @@ export function ModalsContainer() {
|
|||||||
|
|
||||||
let snapPoints: (string | number)[] = DEFAULT_SNAPPOINTS
|
let snapPoints: (string | number)[] = DEFAULT_SNAPPOINTS
|
||||||
let element
|
let element
|
||||||
if (activeModal?.name === 'edit-profile') {
|
if (activeModal?.name === 'create-or-edit-list') {
|
||||||
snapPoints = EditProfileModal.snapPoints
|
|
||||||
element = <EditProfileModal.Component {...activeModal} />
|
|
||||||
} else if (activeModal?.name === 'create-or-edit-list') {
|
|
||||||
snapPoints = CreateOrEditListModal.snapPoints
|
snapPoints = CreateOrEditListModal.snapPoints
|
||||||
element = <CreateOrEditListModal.Component {...activeModal} />
|
element = <CreateOrEditListModal.Component {...activeModal} />
|
||||||
} else if (activeModal?.name === 'user-add-remove-lists') {
|
} else if (activeModal?.name === 'user-add-remove-lists') {
|
||||||
|
|||||||
@@ -8,9 +8,7 @@ import {type Modal as ModalIface} from '#/state/modals'
|
|||||||
import {useModalControls, useModals} from '#/state/modals'
|
import {useModalControls, useModals} from '#/state/modals'
|
||||||
import * as ChangePasswordModal from './ChangePassword'
|
import * as ChangePasswordModal from './ChangePassword'
|
||||||
import * as CreateOrEditListModal from './CreateOrEditList'
|
import * as CreateOrEditListModal from './CreateOrEditList'
|
||||||
import * as CropImageModal from './CropImage.web'
|
|
||||||
import * as DeleteAccountModal from './DeleteAccount'
|
import * as DeleteAccountModal from './DeleteAccount'
|
||||||
import * as EditProfileModal from './EditProfile'
|
|
||||||
import * as InviteCodesModal from './InviteCodes'
|
import * as InviteCodesModal from './InviteCodes'
|
||||||
import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
|
import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
|
||||||
import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
|
import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
|
||||||
@@ -45,9 +43,6 @@ function Modal({modal}: {modal: ModalIface}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onPressMask = () => {
|
const onPressMask = () => {
|
||||||
if (modal.name === 'crop-image') {
|
|
||||||
return // dont close on mask presses during crop
|
|
||||||
}
|
|
||||||
closeModal()
|
closeModal()
|
||||||
}
|
}
|
||||||
const onInnerPress = () => {
|
const onInnerPress = () => {
|
||||||
@@ -56,14 +51,10 @@ function Modal({modal}: {modal: ModalIface}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let element
|
let element
|
||||||
if (modal.name === 'edit-profile') {
|
if (modal.name === 'create-or-edit-list') {
|
||||||
element = <EditProfileModal.Component {...modal} />
|
|
||||||
} else if (modal.name === 'create-or-edit-list') {
|
|
||||||
element = <CreateOrEditListModal.Component {...modal} />
|
element = <CreateOrEditListModal.Component {...modal} />
|
||||||
} else if (modal.name === 'user-add-remove-lists') {
|
} else if (modal.name === 'user-add-remove-lists') {
|
||||||
element = <UserAddRemoveLists.Component {...modal} />
|
element = <UserAddRemoveLists.Component {...modal} />
|
||||||
} else if (modal.name === 'crop-image') {
|
|
||||||
element = <CropImageModal.Component {...modal} />
|
|
||||||
} else if (modal.name === 'delete-account') {
|
} else if (modal.name === 'delete-account') {
|
||||||
element = <DeleteAccountModal.Component />
|
element = <DeleteAccountModal.Component />
|
||||||
} else if (modal.name === 'invite-codes') {
|
} else if (modal.name === 'invite-codes') {
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
import React, {memo} from 'react'
|
import React, {memo, useCallback} from 'react'
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
AppState,
|
AppState,
|
||||||
Dimensions,
|
Dimensions,
|
||||||
|
LayoutAnimation,
|
||||||
type ListRenderItemInfo,
|
type ListRenderItemInfo,
|
||||||
type StyleProp,
|
type StyleProp,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
View,
|
View,
|
||||||
type ViewStyle,
|
type ViewStyle,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import {type AppBskyActorDefs, AppBskyEmbedVideo} from '@atproto/api'
|
import {
|
||||||
|
type AppBskyActorDefs,
|
||||||
|
AppBskyEmbedVideo,
|
||||||
|
type AppBskyFeedDefs,
|
||||||
|
} from '@atproto/api'
|
||||||
import {msg} from '@lingui/macro'
|
import {msg} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {useQueryClient} from '@tanstack/react-query'
|
import {useQueryClient} from '@tanstack/react-query'
|
||||||
@@ -51,6 +56,7 @@ import {DiscoverFallbackHeader} from './DiscoverFallbackHeader'
|
|||||||
import {FeedShutdownMsg} from './FeedShutdownMsg'
|
import {FeedShutdownMsg} from './FeedShutdownMsg'
|
||||||
import {PostFeedErrorMessage} from './PostFeedErrorMessage'
|
import {PostFeedErrorMessage} from './PostFeedErrorMessage'
|
||||||
import {PostFeedItem} from './PostFeedItem'
|
import {PostFeedItem} from './PostFeedItem'
|
||||||
|
import {ShowLessFollowup} from './ShowLessFollowup'
|
||||||
import {ViewFullThread} from './ViewFullThread'
|
import {ViewFullThread} from './ViewFullThread'
|
||||||
|
|
||||||
type FeedRow =
|
type FeedRow =
|
||||||
@@ -117,6 +123,10 @@ type FeedRow =
|
|||||||
type: 'interstitialTrendingVideos'
|
type: 'interstitialTrendingVideos'
|
||||||
key: string
|
key: string
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
type: 'showLessFollowup'
|
||||||
|
key: string
|
||||||
|
}
|
||||||
|
|
||||||
export function getItemsForFeedback(feedRow: FeedRow):
|
export function getItemsForFeedback(feedRow: FeedRow):
|
||||||
| {
|
| {
|
||||||
@@ -200,6 +210,20 @@ let PostFeed = ({
|
|||||||
const {rightNavVisible} = useLayoutBreakpoints()
|
const {rightNavVisible} = useLayoutBreakpoints()
|
||||||
const areVideoFeedsEnabled = isNative
|
const areVideoFeedsEnabled = isNative
|
||||||
|
|
||||||
|
const [hasPressedShowLessUris, setHasPressedShowLessUris] = React.useState(
|
||||||
|
() => new Set<string>(),
|
||||||
|
)
|
||||||
|
const onPressShowLess = useCallback(
|
||||||
|
(interaction: AppBskyFeedDefs.Interaction) => {
|
||||||
|
if (interaction.item) {
|
||||||
|
const uri = interaction.item
|
||||||
|
setHasPressedShowLessUris(prev => new Set([...prev, uri]))
|
||||||
|
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
const feedCacheKey = feedParams?.feedCacheKey
|
const feedCacheKey = feedParams?.feedCacheKey
|
||||||
const opts = React.useMemo(
|
const opts = React.useMemo(
|
||||||
() => ({enabled, ignoreFilterFor}),
|
() => ({enabled, ignoreFilterFor}),
|
||||||
@@ -321,6 +345,19 @@ let PostFeed = ({
|
|||||||
const {trendingDisabled, trendingVideoDisabled} = useTrendingSettings()
|
const {trendingDisabled, trendingVideoDisabled} = useTrendingSettings()
|
||||||
|
|
||||||
const feedItems: FeedRow[] = React.useMemo(() => {
|
const feedItems: FeedRow[] = React.useMemo(() => {
|
||||||
|
// wraps a slice item, and replaces it with a showLessFollowup item
|
||||||
|
// if the user has pressed show less on it
|
||||||
|
const sliceItem = (row: Extract<FeedRow, {type: 'sliceItem'}>) => {
|
||||||
|
if (hasPressedShowLessUris.has(row.slice.items[row.indexInSlice]?.uri)) {
|
||||||
|
return {
|
||||||
|
type: 'showLessFollowup',
|
||||||
|
key: row.key,
|
||||||
|
} as const
|
||||||
|
} else {
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let feedKind: 'following' | 'discover' | 'profile' | 'thevids' | undefined
|
let feedKind: 'following' | 'discover' | 'profile' | 'thevids' | undefined
|
||||||
if (feedType === 'following') {
|
if (feedType === 'following') {
|
||||||
feedKind = 'following'
|
feedKind = 'following'
|
||||||
@@ -450,19 +487,22 @@ let PostFeed = ({
|
|||||||
} else if (slice.isIncompleteThread && slice.items.length >= 3) {
|
} else if (slice.isIncompleteThread && slice.items.length >= 3) {
|
||||||
const beforeLast = slice.items.length - 2
|
const beforeLast = slice.items.length - 2
|
||||||
const last = slice.items.length - 1
|
const last = slice.items.length - 1
|
||||||
arr.push({
|
arr.push(
|
||||||
|
sliceItem({
|
||||||
type: 'sliceItem',
|
type: 'sliceItem',
|
||||||
key: slice.items[0]._reactKey,
|
key: slice.items[0]._reactKey,
|
||||||
slice: slice,
|
slice: slice,
|
||||||
indexInSlice: 0,
|
indexInSlice: 0,
|
||||||
showReplyTo: false,
|
showReplyTo: false,
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
arr.push({
|
arr.push({
|
||||||
type: 'sliceViewFullThread',
|
type: 'sliceViewFullThread',
|
||||||
key: slice._reactKey + '-viewFullThread',
|
key: slice._reactKey + '-viewFullThread',
|
||||||
uri: slice.items[0].uri,
|
uri: slice.items[0].uri,
|
||||||
})
|
})
|
||||||
arr.push({
|
arr.push(
|
||||||
|
sliceItem({
|
||||||
type: 'sliceItem',
|
type: 'sliceItem',
|
||||||
key: slice.items[beforeLast]._reactKey,
|
key: slice.items[beforeLast]._reactKey,
|
||||||
slice: slice,
|
slice: slice,
|
||||||
@@ -470,23 +510,28 @@ let PostFeed = ({
|
|||||||
showReplyTo:
|
showReplyTo:
|
||||||
slice.items[beforeLast].parentAuthor?.did !==
|
slice.items[beforeLast].parentAuthor?.did !==
|
||||||
slice.items[beforeLast].post.author.did,
|
slice.items[beforeLast].post.author.did,
|
||||||
})
|
}),
|
||||||
arr.push({
|
)
|
||||||
|
arr.push(
|
||||||
|
sliceItem({
|
||||||
type: 'sliceItem',
|
type: 'sliceItem',
|
||||||
key: slice.items[last]._reactKey,
|
key: slice.items[last]._reactKey,
|
||||||
slice: slice,
|
slice: slice,
|
||||||
indexInSlice: last,
|
indexInSlice: last,
|
||||||
showReplyTo: false,
|
showReplyTo: false,
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
for (let i = 0; i < slice.items.length; i++) {
|
for (let i = 0; i < slice.items.length; i++) {
|
||||||
arr.push({
|
arr.push(
|
||||||
|
sliceItem({
|
||||||
type: 'sliceItem',
|
type: 'sliceItem',
|
||||||
key: slice.items[i]._reactKey,
|
key: slice.items[i]._reactKey,
|
||||||
slice: slice,
|
slice: slice,
|
||||||
indexInSlice: i,
|
indexInSlice: i,
|
||||||
showReplyTo: i === 0,
|
showReplyTo: i === 0,
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -531,6 +576,7 @@ let PostFeed = ({
|
|||||||
gtMobile,
|
gtMobile,
|
||||||
isVideoFeed,
|
isVideoFeed,
|
||||||
areVideoFeedsEnabled,
|
areVideoFeedsEnabled,
|
||||||
|
hasPressedShowLessUris,
|
||||||
])
|
])
|
||||||
|
|
||||||
// events
|
// events
|
||||||
@@ -650,6 +696,7 @@ let PostFeed = ({
|
|||||||
isParentNotFound={item.isParentNotFound}
|
isParentNotFound={item.isParentNotFound}
|
||||||
hideTopBorder={rowIndex === 0 && indexInSlice === 0}
|
hideTopBorder={rowIndex === 0 && indexInSlice === 0}
|
||||||
rootPost={slice.items[0].post}
|
rootPost={slice.items[0].post}
|
||||||
|
onShowLess={onPressShowLess}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
} else if (row.type === 'sliceViewFullThread') {
|
} else if (row.type === 'sliceViewFullThread') {
|
||||||
@@ -684,6 +731,8 @@ let PostFeed = ({
|
|||||||
sourceContext={sourceContext}
|
sourceContext={sourceContext}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
} else if (row.type === 'showLessFollowup') {
|
||||||
|
return <ShowLessFollowup />
|
||||||
} else {
|
} else {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -700,6 +749,7 @@ let PostFeed = ({
|
|||||||
feedUriOrActorDid,
|
feedUriOrActorDid,
|
||||||
feedTab,
|
feedTab,
|
||||||
feedCacheKey,
|
feedCacheKey,
|
||||||
|
onPressShowLess,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
import React, {memo, useMemo, useState} from 'react'
|
import {memo, useCallback, useMemo, useState} from 'react'
|
||||||
import {StyleSheet, View} from 'react-native'
|
import {StyleSheet, View} from 'react-native'
|
||||||
import {
|
import {
|
||||||
AppBskyActorDefs,
|
type AppBskyActorDefs,
|
||||||
AppBskyFeedDefs,
|
AppBskyFeedDefs,
|
||||||
AppBskyFeedPost,
|
AppBskyFeedPost,
|
||||||
AppBskyFeedThreadgate,
|
AppBskyFeedThreadgate,
|
||||||
AtUri,
|
AtUri,
|
||||||
ModerationDecision,
|
type ModerationDecision,
|
||||||
RichText as RichTextAPI,
|
RichText as RichTextAPI,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
import {
|
import {
|
||||||
FontAwesomeIcon,
|
FontAwesomeIcon,
|
||||||
FontAwesomeIconStyle,
|
type FontAwesomeIconStyle,
|
||||||
} from '@fortawesome/react-native-fontawesome'
|
} from '@fortawesome/react-native-fontawesome'
|
||||||
import {msg, Trans} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {useQueryClient} from '@tanstack/react-query'
|
import {useQueryClient} from '@tanstack/react-query'
|
||||||
|
|
||||||
import {isReasonFeedSource, ReasonFeedSource} from '#/lib/api/feed/types'
|
import {isReasonFeedSource, type ReasonFeedSource} from '#/lib/api/feed/types'
|
||||||
import {MAX_POST_LINES} from '#/lib/constants'
|
import {MAX_POST_LINES} from '#/lib/constants'
|
||||||
import {usePalette} from '#/lib/hooks/usePalette'
|
import {usePalette} from '#/lib/hooks/usePalette'
|
||||||
import {makeProfileLink} from '#/lib/routes/links'
|
import {makeProfileLink} from '#/lib/routes/links'
|
||||||
@@ -25,7 +25,11 @@ import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
|||||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||||
import {countLines} from '#/lib/strings/helpers'
|
import {countLines} from '#/lib/strings/helpers'
|
||||||
import {s} from '#/lib/styles'
|
import {s} from '#/lib/styles'
|
||||||
import {POST_TOMBSTONE, Shadow, usePostShadow} from '#/state/cache/post-shadow'
|
import {
|
||||||
|
POST_TOMBSTONE,
|
||||||
|
type Shadow,
|
||||||
|
usePostShadow,
|
||||||
|
} from '#/state/cache/post-shadow'
|
||||||
import {useFeedFeedbackContext} from '#/state/feed-feedback'
|
import {useFeedFeedbackContext} from '#/state/feed-feedback'
|
||||||
import {precacheProfile} from '#/state/queries/profile'
|
import {precacheProfile} from '#/state/queries/profile'
|
||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
@@ -43,7 +47,7 @@ import {Repost_Stroke2_Corner2_Rounded as RepostIcon} from '#/components/icons/R
|
|||||||
import {ContentHider} from '#/components/moderation/ContentHider'
|
import {ContentHider} from '#/components/moderation/ContentHider'
|
||||||
import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe'
|
import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe'
|
||||||
import {PostAlerts} from '#/components/moderation/PostAlerts'
|
import {PostAlerts} from '#/components/moderation/PostAlerts'
|
||||||
import {AppModerationCause} from '#/components/Pills'
|
import {type AppModerationCause} from '#/components/Pills'
|
||||||
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
|
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
|
||||||
import {RichText} from '#/components/RichText'
|
import {RichText} from '#/components/RichText'
|
||||||
import {SubtleWebHover} from '#/components/SubtleWebHover'
|
import {SubtleWebHover} from '#/components/SubtleWebHover'
|
||||||
@@ -86,9 +90,11 @@ export function PostFeedItem({
|
|||||||
isParentBlocked,
|
isParentBlocked,
|
||||||
isParentNotFound,
|
isParentNotFound,
|
||||||
rootPost,
|
rootPost,
|
||||||
|
onShowLess,
|
||||||
}: FeedItemProps & {
|
}: FeedItemProps & {
|
||||||
post: AppBskyFeedDefs.PostView
|
post: AppBskyFeedDefs.PostView
|
||||||
rootPost: AppBskyFeedDefs.PostView
|
rootPost: AppBskyFeedDefs.PostView
|
||||||
|
onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void
|
||||||
}): React.ReactNode {
|
}): React.ReactNode {
|
||||||
const postShadowed = usePostShadow(post)
|
const postShadowed = usePostShadow(post)
|
||||||
const richText = useMemo(
|
const richText = useMemo(
|
||||||
@@ -122,6 +128,7 @@ export function PostFeedItem({
|
|||||||
isParentBlocked={isParentBlocked}
|
isParentBlocked={isParentBlocked}
|
||||||
isParentNotFound={isParentNotFound}
|
isParentNotFound={isParentNotFound}
|
||||||
rootPost={rootPost}
|
rootPost={rootPost}
|
||||||
|
onShowLess={onShowLess}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -144,23 +151,27 @@ let FeedItemInner = ({
|
|||||||
isParentBlocked,
|
isParentBlocked,
|
||||||
isParentNotFound,
|
isParentNotFound,
|
||||||
rootPost,
|
rootPost,
|
||||||
|
onShowLess,
|
||||||
}: FeedItemProps & {
|
}: FeedItemProps & {
|
||||||
richText: RichTextAPI
|
richText: RichTextAPI
|
||||||
post: Shadow<AppBskyFeedDefs.PostView>
|
post: Shadow<AppBskyFeedDefs.PostView>
|
||||||
rootPost: AppBskyFeedDefs.PostView
|
rootPost: AppBskyFeedDefs.PostView
|
||||||
|
onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void
|
||||||
}): React.ReactNode => {
|
}): React.ReactNode => {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const {openComposer} = useComposerControls()
|
const {openComposer} = useComposerControls()
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
|
|
||||||
|
const [hover, setHover] = useState(false)
|
||||||
|
|
||||||
const href = useMemo(() => {
|
const href = useMemo(() => {
|
||||||
const urip = new AtUri(post.uri)
|
const urip = new AtUri(post.uri)
|
||||||
return makeProfileLink(post.author, 'post', urip.rkey)
|
return makeProfileLink(post.author, 'post', urip.rkey)
|
||||||
}, [post.uri, post.author])
|
}, [post.uri, post.author])
|
||||||
const {sendInteraction} = useFeedFeedbackContext()
|
const {sendInteraction} = useFeedFeedbackContext()
|
||||||
|
|
||||||
const onPressReply = React.useCallback(() => {
|
const onPressReply = useCallback(() => {
|
||||||
sendInteraction({
|
sendInteraction({
|
||||||
item: post.uri,
|
item: post.uri,
|
||||||
event: 'app.bsky.feed.defs#interactionReply',
|
event: 'app.bsky.feed.defs#interactionReply',
|
||||||
@@ -178,7 +189,7 @@ let FeedItemInner = ({
|
|||||||
})
|
})
|
||||||
}, [post, record, openComposer, moderation, sendInteraction, feedContext])
|
}, [post, record, openComposer, moderation, sendInteraction, feedContext])
|
||||||
|
|
||||||
const onOpenAuthor = React.useCallback(() => {
|
const onOpenAuthor = useCallback(() => {
|
||||||
sendInteraction({
|
sendInteraction({
|
||||||
item: post.uri,
|
item: post.uri,
|
||||||
event: 'app.bsky.feed.defs#clickthroughAuthor',
|
event: 'app.bsky.feed.defs#clickthroughAuthor',
|
||||||
@@ -186,7 +197,7 @@ let FeedItemInner = ({
|
|||||||
})
|
})
|
||||||
}, [sendInteraction, post, feedContext])
|
}, [sendInteraction, post, feedContext])
|
||||||
|
|
||||||
const onOpenReposter = React.useCallback(() => {
|
const onOpenReposter = useCallback(() => {
|
||||||
sendInteraction({
|
sendInteraction({
|
||||||
item: post.uri,
|
item: post.uri,
|
||||||
event: 'app.bsky.feed.defs#clickthroughReposter',
|
event: 'app.bsky.feed.defs#clickthroughReposter',
|
||||||
@@ -194,7 +205,7 @@ let FeedItemInner = ({
|
|||||||
})
|
})
|
||||||
}, [sendInteraction, post, feedContext])
|
}, [sendInteraction, post, feedContext])
|
||||||
|
|
||||||
const onOpenEmbed = React.useCallback(() => {
|
const onOpenEmbed = useCallback(() => {
|
||||||
sendInteraction({
|
sendInteraction({
|
||||||
item: post.uri,
|
item: post.uri,
|
||||||
event: 'app.bsky.feed.defs#clickthroughEmbed',
|
event: 'app.bsky.feed.defs#clickthroughEmbed',
|
||||||
@@ -202,7 +213,7 @@ let FeedItemInner = ({
|
|||||||
})
|
})
|
||||||
}, [sendInteraction, post, feedContext])
|
}, [sendInteraction, post, feedContext])
|
||||||
|
|
||||||
const onBeforePress = React.useCallback(() => {
|
const onBeforePress = useCallback(() => {
|
||||||
sendInteraction({
|
sendInteraction({
|
||||||
item: post.uri,
|
item: post.uri,
|
||||||
event: 'app.bsky.feed.defs#clickthroughItem',
|
event: 'app.bsky.feed.defs#clickthroughItem',
|
||||||
@@ -240,7 +251,6 @@ let FeedItemInner = ({
|
|||||||
? rootPost.threadgate.record
|
? rootPost.threadgate.record
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
const [hover, setHover] = useState(false)
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
testID={`feedItem-by-${post.author.handle}`}
|
testID={`feedItem-by-${post.author.handle}`}
|
||||||
@@ -427,6 +437,7 @@ let FeedItemInner = ({
|
|||||||
logContext="FeedItem"
|
logContext="FeedItem"
|
||||||
feedContext={feedContext}
|
feedContext={feedContext}
|
||||||
threadgateRecord={threadgateRecord}
|
threadgateRecord={threadgateRecord}
|
||||||
|
onShowLess={onShowLess}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -461,7 +472,7 @@ let PostContent = ({
|
|||||||
const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({
|
const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({
|
||||||
threadgateRecord,
|
threadgateRecord,
|
||||||
})
|
})
|
||||||
const additionalPostAlerts: AppModerationCause[] = React.useMemo(() => {
|
const additionalPostAlerts: AppModerationCause[] = useMemo(() => {
|
||||||
const isPostHiddenByThreadgate = threadgateHiddenReplies.has(post.uri)
|
const isPostHiddenByThreadgate = threadgateHiddenReplies.has(post.uri)
|
||||||
const rootPostUri = bsky.dangerousIsType<AppBskyFeedPost.Record>(
|
const rootPostUri = bsky.dangerousIsType<AppBskyFeedPost.Record>(
|
||||||
post.record,
|
post.record,
|
||||||
@@ -482,7 +493,7 @@ let PostContent = ({
|
|||||||
: []
|
: []
|
||||||
}, [post, currentAccount?.did, threadgateHiddenReplies])
|
}, [post, currentAccount?.did, threadgateHiddenReplies])
|
||||||
|
|
||||||
const onPressShowMore = React.useCallback(() => {
|
const onPressShowMore = useCallback(() => {
|
||||||
setLimitLines(false)
|
setLimitLines(false)
|
||||||
}, [setLimitLines])
|
}, [setLimitLines])
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import {View} from 'react-native'
|
||||||
|
import {Trans} from '@lingui/macro'
|
||||||
|
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {CircleCheck_Stroke2_Corner0_Rounded} from '#/components/icons/CircleCheck'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
export function ShowLessFollowup() {
|
||||||
|
const t = useTheme()
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
t.atoms.border_contrast_low,
|
||||||
|
a.border_t,
|
||||||
|
t.atoms.bg_contrast_25,
|
||||||
|
a.p_sm,
|
||||||
|
]}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
t.atoms.bg,
|
||||||
|
t.atoms.border_contrast_low,
|
||||||
|
a.border,
|
||||||
|
a.rounded_sm,
|
||||||
|
a.p_md,
|
||||||
|
a.flex_row,
|
||||||
|
a.gap_sm,
|
||||||
|
]}>
|
||||||
|
<CircleCheck_Stroke2_Corner0_Rounded
|
||||||
|
style={[t.atoms.text_contrast_low]}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
a.flex_1,
|
||||||
|
a.text_sm,
|
||||||
|
t.atoms.text_contrast_medium,
|
||||||
|
a.leading_snug,
|
||||||
|
]}>
|
||||||
|
<Trans>
|
||||||
|
Thank you for your feedback! It has been sent to the feed operator.
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react'
|
import {View, type ViewStyle} from 'react-native'
|
||||||
import {View, ViewStyle} from 'react-native'
|
import type React from 'react'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This utility function captures events and stops
|
* This utility function captures events and stops
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, {memo, useMemo} from 'react'
|
import React, {memo, useCallback, useMemo, useState} from 'react'
|
||||||
import {
|
import {
|
||||||
Image,
|
Image,
|
||||||
Pressable,
|
Pressable,
|
||||||
@@ -14,36 +14,38 @@ import {msg, Trans} from '@lingui/macro'
|
|||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {useQueryClient} from '@tanstack/react-query'
|
import {useQueryClient} from '@tanstack/react-query'
|
||||||
|
|
||||||
import {usePalette} from '#/lib/hooks/usePalette'
|
|
||||||
import {
|
import {
|
||||||
useCameraPermission,
|
useCameraPermission,
|
||||||
usePhotoLibraryPermission,
|
usePhotoLibraryPermission,
|
||||||
} from '#/lib/hooks/usePermissions'
|
} from '#/lib/hooks/usePermissions'
|
||||||
|
import {compressIfNeeded} from '#/lib/media/manip'
|
||||||
|
import {openCamera, openCropper, openPicker} from '#/lib/media/picker'
|
||||||
|
import {type PickerImage} from '#/lib/media/picker.shared'
|
||||||
import {makeProfileLink} from '#/lib/routes/links'
|
import {makeProfileLink} from '#/lib/routes/links'
|
||||||
import {colors} from '#/lib/styles'
|
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {isAndroid, isNative, isWeb} from '#/platform/detection'
|
import {isAndroid, isNative, isWeb} from '#/platform/detection'
|
||||||
import {precacheProfile} from '#/state/queries/profile'
|
import {
|
||||||
|
type ComposerImage,
|
||||||
|
compressImage,
|
||||||
|
createComposerImage,
|
||||||
|
} from '#/state/gallery'
|
||||||
|
import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache'
|
||||||
|
import {EditImageDialog} from '#/view/com/composer/photos/EditImageDialog'
|
||||||
import {HighPriorityImage} from '#/view/com/util/images/Image'
|
import {HighPriorityImage} from '#/view/com/util/images/Image'
|
||||||
import {tokens, useTheme} from '#/alf'
|
import {atoms as a, tokens, useTheme} from '#/alf'
|
||||||
|
import {useDialogControl} from '#/components/Dialog'
|
||||||
import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper'
|
import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper'
|
||||||
import {
|
import {
|
||||||
Camera_Filled_Stroke2_Corner0_Rounded as CameraFilled,
|
Camera_Filled_Stroke2_Corner0_Rounded as CameraFilledIcon,
|
||||||
Camera_Stroke2_Corner0_Rounded as Camera,
|
Camera_Stroke2_Corner0_Rounded as CameraIcon,
|
||||||
} from '#/components/icons/Camera'
|
} from '#/components/icons/Camera'
|
||||||
import {StreamingLive_Stroke2_Corner0_Rounded as Library} from '#/components/icons/StreamingLive'
|
import {StreamingLive_Stroke2_Corner0_Rounded as LibraryIcon} from '#/components/icons/StreamingLive'
|
||||||
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
|
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
|
||||||
import {Link} from '#/components/Link'
|
import {Link} from '#/components/Link'
|
||||||
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
||||||
import * as Menu from '#/components/Menu'
|
import * as Menu from '#/components/Menu'
|
||||||
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
|
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
|
||||||
import type * as bsky from '#/types/bsky'
|
import type * as bsky from '#/types/bsky'
|
||||||
import {
|
|
||||||
openCamera,
|
|
||||||
openCropper,
|
|
||||||
openPicker,
|
|
||||||
type RNImage,
|
|
||||||
} from '../../../lib/media/picker'
|
|
||||||
|
|
||||||
export type UserAvatarType = 'user' | 'algo' | 'list' | 'labeler'
|
export type UserAvatarType = 'user' | 'algo' | 'list' | 'labeler'
|
||||||
|
|
||||||
@@ -63,7 +65,7 @@ interface UserAvatarProps extends BaseUserAvatarProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface EditableUserAvatarProps extends BaseUserAvatarProps {
|
interface EditableUserAvatarProps extends BaseUserAvatarProps {
|
||||||
onSelectNewAvatar: (img: RNImage | null) => void
|
onSelectNewAvatar: (img: PickerImage | null) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PreviewableUserAvatarProps extends BaseUserAvatarProps {
|
interface PreviewableUserAvatarProps extends BaseUserAvatarProps {
|
||||||
@@ -195,8 +197,8 @@ let UserAvatar = ({
|
|||||||
onLoad,
|
onLoad,
|
||||||
style,
|
style,
|
||||||
}: UserAvatarProps): React.ReactNode => {
|
}: UserAvatarProps): React.ReactNode => {
|
||||||
const pal = usePalette('default')
|
const t = useTheme()
|
||||||
const backgroundColor = pal.colors.backgroundLight
|
const backgroundColor = t.palette.contrast_25
|
||||||
const finalShape = overrideShape ?? (type === 'user' ? 'circle' : 'square')
|
const finalShape = overrideShape ?? (type === 'user' ? 'circle' : 'square')
|
||||||
|
|
||||||
const aviStyle = useMemo(() => {
|
const aviStyle = useMemo(() => {
|
||||||
@@ -221,15 +223,22 @@ let UserAvatar = ({
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<View style={[styles.alertIconContainer, pal.view]}>
|
<View
|
||||||
|
style={[
|
||||||
|
a.absolute,
|
||||||
|
a.right_0,
|
||||||
|
a.bottom_0,
|
||||||
|
a.rounded_full,
|
||||||
|
{backgroundColor: t.palette.white},
|
||||||
|
]}>
|
||||||
<FontAwesomeIcon
|
<FontAwesomeIcon
|
||||||
icon="exclamation-circle"
|
icon="exclamation-circle"
|
||||||
style={styles.alertIcon}
|
style={{color: t.palette.negative_400}}
|
||||||
size={Math.floor(size / 3)}
|
size={Math.floor(size / 3)}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}, [moderation?.alert, size, pal])
|
}, [moderation?.alert, size, t])
|
||||||
|
|
||||||
const containerStyle = useMemo(() => {
|
const containerStyle = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
@@ -288,14 +297,18 @@ let EditableUserAvatar = ({
|
|||||||
onSelectNewAvatar,
|
onSelectNewAvatar,
|
||||||
}: EditableUserAvatarProps): React.ReactNode => {
|
}: EditableUserAvatarProps): React.ReactNode => {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const pal = usePalette('default')
|
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const {requestCameraAccessIfNeeded} = useCameraPermission()
|
const {requestCameraAccessIfNeeded} = useCameraPermission()
|
||||||
const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
|
const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
|
||||||
|
const [rawImage, setRawImage] = useState<ComposerImage | undefined>()
|
||||||
|
const editImageDialogControl = useDialogControl()
|
||||||
|
|
||||||
const sheetWrapper = useSheetWrapper()
|
const sheetWrapper = useSheetWrapper()
|
||||||
|
|
||||||
|
const circular = type !== 'algo' && type !== 'list'
|
||||||
|
|
||||||
const aviStyle = useMemo(() => {
|
const aviStyle = useMemo(() => {
|
||||||
if (type === 'algo' || type === 'list') {
|
if (!circular) {
|
||||||
return {
|
return {
|
||||||
width: size,
|
width: size,
|
||||||
height: size,
|
height: size,
|
||||||
@@ -307,7 +320,7 @@ let EditableUserAvatar = ({
|
|||||||
height: size,
|
height: size,
|
||||||
borderRadius: Math.floor(size / 2),
|
borderRadius: Math.floor(size / 2),
|
||||||
}
|
}
|
||||||
}, [type, size])
|
}, [circular, size])
|
||||||
|
|
||||||
const onOpenCamera = React.useCallback(async () => {
|
const onOpenCamera = React.useCallback(async () => {
|
||||||
if (!(await requestCameraAccessIfNeeded())) {
|
if (!(await requestCameraAccessIfNeeded())) {
|
||||||
@@ -315,9 +328,11 @@ let EditableUserAvatar = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
onSelectNewAvatar(
|
onSelectNewAvatar(
|
||||||
|
await compressIfNeeded(
|
||||||
await openCamera({
|
await openCamera({
|
||||||
aspect: [1, 1],
|
aspect: [1, 1],
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}, [onSelectNewAvatar, requestCameraAccessIfNeeded])
|
}, [onSelectNewAvatar, requestCameraAccessIfNeeded])
|
||||||
|
|
||||||
@@ -337,25 +352,48 @@ let EditableUserAvatar = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const croppedImage = await openCropper({
|
if (isNative) {
|
||||||
|
onSelectNewAvatar(
|
||||||
|
await compressIfNeeded(
|
||||||
|
await openCropper({
|
||||||
imageUri: item.path,
|
imageUri: item.path,
|
||||||
shape: 'circle',
|
shape: circular ? 'circle' : 'rectangle',
|
||||||
aspectRatio: 1,
|
aspectRatio: 1,
|
||||||
})
|
}),
|
||||||
onSelectNewAvatar(croppedImage)
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
setRawImage(await createComposerImage(item))
|
||||||
|
editImageDialogControl.open()
|
||||||
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
// Don't log errors for cancelling selection to sentry on ios or android
|
// Don't log errors for cancelling selection to sentry on ios or android
|
||||||
if (!String(e).toLowerCase().includes('cancel')) {
|
if (!String(e).toLowerCase().includes('cancel')) {
|
||||||
logger.error('Failed to crop banner', {error: e})
|
logger.error('Failed to crop banner', {error: e})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [onSelectNewAvatar, requestPhotoAccessIfNeeded, sheetWrapper])
|
}, [
|
||||||
|
onSelectNewAvatar,
|
||||||
|
requestPhotoAccessIfNeeded,
|
||||||
|
sheetWrapper,
|
||||||
|
editImageDialogControl,
|
||||||
|
circular,
|
||||||
|
])
|
||||||
|
|
||||||
const onRemoveAvatar = React.useCallback(() => {
|
const onRemoveAvatar = React.useCallback(() => {
|
||||||
onSelectNewAvatar(null)
|
onSelectNewAvatar(null)
|
||||||
}, [onSelectNewAvatar])
|
}, [onSelectNewAvatar])
|
||||||
|
|
||||||
|
const onChangeEditImage = useCallback(
|
||||||
|
async (image: ComposerImage) => {
|
||||||
|
const compressed = await compressImage(image)
|
||||||
|
onSelectNewAvatar(compressed)
|
||||||
|
},
|
||||||
|
[onSelectNewAvatar],
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<Menu.Root>
|
<Menu.Root>
|
||||||
<Menu.Trigger label={_(msg`Edit avatar`)}>
|
<Menu.Trigger label={_(msg`Edit avatar`)}>
|
||||||
{({props}) => (
|
{({props}) => (
|
||||||
@@ -370,8 +408,14 @@ let EditableUserAvatar = ({
|
|||||||
) : (
|
) : (
|
||||||
<DefaultAvatar type={type} size={size} />
|
<DefaultAvatar type={type} size={size} />
|
||||||
)}
|
)}
|
||||||
<View style={[styles.editButtonContainer, pal.btn]}>
|
<View
|
||||||
<CameraFilled height={14} width={14} style={t.atoms.text} />
|
style={[
|
||||||
|
styles.editButtonContainer,
|
||||||
|
t.atoms.bg_contrast_25,
|
||||||
|
a.border,
|
||||||
|
t.atoms.border_contrast_low,
|
||||||
|
]}>
|
||||||
|
<CameraFilledIcon height={14} width={14} style={t.atoms.text} />
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
)}
|
)}
|
||||||
@@ -386,7 +430,7 @@ let EditableUserAvatar = ({
|
|||||||
<Menu.ItemText>
|
<Menu.ItemText>
|
||||||
<Trans>Upload from Camera</Trans>
|
<Trans>Upload from Camera</Trans>
|
||||||
</Menu.ItemText>
|
</Menu.ItemText>
|
||||||
<Menu.ItemIcon icon={Camera} />
|
<Menu.ItemIcon icon={CameraIcon} />
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -401,7 +445,7 @@ let EditableUserAvatar = ({
|
|||||||
<Trans>Upload from Files</Trans>
|
<Trans>Upload from Files</Trans>
|
||||||
)}
|
)}
|
||||||
</Menu.ItemText>
|
</Menu.ItemText>
|
||||||
<Menu.ItemIcon icon={Library} />
|
<Menu.ItemIcon icon={LibraryIcon} />
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</Menu.Group>
|
</Menu.Group>
|
||||||
{!!avatar && (
|
{!!avatar && (
|
||||||
@@ -415,13 +459,22 @@ let EditableUserAvatar = ({
|
|||||||
<Menu.ItemText>
|
<Menu.ItemText>
|
||||||
<Trans>Remove Avatar</Trans>
|
<Trans>Remove Avatar</Trans>
|
||||||
</Menu.ItemText>
|
</Menu.ItemText>
|
||||||
<Menu.ItemIcon icon={Trash} />
|
<Menu.ItemIcon icon={TrashIcon} />
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</Menu.Group>
|
</Menu.Group>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Menu.Outer>
|
</Menu.Outer>
|
||||||
</Menu.Root>
|
</Menu.Root>
|
||||||
|
|
||||||
|
<EditImageDialog
|
||||||
|
control={editImageDialogControl}
|
||||||
|
image={rawImage}
|
||||||
|
onChange={onChangeEditImage}
|
||||||
|
aspectRatio={1}
|
||||||
|
circularCrop={circular}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
EditableUserAvatar = memo(EditableUserAvatar)
|
EditableUserAvatar = memo(EditableUserAvatar)
|
||||||
@@ -440,7 +493,7 @@ let PreviewableUserAvatar = ({
|
|||||||
|
|
||||||
const onPress = React.useCallback(() => {
|
const onPress = React.useCallback(() => {
|
||||||
onBeforePress?.()
|
onBeforePress?.()
|
||||||
precacheProfile(queryClient, profile)
|
unstableCacheProfileView(queryClient, profile)
|
||||||
}, [profile, queryClient, onBeforePress])
|
}, [profile, queryClient, onBeforePress])
|
||||||
|
|
||||||
const avatarEl = (
|
const avatarEl = (
|
||||||
@@ -494,15 +547,5 @@ const styles = StyleSheet.create({
|
|||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
backgroundColor: colors.gray5,
|
|
||||||
},
|
|
||||||
alertIconContainer: {
|
|
||||||
position: 'absolute',
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
borderRadius: 100,
|
|
||||||
},
|
|
||||||
alertIcon: {
|
|
||||||
color: colors.red3,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,35 +1,36 @@
|
|||||||
import React from 'react'
|
import {useCallback, useState} from 'react'
|
||||||
import {Pressable, StyleSheet, View} from 'react-native'
|
import {Pressable, StyleSheet, View} from 'react-native'
|
||||||
import {Image} from 'expo-image'
|
import {Image} from 'expo-image'
|
||||||
import {type ModerationUI} from '@atproto/api'
|
import {type ModerationUI} from '@atproto/api'
|
||||||
import {msg, Trans} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
import {usePalette} from '#/lib/hooks/usePalette'
|
|
||||||
import {
|
import {
|
||||||
useCameraPermission,
|
useCameraPermission,
|
||||||
usePhotoLibraryPermission,
|
usePhotoLibraryPermission,
|
||||||
} from '#/lib/hooks/usePermissions'
|
} from '#/lib/hooks/usePermissions'
|
||||||
import {colors} from '#/lib/styles'
|
import {compressIfNeeded} from '#/lib/media/manip'
|
||||||
import {useTheme} from '#/lib/ThemeContext'
|
import {openCamera, openCropper, openPicker} from '#/lib/media/picker'
|
||||||
|
import {type PickerImage} from '#/lib/media/picker.shared'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {isAndroid, isNative} from '#/platform/detection'
|
import {isAndroid, isNative} from '#/platform/detection'
|
||||||
|
import {
|
||||||
|
type ComposerImage,
|
||||||
|
compressImage,
|
||||||
|
createComposerImage,
|
||||||
|
} from '#/state/gallery'
|
||||||
|
import {EditImageDialog} from '#/view/com/composer/photos/EditImageDialog'
|
||||||
import {EventStopper} from '#/view/com/util/EventStopper'
|
import {EventStopper} from '#/view/com/util/EventStopper'
|
||||||
import {tokens, useTheme as useAlfTheme} from '#/alf'
|
import {atoms as a, tokens, useTheme} from '#/alf'
|
||||||
|
import {useDialogControl} from '#/components/Dialog'
|
||||||
import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper'
|
import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper'
|
||||||
import {
|
import {
|
||||||
Camera_Filled_Stroke2_Corner0_Rounded as CameraFilled,
|
Camera_Filled_Stroke2_Corner0_Rounded as CameraFilledIcon,
|
||||||
Camera_Stroke2_Corner0_Rounded as Camera,
|
Camera_Stroke2_Corner0_Rounded as CameraIcon,
|
||||||
} from '#/components/icons/Camera'
|
} from '#/components/icons/Camera'
|
||||||
import {StreamingLive_Stroke2_Corner0_Rounded as Library} from '#/components/icons/StreamingLive'
|
import {StreamingLive_Stroke2_Corner0_Rounded as LibraryIcon} from '#/components/icons/StreamingLive'
|
||||||
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
|
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
|
||||||
import * as Menu from '#/components/Menu'
|
import * as Menu from '#/components/Menu'
|
||||||
import {
|
|
||||||
openCamera,
|
|
||||||
openCropper,
|
|
||||||
openPicker,
|
|
||||||
type RNImage,
|
|
||||||
} from '../../../lib/media/picker'
|
|
||||||
|
|
||||||
export function UserBanner({
|
export function UserBanner({
|
||||||
type,
|
type,
|
||||||
@@ -40,28 +41,30 @@ export function UserBanner({
|
|||||||
type?: 'labeler' | 'default'
|
type?: 'labeler' | 'default'
|
||||||
banner?: string | null
|
banner?: string | null
|
||||||
moderation?: ModerationUI
|
moderation?: ModerationUI
|
||||||
onSelectNewBanner?: (img: RNImage | null) => void
|
onSelectNewBanner?: (img: PickerImage | null) => void
|
||||||
}) {
|
}) {
|
||||||
const pal = usePalette('default')
|
const t = useTheme()
|
||||||
const theme = useTheme()
|
|
||||||
const t = useAlfTheme()
|
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const {requestCameraAccessIfNeeded} = useCameraPermission()
|
const {requestCameraAccessIfNeeded} = useCameraPermission()
|
||||||
const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
|
const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
|
||||||
const sheetWrapper = useSheetWrapper()
|
const sheetWrapper = useSheetWrapper()
|
||||||
|
const [rawImage, setRawImage] = useState<ComposerImage | undefined>()
|
||||||
|
const editImageDialogControl = useDialogControl()
|
||||||
|
|
||||||
const onOpenCamera = React.useCallback(async () => {
|
const onOpenCamera = useCallback(async () => {
|
||||||
if (!(await requestCameraAccessIfNeeded())) {
|
if (!(await requestCameraAccessIfNeeded())) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
onSelectNewBanner?.(
|
onSelectNewBanner?.(
|
||||||
|
await compressIfNeeded(
|
||||||
await openCamera({
|
await openCamera({
|
||||||
aspect: [3, 1],
|
aspect: [3, 1],
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}, [onSelectNewBanner, requestCameraAccessIfNeeded])
|
}, [onSelectNewBanner, requestCameraAccessIfNeeded])
|
||||||
|
|
||||||
const onOpenLibrary = React.useCallback(async () => {
|
const onOpenLibrary = useCallback(async () => {
|
||||||
if (!(await requestPhotoAccessIfNeeded())) {
|
if (!(await requestPhotoAccessIfNeeded())) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -71,25 +74,46 @@ export function UserBanner({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (isNative) {
|
||||||
onSelectNewBanner?.(
|
onSelectNewBanner?.(
|
||||||
|
await compressIfNeeded(
|
||||||
await openCropper({
|
await openCropper({
|
||||||
imageUri: items[0].path,
|
imageUri: items[0].path,
|
||||||
aspectRatio: 3 / 1,
|
aspectRatio: 3 / 1,
|
||||||
}),
|
}),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
setRawImage(await createComposerImage(items[0]))
|
||||||
|
editImageDialogControl.open()
|
||||||
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (!String(e).includes('Canceled')) {
|
if (!String(e).includes('Canceled')) {
|
||||||
logger.error('Failed to crop banner', {error: e})
|
logger.error('Failed to crop banner', {error: e})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [onSelectNewBanner, requestPhotoAccessIfNeeded, sheetWrapper])
|
}, [
|
||||||
|
onSelectNewBanner,
|
||||||
|
requestPhotoAccessIfNeeded,
|
||||||
|
sheetWrapper,
|
||||||
|
editImageDialogControl,
|
||||||
|
])
|
||||||
|
|
||||||
const onRemoveBanner = React.useCallback(() => {
|
const onRemoveBanner = useCallback(() => {
|
||||||
onSelectNewBanner?.(null)
|
onSelectNewBanner?.(null)
|
||||||
}, [onSelectNewBanner])
|
}, [onSelectNewBanner])
|
||||||
|
|
||||||
|
const onChangeEditImage = useCallback(
|
||||||
|
async (image: ComposerImage) => {
|
||||||
|
const compressed = await compressImage(image)
|
||||||
|
onSelectNewBanner?.(compressed)
|
||||||
|
},
|
||||||
|
[onSelectNewBanner],
|
||||||
|
)
|
||||||
|
|
||||||
// setUserBanner is only passed as prop on the EditProfile component
|
// setUserBanner is only passed as prop on the EditProfile component
|
||||||
return onSelectNewBanner ? (
|
return onSelectNewBanner ? (
|
||||||
|
<>
|
||||||
<EventStopper onKeyDown={true}>
|
<EventStopper onKeyDown={true}>
|
||||||
<Menu.Root>
|
<Menu.Root>
|
||||||
<Menu.Trigger label={_(msg`Edit avatar`)}>
|
<Menu.Trigger label={_(msg`Edit avatar`)}>
|
||||||
@@ -109,8 +133,18 @@ export function UserBanner({
|
|||||||
style={[styles.bannerImage, t.atoms.bg_contrast_25]}
|
style={[styles.bannerImage, t.atoms.bg_contrast_25]}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<View style={[styles.editButtonContainer, pal.btn]}>
|
<View
|
||||||
<CameraFilled height={14} width={14} style={t.atoms.text} />
|
style={[
|
||||||
|
styles.editButtonContainer,
|
||||||
|
t.atoms.bg_contrast_25,
|
||||||
|
a.border,
|
||||||
|
t.atoms.border_contrast_low,
|
||||||
|
]}>
|
||||||
|
<CameraFilledIcon
|
||||||
|
height={14}
|
||||||
|
width={14}
|
||||||
|
style={t.atoms.text}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
)}
|
)}
|
||||||
@@ -125,7 +159,7 @@ export function UserBanner({
|
|||||||
<Menu.ItemText>
|
<Menu.ItemText>
|
||||||
<Trans>Upload from Camera</Trans>
|
<Trans>Upload from Camera</Trans>
|
||||||
</Menu.ItemText>
|
</Menu.ItemText>
|
||||||
<Menu.ItemIcon icon={Camera} />
|
<Menu.ItemIcon icon={CameraIcon} />
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -140,7 +174,7 @@ export function UserBanner({
|
|||||||
<Trans>Upload from Files</Trans>
|
<Trans>Upload from Files</Trans>
|
||||||
)}
|
)}
|
||||||
</Menu.ItemText>
|
</Menu.ItemText>
|
||||||
<Menu.ItemIcon icon={Library} />
|
<Menu.ItemIcon icon={LibraryIcon} />
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</Menu.Group>
|
</Menu.Group>
|
||||||
{!!banner && (
|
{!!banner && (
|
||||||
@@ -154,7 +188,7 @@ export function UserBanner({
|
|||||||
<Menu.ItemText>
|
<Menu.ItemText>
|
||||||
<Trans>Remove Banner</Trans>
|
<Trans>Remove Banner</Trans>
|
||||||
</Menu.ItemText>
|
</Menu.ItemText>
|
||||||
<Menu.ItemIcon icon={Trash} />
|
<Menu.ItemIcon icon={TrashIcon} />
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</Menu.Group>
|
</Menu.Group>
|
||||||
</>
|
</>
|
||||||
@@ -162,14 +196,19 @@ export function UserBanner({
|
|||||||
</Menu.Outer>
|
</Menu.Outer>
|
||||||
</Menu.Root>
|
</Menu.Root>
|
||||||
</EventStopper>
|
</EventStopper>
|
||||||
|
|
||||||
|
<EditImageDialog
|
||||||
|
control={editImageDialogControl}
|
||||||
|
image={rawImage}
|
||||||
|
onChange={onChangeEditImage}
|
||||||
|
aspectRatio={3}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
) : banner &&
|
) : banner &&
|
||||||
!((moderation?.blur && isAndroid) /* android crashes with blur */) ? (
|
!((moderation?.blur && isAndroid) /* android crashes with blur */) ? (
|
||||||
<Image
|
<Image
|
||||||
testID="userBannerImage"
|
testID="userBannerImage"
|
||||||
style={[
|
style={[styles.bannerImage, t.atoms.bg_contrast_25]}
|
||||||
styles.bannerImage,
|
|
||||||
{backgroundColor: theme.palette.default.backgroundLight},
|
|
||||||
]}
|
|
||||||
contentFit="cover"
|
contentFit="cover"
|
||||||
source={{uri: banner}}
|
source={{uri: banner}}
|
||||||
blurRadius={moderation?.blur ? 100 : 0}
|
blurRadius={moderation?.blur ? 100 : 0}
|
||||||
@@ -197,7 +236,6 @@ const styles = StyleSheet.create({
|
|||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
backgroundColor: colors.gray5,
|
|
||||||
},
|
},
|
||||||
bannerImage: {
|
bannerImage: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, {memo, useMemo, useState} from 'react'
|
import {memo, useMemo, useState} from 'react'
|
||||||
import {
|
import {
|
||||||
Pressable,
|
Pressable,
|
||||||
type PressableProps,
|
type PressableProps,
|
||||||
@@ -6,16 +6,17 @@ import {
|
|||||||
type ViewStyle,
|
type ViewStyle,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import {
|
import {
|
||||||
AppBskyFeedDefs,
|
type AppBskyFeedDefs,
|
||||||
AppBskyFeedPost,
|
type AppBskyFeedPost,
|
||||||
AppBskyFeedThreadgate,
|
type AppBskyFeedThreadgate,
|
||||||
RichText as RichTextAPI,
|
type RichText as RichTextAPI,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
import {msg} from '@lingui/macro'
|
import {msg} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
import type React from 'react'
|
||||||
|
|
||||||
import {useTheme} from '#/lib/ThemeContext'
|
import {useTheme} from '#/lib/ThemeContext'
|
||||||
import {Shadow} from '#/state/cache/post-shadow'
|
import {type Shadow} from '#/state/cache/post-shadow'
|
||||||
import {atoms as a, useTheme as useAlf} from '#/alf'
|
import {atoms as a, useTheme as useAlf} from '#/alf'
|
||||||
import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid'
|
import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid'
|
||||||
import {useMenuControl} from '#/components/Menu'
|
import {useMenuControl} from '#/components/Menu'
|
||||||
@@ -34,6 +35,7 @@ let PostDropdownBtn = ({
|
|||||||
size,
|
size,
|
||||||
timestamp,
|
timestamp,
|
||||||
threadgateRecord,
|
threadgateRecord,
|
||||||
|
onShowLess,
|
||||||
}: {
|
}: {
|
||||||
testID: string
|
testID: string
|
||||||
post: Shadow<AppBskyFeedDefs.PostView>
|
post: Shadow<AppBskyFeedDefs.PostView>
|
||||||
@@ -45,6 +47,7 @@ let PostDropdownBtn = ({
|
|||||||
size?: 'lg' | 'md' | 'sm'
|
size?: 'lg' | 'md' | 'sm'
|
||||||
timestamp: string
|
timestamp: string
|
||||||
threadgateRecord?: AppBskyFeedThreadgate.Record
|
threadgateRecord?: AppBskyFeedThreadgate.Record
|
||||||
|
onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void
|
||||||
}): React.ReactNode => {
|
}): React.ReactNode => {
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const alf = useAlf()
|
const alf = useAlf()
|
||||||
@@ -100,6 +103,7 @@ let PostDropdownBtn = ({
|
|||||||
richText={richText}
|
richText={richText}
|
||||||
timestamp={timestamp}
|
timestamp={timestamp}
|
||||||
threadgateRecord={threadgateRecord}
|
threadgateRecord={threadgateRecord}
|
||||||
|
onShowLess={onShowLess}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Menu.Root>
|
</Menu.Root>
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import {msg, Trans} from '@lingui/macro'
|
|||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {useNavigation} from '@react-navigation/native'
|
import {useNavigation} from '@react-navigation/native'
|
||||||
|
|
||||||
|
import {IS_INTERNAL} from '#/lib/app-info'
|
||||||
|
import {DISCOVER_DEBUG_DIDS} from '#/lib/constants'
|
||||||
import {useOpenLink} from '#/lib/hooks/useOpenLink'
|
import {useOpenLink} from '#/lib/hooks/useOpenLink'
|
||||||
import {getCurrentRoute} from '#/lib/routes/helpers'
|
import {getCurrentRoute} from '#/lib/routes/helpers'
|
||||||
import {makeProfileLink} from '#/lib/routes/links'
|
import {makeProfileLink} from '#/lib/routes/links'
|
||||||
@@ -60,6 +62,7 @@ import {
|
|||||||
} from '#/components/dialogs/PostInteractionSettingsDialog'
|
} from '#/components/dialogs/PostInteractionSettingsDialog'
|
||||||
import {SendViaChatDialog} from '#/components/dms/dialogs/ShareViaChatDialog'
|
import {SendViaChatDialog} from '#/components/dms/dialogs/ShareViaChatDialog'
|
||||||
import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox'
|
import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox'
|
||||||
|
import {Atom_Stroke2_Corner0_Rounded as AtomIcon} from '#/components/icons/Atom'
|
||||||
import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble'
|
import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble'
|
||||||
import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard'
|
import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard'
|
||||||
import {CodeBrackets_Stroke2_Corner0_Rounded as CodeBrackets} from '#/components/icons/CodeBrackets'
|
import {CodeBrackets_Stroke2_Corner0_Rounded as CodeBrackets} from '#/components/icons/CodeBrackets'
|
||||||
@@ -98,6 +101,7 @@ let PostDropdownMenuItems = ({
|
|||||||
richText,
|
richText,
|
||||||
timestamp,
|
timestamp,
|
||||||
threadgateRecord,
|
threadgateRecord,
|
||||||
|
onShowLess,
|
||||||
}: {
|
}: {
|
||||||
testID: string
|
testID: string
|
||||||
post: Shadow<AppBskyFeedDefs.PostView>
|
post: Shadow<AppBskyFeedDefs.PostView>
|
||||||
@@ -109,6 +113,7 @@ let PostDropdownMenuItems = ({
|
|||||||
size?: 'lg' | 'md' | 'sm'
|
size?: 'lg' | 'md' | 'sm'
|
||||||
timestamp: string
|
timestamp: string
|
||||||
threadgateRecord?: AppBskyFeedThreadgate.Record
|
threadgateRecord?: AppBskyFeedThreadgate.Record
|
||||||
|
onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void
|
||||||
}): React.ReactNode => {
|
}): React.ReactNode => {
|
||||||
const {hasSession, currentAccount} = useSession()
|
const {hasSession, currentAccount} = useSession()
|
||||||
const {gtMobile} = useBreakpoints()
|
const {gtMobile} = useBreakpoints()
|
||||||
@@ -300,8 +305,15 @@ let PostDropdownMenuItems = ({
|
|||||||
item: postUri,
|
item: postUri,
|
||||||
feedContext: postFeedContext,
|
feedContext: postFeedContext,
|
||||||
})
|
})
|
||||||
|
if (onShowLess) {
|
||||||
|
onShowLess({
|
||||||
|
item: postUri,
|
||||||
|
feedContext: postFeedContext,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
Toast.show(_(msg({message: 'Feedback sent!', context: 'toast'})))
|
Toast.show(_(msg({message: 'Feedback sent!', context: 'toast'})))
|
||||||
}, [feedFeedback, postUri, postFeedContext, _])
|
}
|
||||||
|
}, [feedFeedback, postUri, postFeedContext, _, onShowLess])
|
||||||
|
|
||||||
const onSelectChatToShareTo = React.useCallback(
|
const onSelectChatToShareTo = React.useCallback(
|
||||||
(conversation: string) => {
|
(conversation: string) => {
|
||||||
@@ -430,6 +442,13 @@ let PostDropdownMenuItems = ({
|
|||||||
shareText(postAuthor.did)
|
shareText(postAuthor.did)
|
||||||
}, [postAuthor.did])
|
}, [postAuthor.did])
|
||||||
|
|
||||||
|
const onReportMisclassification = useCallback(() => {
|
||||||
|
const url = `https://docs.google.com/forms/d/e/1FAIpQLSd0QPqhNFksDQf1YyOos7r1ofCLvmrKAH1lU042TaS3GAZaWQ/viewform?entry.1756031717=${toShareUrl(
|
||||||
|
href,
|
||||||
|
)}`
|
||||||
|
openLink(url)
|
||||||
|
}, [href, openLink])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Menu.Outer>
|
<Menu.Outer>
|
||||||
@@ -539,6 +558,19 @@ let PostDropdownMenuItems = ({
|
|||||||
<Menu.ItemText>{_(msg`Show less like this`)}</Menu.ItemText>
|
<Menu.ItemText>{_(msg`Show less like this`)}</Menu.ItemText>
|
||||||
<Menu.ItemIcon icon={EmojiSad} position="right" />
|
<Menu.ItemIcon icon={EmojiSad} position="right" />
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
|
{IS_INTERNAL &&
|
||||||
|
DISCOVER_DEBUG_DIDS[currentAccount?.did ?? ''] && (
|
||||||
|
<Menu.Item
|
||||||
|
testID="postDropdownReportMisclassificationBtn"
|
||||||
|
label={_(msg`Report topic misclassification`)}
|
||||||
|
onPress={onReportMisclassification}>
|
||||||
|
<Menu.ItemText>
|
||||||
|
{_(msg`Report topic misclassification`)}
|
||||||
|
</Menu.ItemText>
|
||||||
|
<Menu.ItemIcon icon={AtomIcon} position="right" />
|
||||||
|
</Menu.Item>
|
||||||
|
)}
|
||||||
</Menu.Group>
|
</Menu.Group>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ import {
|
|||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import * as Clipboard from 'expo-clipboard'
|
import * as Clipboard from 'expo-clipboard'
|
||||||
import {
|
import {
|
||||||
AppBskyFeedDefs,
|
type AppBskyFeedDefs,
|
||||||
AppBskyFeedPost,
|
type AppBskyFeedPost,
|
||||||
AppBskyFeedThreadgate,
|
type AppBskyFeedThreadgate,
|
||||||
AtUri,
|
AtUri,
|
||||||
RichText as RichTextAPI,
|
type RichText as RichTextAPI,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
import {msg, plural} from '@lingui/macro'
|
import {msg, plural} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
@@ -26,7 +26,7 @@ import {makeProfileLink} from '#/lib/routes/links'
|
|||||||
import {shareUrl} from '#/lib/sharing'
|
import {shareUrl} from '#/lib/sharing'
|
||||||
import {useGate} from '#/lib/statsig/statsig'
|
import {useGate} from '#/lib/statsig/statsig'
|
||||||
import {toShareUrl} from '#/lib/strings/url-helpers'
|
import {toShareUrl} from '#/lib/strings/url-helpers'
|
||||||
import {Shadow} from '#/state/cache/types'
|
import {type Shadow} from '#/state/cache/types'
|
||||||
import {useFeedFeedbackContext} from '#/state/feed-feedback'
|
import {useFeedFeedbackContext} from '#/state/feed-feedback'
|
||||||
import {
|
import {
|
||||||
usePostLikeMutationQueue,
|
usePostLikeMutationQueue,
|
||||||
@@ -60,6 +60,7 @@ let PostCtrls = ({
|
|||||||
onPostReply,
|
onPostReply,
|
||||||
logContext,
|
logContext,
|
||||||
threadgateRecord,
|
threadgateRecord,
|
||||||
|
onShowLess,
|
||||||
}: {
|
}: {
|
||||||
big?: boolean
|
big?: boolean
|
||||||
post: Shadow<AppBskyFeedDefs.PostView>
|
post: Shadow<AppBskyFeedDefs.PostView>
|
||||||
@@ -71,6 +72,7 @@ let PostCtrls = ({
|
|||||||
onPostReply?: (postUri: string | undefined) => void
|
onPostReply?: (postUri: string | undefined) => void
|
||||||
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
|
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
|
||||||
threadgateRecord?: AppBskyFeedThreadgate.Record
|
threadgateRecord?: AppBskyFeedThreadgate.Record
|
||||||
|
onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void
|
||||||
}): React.ReactNode => {
|
}): React.ReactNode => {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {_, i18n} = useLingui()
|
const {_, i18n} = useLingui()
|
||||||
@@ -378,6 +380,7 @@ let PostCtrls = ({
|
|||||||
hitSlop={POST_CTRL_HITSLOP}
|
hitSlop={POST_CTRL_HITSLOP}
|
||||||
timestamp={post.indexedAt}
|
timestamp={post.indexedAt}
|
||||||
threadgateRecord={threadgateRecord}
|
threadgateRecord={threadgateRecord}
|
||||||
|
onShowLess={onShowLess}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
{isDiscoverDebugUser && feedContext && (
|
{isDiscoverDebugUser && feedContext && (
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ let NavSignupCard = ({}: {}): React.ReactNode => {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={[a.mt_md, a.w_full, {height: 32}]}>
|
<View style={[a.mt_md, a.w_full, {height: 32}]}>
|
||||||
<AppLanguageDropdown style={{marginTop: 0}} />
|
<AppLanguageDropdown />
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ export function DesktopRightNav({routeName}: {routeName: string}) {
|
|||||||
|
|
||||||
{!hasSession && leftNavMinimal && (
|
{!hasSession && leftNavMinimal && (
|
||||||
<View style={[a.w_full, {height: 32}]}>
|
<View style={[a.w_full, {height: 32}]}>
|
||||||
<AppLanguageDropdown style={{marginTop: 0}} />
|
<AppLanguageDropdown />
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -11242,10 +11242,10 @@ expo-haptics@~14.1.4:
|
|||||||
resolved "https://registry.yarnpkg.com/expo-haptics/-/expo-haptics-14.1.4.tgz#442f48b1bdf83484d4fcadc653445aaae6049b70"
|
resolved "https://registry.yarnpkg.com/expo-haptics/-/expo-haptics-14.1.4.tgz#442f48b1bdf83484d4fcadc653445aaae6049b70"
|
||||||
integrity sha512-QZdE3NMX74rTuIl82I+n12XGwpDWKb8zfs5EpwsnGi/D/n7O2Jd4tO5ivH+muEG/OCJOMq5aeaVDqqaQOhTkcA==
|
integrity sha512-QZdE3NMX74rTuIl82I+n12XGwpDWKb8zfs5EpwsnGi/D/n7O2Jd4tO5ivH+muEG/OCJOMq5aeaVDqqaQOhTkcA==
|
||||||
|
|
||||||
expo-image-crop-tool@^0.1.6:
|
expo-image-crop-tool@^0.1.8:
|
||||||
version "0.1.6"
|
version "0.1.8"
|
||||||
resolved "https://registry.yarnpkg.com/expo-image-crop-tool/-/expo-image-crop-tool-0.1.6.tgz#fd65ea2a143fef9a45a0d0cd2b967ec1f63a0bad"
|
resolved "https://registry.yarnpkg.com/expo-image-crop-tool/-/expo-image-crop-tool-0.1.8.tgz#3e9f34825cf5d7dad1ef2786615571b078ece4e7"
|
||||||
integrity sha512-I0KVaLur+4EBMf6jtp7uVQNS26DgyGWERn2sng6iKc1I2QygILU8jj5/sMqzCB7fGPpzGn7e0S9u6EmR2YJHfg==
|
integrity sha512-UlS1zV7JewUzuZzVT9aA0vFD1+dt+pU60ILgt3ntQl4G9SeDJ9bB/+ylz9dzn6BjZecUQkGJmbCQ3H7jGZeZMA==
|
||||||
|
|
||||||
expo-image-loader@~5.1.0:
|
expo-image-loader@~5.1.0:
|
||||||
version "5.1.0"
|
version "5.1.0"
|
||||||
@@ -14273,11 +14273,6 @@ lodash.isequal@^4.5.0:
|
|||||||
resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0"
|
resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0"
|
||||||
integrity sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==
|
integrity sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==
|
||||||
|
|
||||||
lodash.isobject@^3.0.2:
|
|
||||||
version "3.0.2"
|
|
||||||
resolved "https://registry.yarnpkg.com/lodash.isobject/-/lodash.isobject-3.0.2.tgz#3c8fb8d5b5bf4bf90ae06e14f2a530a4ed935e1d"
|
|
||||||
integrity sha512-3/Qptq2vr7WeJbB4KHUSKlq8Pl7ASXi3UG6CMbBm8WRtXi8+GHm7mKaU3urfpSEzWe2wCIChs6/sdocUsTKJiA==
|
|
||||||
|
|
||||||
lodash.memoize@^4.1.2:
|
lodash.memoize@^4.1.2:
|
||||||
version "4.1.2"
|
version "4.1.2"
|
||||||
resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe"
|
resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe"
|
||||||
@@ -16823,14 +16818,6 @@ react-native-pager-view@6.7.1:
|
|||||||
resolved "https://registry.yarnpkg.com/react-native-pager-view/-/react-native-pager-view-6.7.1.tgz#60d52dedbcc92ee7037a13287ebeed5f74e49df7"
|
resolved "https://registry.yarnpkg.com/react-native-pager-view/-/react-native-pager-view-6.7.1.tgz#60d52dedbcc92ee7037a13287ebeed5f74e49df7"
|
||||||
integrity sha512-cBSr6xw4g5N7Kd3VGWcf+kmaH7iBWb0DXAf2bVo3bXkzBcBbTOmYSvc0LVLHhUPW8nEq5WjT9LCIYAzgF++EXw==
|
integrity sha512-cBSr6xw4g5N7Kd3VGWcf+kmaH7iBWb0DXAf2bVo3bXkzBcBbTOmYSvc0LVLHhUPW8nEq5WjT9LCIYAzgF++EXw==
|
||||||
|
|
||||||
react-native-picker-select@^9.3.1:
|
|
||||||
version "9.3.1"
|
|
||||||
resolved "https://registry.yarnpkg.com/react-native-picker-select/-/react-native-picker-select-9.3.1.tgz#8a2ad51c286fcd54ef60fb883842ec1895c15003"
|
|
||||||
integrity sha512-o621HcsKJfJkpYeP/PZQiZTKbf8W7FT08niLFL0v1pGkIQyak5IfzfinV2t+/l1vktGwAH2Tt29LrP/Hc5fk3A==
|
|
||||||
dependencies:
|
|
||||||
lodash.isequal "^4.5.0"
|
|
||||||
lodash.isobject "^3.0.2"
|
|
||||||
|
|
||||||
react-native-progress@bluesky-social/react-native-progress:
|
react-native-progress@bluesky-social/react-native-progress:
|
||||||
version "5.0.0"
|
version "5.0.0"
|
||||||
resolved "https://codeload.github.com/bluesky-social/react-native-progress/tar.gz/5a372f4f2ce5feb26f4f47b6a4d187ab9b923ab4"
|
resolved "https://codeload.github.com/bluesky-social/react-native-progress/tar.gz/5a372f4f2ce5feb26f4f47b6a4d187ab9b923ab4"
|
||||||
|
|||||||
Reference in New Issue
Block a user