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:
|
||||
|
||||
|
||||
-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 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<
|
||||
BottomSheetViewProps,
|
||||
|
||||
+1
-2
@@ -142,7 +142,7 @@
|
||||
"expo-font": "~13.3.0",
|
||||
"expo-haptics": "~14.1.4",
|
||||
"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-picker": "~16.1.4",
|
||||
"expo-linear-gradient": "~14.1.4",
|
||||
@@ -193,7 +193,6 @@
|
||||
"react-native-keyboard-controller": "^1.17.1",
|
||||
"react-native-mmkv": "^3.2.0",
|
||||
"react-native-pager-view": "6.7.1",
|
||||
"react-native-picker-select": "^9.3.1",
|
||||
"react-native-progress": "bluesky-social/react-native-progress",
|
||||
"react-native-qrcode-styled": "^0.3.3",
|
||||
"react-native-reanimated": "~3.17.5",
|
||||
|
||||
@@ -204,6 +204,9 @@ export const atoms = {
|
||||
flex_grow: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
flex_grow_0: {
|
||||
flexGrow: 0,
|
||||
},
|
||||
flex_shrink: {
|
||||
flexShrink: 1,
|
||||
},
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import RNPickerSelect, {PickerSelectProps} from 'react-native-picker-select'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
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 {atoms as a, platform, useTheme} from '#/alf'
|
||||
import * as Select from '#/components/Select'
|
||||
import {Button} from './Button'
|
||||
|
||||
export function AppLanguageDropdown(_props: ViewStyleProp) {
|
||||
export function AppLanguageDropdown() {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const langPrefs = useLanguagePrefs()
|
||||
@@ -19,7 +21,7 @@ export function AppLanguageDropdown(_props: ViewStyleProp) {
|
||||
const sanitizedLang = sanitizeAppLanguageSetting(langPrefs.appLanguage)
|
||||
|
||||
const onChangeAppLanguage = React.useCallback(
|
||||
(value: Parameters<PickerSelectProps['onValueChange']>[0]) => {
|
||||
(value: string) => {
|
||||
if (!value) return
|
||||
if (sanitizedLang !== value) {
|
||||
setLangPrefs.setAppLanguage(sanitizeAppLanguageSetting(value))
|
||||
@@ -32,43 +34,48 @@ export function AppLanguageDropdown(_props: ViewStyleProp) {
|
||||
)
|
||||
|
||||
return (
|
||||
<View style={a.relative}>
|
||||
<RNPickerSelect
|
||||
darkTheme={t.scheme === 'dark'}
|
||||
placeholder={{}}
|
||||
value={sanitizedLang}
|
||||
onValueChange={onChangeAppLanguage}
|
||||
items={APP_LANGUAGES.filter(l => Boolean(l.code2)).map(l => ({
|
||||
<Select.Root
|
||||
value={sanitizeAppLanguageSetting(langPrefs.appLanguage)}
|
||||
onValueChange={onChangeAppLanguage}>
|
||||
<Select.Trigger label={_(msg`Change app language`)}>
|
||||
{({props}) => (
|
||||
<Button
|
||||
{...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,
|
||||
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,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<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>
|
||||
</Select.Root>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -8,7 +15,7 @@ import {
|
||||
} from '#/components/Dialog/types'
|
||||
import {BottomSheetSnapPoint} from '../../../modules/bottom-sheet/src/BottomSheet.types'
|
||||
|
||||
export const Context = React.createContext<DialogContextProps>({
|
||||
export const Context = createContext<DialogContextProps>({
|
||||
close: () => {},
|
||||
isNativeDialog: false,
|
||||
nativeSnapPoint: BottomSheetSnapPoint.Hidden,
|
||||
@@ -18,18 +25,18 @@ export const Context = React.createContext<DialogContextProps>({
|
||||
})
|
||||
|
||||
export function useDialogContext() {
|
||||
return React.useContext(Context)
|
||||
return useContext(Context)
|
||||
}
|
||||
|
||||
export function useDialogControl(): DialogOuterProps['control'] {
|
||||
const id = React.useId()
|
||||
const control = React.useRef<DialogControlRefProps>({
|
||||
const id = useId()
|
||||
const control = useRef<DialogControlRefProps>({
|
||||
open: () => {},
|
||||
close: () => {},
|
||||
})
|
||||
const {activeDialogs} = useDialogStateContext()
|
||||
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
activeDialogs.current.set(id, control)
|
||||
return () => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -37,7 +44,7 @@ export function useDialogControl(): DialogOuterProps['control'] {
|
||||
}
|
||||
}, [id, activeDialogs])
|
||||
|
||||
return React.useMemo<DialogOuterProps['control']>(
|
||||
return useMemo<DialogOuterProps['control']>(
|
||||
() => ({
|
||||
id,
|
||||
ref: control,
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import React from 'react'
|
||||
import {StyleProp, TextStyle, View, ViewStyle} from 'react-native'
|
||||
import {
|
||||
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 {Text} from '#/components/Typography'
|
||||
@@ -9,15 +15,18 @@ export function Header({
|
||||
renderRight,
|
||||
children,
|
||||
style,
|
||||
onLayout,
|
||||
}: {
|
||||
renderLeft?: () => React.ReactNode
|
||||
renderRight?: () => React.ReactNode
|
||||
children?: React.ReactNode
|
||||
style?: StyleProp<ViewStyle>
|
||||
onLayout?: (event: LayoutChangeEvent) => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<View
|
||||
onLayout={onLayout}
|
||||
style={[
|
||||
a.relative,
|
||||
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
|
||||
|
||||
@@ -9,32 +19,32 @@ type ContextType = {
|
||||
}
|
||||
|
||||
type ComponentMap = {
|
||||
[id: string]: Component
|
||||
[id: string]: Component | null
|
||||
}
|
||||
|
||||
export function createPortalGroup() {
|
||||
const Context = React.createContext<ContextType>({
|
||||
const Context = createContext<ContextType>({
|
||||
outlet: null,
|
||||
append: () => {},
|
||||
remove: () => {},
|
||||
})
|
||||
|
||||
function Provider(props: React.PropsWithChildren<{}>) {
|
||||
const map = React.useRef<ComponentMap>({})
|
||||
const [outlet, setOutlet] = React.useState<ContextType['outlet']>(null)
|
||||
const map = useRef<ComponentMap>({})
|
||||
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
|
||||
map.current[id] = <React.Fragment key={id}>{component}</React.Fragment>
|
||||
map.current[id] = <Fragment key={id}>{component}</Fragment>
|
||||
setOutlet(<>{Object.values(map.current)}</>)
|
||||
}, [])
|
||||
|
||||
const remove = React.useCallback<ContextType['remove']>(id => {
|
||||
delete map.current[id]
|
||||
const remove = useCallback<ContextType['remove']>(id => {
|
||||
map.current[id] = null
|
||||
setOutlet(<>{Object.values(map.current)}</>)
|
||||
}, [])
|
||||
|
||||
const contextValue = React.useMemo(
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
outlet,
|
||||
append,
|
||||
@@ -49,14 +59,14 @@ export function createPortalGroup() {
|
||||
}
|
||||
|
||||
function Outlet() {
|
||||
const ctx = React.useContext(Context)
|
||||
const ctx = useContext(Context)
|
||||
return ctx.outlet
|
||||
}
|
||||
|
||||
function Portal({children}: React.PropsWithChildren<{}>) {
|
||||
const {append, remove} = React.useContext(Context)
|
||||
const id = React.useId()
|
||||
React.useEffect(() => {
|
||||
const {append, remove} = useContext(Context)
|
||||
const id = useId()
|
||||
useEffect(() => {
|
||||
append(id, children as Component)
|
||||
return () => remove(id)
|
||||
}, [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(
|
||||
img: PickerImage,
|
||||
maxSize: number = 1000000,
|
||||
maxSize: number = POST_IMG_MAX.size,
|
||||
): Promise<PickerImage> {
|
||||
if (img.size < maxSize) {
|
||||
return img
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/// <reference lib="dom" />
|
||||
|
||||
import {type PickerImage} from './picker.shared'
|
||||
import {type Dimensions} from './types'
|
||||
import {blobToDataUri, getDataUriSize} from './util'
|
||||
|
||||
@@ -1,25 +1,21 @@
|
||||
import {
|
||||
type ImagePickerOptions,
|
||||
launchImageLibraryAsync,
|
||||
MediaTypeOptions,
|
||||
} from 'expo-image-picker'
|
||||
import {t} from '@lingui/macro'
|
||||
|
||||
import {type ImageMeta} from '#/state/gallery'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {getDataUriSize} from './util'
|
||||
|
||||
export type PickerImage = {
|
||||
mime: string
|
||||
height: number
|
||||
width: number
|
||||
path: string
|
||||
export type PickerImage = ImageMeta & {
|
||||
size: number
|
||||
}
|
||||
|
||||
export async function openPicker(opts?: ImagePickerOptions) {
|
||||
const response = await launchImageLibraryAsync({
|
||||
exif: false,
|
||||
mediaTypes: MediaTypeOptions.Images,
|
||||
mediaTypes: ['images'],
|
||||
quality: 1,
|
||||
...opts,
|
||||
legacy: true,
|
||||
|
||||
@@ -1,35 +1,18 @@
|
||||
/// <reference lib="dom" />
|
||||
|
||||
import {type OpenCropperOptions} from 'expo-image-crop-tool'
|
||||
|
||||
import {unstable__openModal} from '#/state/modals'
|
||||
import {type PickerImage} from './picker.shared'
|
||||
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> {
|
||||
// const mediaType = opts.mediaType || 'photo' TODO
|
||||
throw new Error('TODO')
|
||||
throw new Error('openCamera is not supported on web')
|
||||
}
|
||||
|
||||
export async function openCropper(
|
||||
opts: OpenCropperOptions,
|
||||
_opts: OpenCropperOptions,
|
||||
): Promise<PickerImage> {
|
||||
// TODO handle more opts
|
||||
return new Promise((resolve, reject) => {
|
||||
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'))
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
throw new Error(
|
||||
'openCropper is not supported on web. Use EditImageDialog instead.',
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,12 +5,11 @@ import {msg, Plural, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
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 {useWarnMaxGraphemeCount} from '#/lib/strings/helpers'
|
||||
import {logger} from '#/logger'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {type ImageMeta} from '#/state/gallery'
|
||||
import {useProfileUpdateMutation} from '#/state/queries/profile'
|
||||
import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
|
||||
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 {atoms as a, useTheme} from '#/alf'
|
||||
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 TextField from '#/components/forms/TextField'
|
||||
import {InlineLinkText} from '#/components/Link'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {useSimpleVerificationState} from '#/components/verification'
|
||||
|
||||
@@ -127,10 +127,10 @@ function DialogInner({
|
||||
profile.avatar,
|
||||
)
|
||||
const [newUserBanner, setNewUserBanner] = useState<
|
||||
PickerImage | undefined | null
|
||||
ImageMeta | undefined | null
|
||||
>()
|
||||
const [newUserAvatar, setNewUserAvatar] = useState<
|
||||
PickerImage | undefined | null
|
||||
ImageMeta | undefined | null
|
||||
>()
|
||||
|
||||
const dirty =
|
||||
@@ -144,7 +144,7 @@ function DialogInner({
|
||||
}, [dirty, setDirty])
|
||||
|
||||
const onSelectNewAvatar = useCallback(
|
||||
async (img: PickerImage | null) => {
|
||||
(img: ImageMeta | null) => {
|
||||
setImageError('')
|
||||
if (img === null) {
|
||||
setNewUserAvatar(null)
|
||||
@@ -152,9 +152,8 @@ function DialogInner({
|
||||
return
|
||||
}
|
||||
try {
|
||||
const finalImg = await compressIfNeeded(img, 1000000)
|
||||
setNewUserAvatar(finalImg)
|
||||
setUserAvatar(finalImg.path)
|
||||
setNewUserAvatar(img)
|
||||
setUserAvatar(img.path)
|
||||
} catch (e: any) {
|
||||
setImageError(cleanError(e))
|
||||
}
|
||||
@@ -163,7 +162,7 @@ function DialogInner({
|
||||
)
|
||||
|
||||
const onSelectNewBanner = useCallback(
|
||||
async (img: PickerImage | null) => {
|
||||
(img: ImageMeta | null) => {
|
||||
setImageError('')
|
||||
if (!img) {
|
||||
setNewUserBanner(null)
|
||||
@@ -171,9 +170,8 @@ function DialogInner({
|
||||
return
|
||||
}
|
||||
try {
|
||||
const finalImg = await compressIfNeeded(img, 1000000)
|
||||
setNewUserBanner(finalImg)
|
||||
setUserBanner(finalImg.path)
|
||||
setNewUserBanner(img)
|
||||
setUserBanner(img.path)
|
||||
} catch (e: any) {
|
||||
setImageError(cleanError(e))
|
||||
}
|
||||
@@ -258,6 +256,7 @@ function DialogInner({
|
||||
<ButtonText style={[a.text_md, !dirty && t.atoms.text_contrast_low]}>
|
||||
<Trans>Save</Trans>
|
||||
</ButtonText>
|
||||
{isUpdatingProfile && <ButtonIcon icon={Loader} />}
|
||||
</Button>
|
||||
),
|
||||
[
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import React, {memo, useMemo} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {
|
||||
AppBskyActorDefs,
|
||||
AppBskyLabelerDefs,
|
||||
type AppBskyActorDefs,
|
||||
type AppBskyLabelerDefs,
|
||||
moderateProfile,
|
||||
ModerationOpts,
|
||||
RichText as RichTextAPI,
|
||||
type ModerationOpts,
|
||||
type RichText as RichTextAPI,
|
||||
} from '@atproto/api'
|
||||
import {msg, Plural, plural, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
@@ -15,10 +15,9 @@ import {MAX_LABELERS} from '#/lib/constants'
|
||||
import {useHaptics} from '#/lib/haptics'
|
||||
import {isAppLabeler} from '#/lib/moderation'
|
||||
import {logger} from '#/logger'
|
||||
import {isIOS, isWeb} from '#/platform/detection'
|
||||
import {isIOS} from '#/platform/detection'
|
||||
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
||||
import {Shadow} from '#/state/cache/types'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {type Shadow} from '#/state/cache/types'
|
||||
import {useLabelerSubscriptionMutation} from '#/state/queries/labeler'
|
||||
import {useLikeMutation, useUnlikeMutation} from '#/state/queries/like'
|
||||
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 {atoms as a, tokens, useTheme} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {DialogOuterProps, useDialogControl} from '#/components/Dialog'
|
||||
import {type DialogOuterProps, useDialogControl} from '#/components/Dialog'
|
||||
import {
|
||||
Heart2_Filled_Stroke2_Corner0_Rounded as HeartFilled,
|
||||
Heart2_Stroke2_Corner0_Rounded as Heart,
|
||||
@@ -117,19 +116,7 @@ let ProfileHeaderLabeler = ({
|
||||
}
|
||||
}, [labeler, playHaptic, likeUri, unlikeMod, likeMod, _])
|
||||
|
||||
const {openModal} = useModalControls()
|
||||
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(
|
||||
() =>
|
||||
@@ -192,7 +179,7 @@ let ProfileHeaderLabeler = ({
|
||||
size="small"
|
||||
color="secondary"
|
||||
variant="solid"
|
||||
onPress={onPressEditProfile}
|
||||
onPress={editProfileControl.open}
|
||||
label={_(msg`Edit profile`)}
|
||||
style={a.rounded_full}>
|
||||
<ButtonText>
|
||||
|
||||
@@ -12,10 +12,9 @@ import {useLingui} from '@lingui/react'
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {logger} from '#/logger'
|
||||
import {isIOS, isWeb} from '#/platform/detection'
|
||||
import {isIOS} from '#/platform/detection'
|
||||
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
||||
import {type Shadow} from '#/state/cache/types'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {
|
||||
useProfileBlockMutationQueue,
|
||||
useProfileFollowMutationQueue,
|
||||
@@ -78,19 +77,7 @@ let ProfileHeaderStandard = ({
|
||||
profile.viewer?.blockedBy ||
|
||||
profile.viewer?.blockingByList
|
||||
|
||||
const {openModal} = useModalControls()
|
||||
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 = () => {
|
||||
requireAuth(async () => {
|
||||
@@ -178,7 +165,7 @@ let ProfileHeaderStandard = ({
|
||||
size="small"
|
||||
color="secondary"
|
||||
variant="solid"
|
||||
onPress={onPressEditProfile}
|
||||
onPress={editProfileControl.open}
|
||||
label={_(msg`Edit profile`)}
|
||||
style={[a.rounded_full]}>
|
||||
<ButtonText>
|
||||
|
||||
@@ -1,23 +1,30 @@
|
||||
import {useCallback, useMemo} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import RNPickerSelect, {PickerSelectProps} from 'react-native-picker-select'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
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 {useModalControls} from '#/state/modals'
|
||||
import {useLanguagePrefs, useLanguagePrefsApi} from '#/state/preferences'
|
||||
import {atoms as a, useTheme, web} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
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 * as Layout from '#/components/Layout'
|
||||
import * as Select from '#/components/Select'
|
||||
import {Text} from '#/components/Typography'
|
||||
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'>
|
||||
export function LanguageSettingsScreen({}: Props) {
|
||||
const {_} = useLingui()
|
||||
@@ -32,7 +39,7 @@ export function LanguageSettingsScreen({}: Props) {
|
||||
}, [openModal])
|
||||
|
||||
const onChangePrimaryLanguage = useCallback(
|
||||
(value: Parameters<PickerSelectProps['onValueChange']>[0]) => {
|
||||
(value: string) => {
|
||||
if (!value) return
|
||||
if (langPrefs.primaryLanguage !== value) {
|
||||
setLangPrefs.setPrimaryLanguage(value)
|
||||
@@ -42,7 +49,7 @@ export function LanguageSettingsScreen({}: Props) {
|
||||
)
|
||||
|
||||
const onChangeAppLanguage = useCallback(
|
||||
(value: Parameters<PickerSelectProps['onValueChange']>[0]) => {
|
||||
(value: string) => {
|
||||
if (!value) return
|
||||
if (langPrefs.appLanguage !== value) {
|
||||
setLangPrefs.setAppLanguage(sanitizeAppLanguageSetting(value))
|
||||
@@ -85,79 +92,26 @@ export function LanguageSettingsScreen({}: Props) {
|
||||
Select which language to use for the app's user interface.
|
||||
</Trans>
|
||||
</Text>
|
||||
<View style={[a.relative, web([a.w_full, {maxWidth: 400}])]}>
|
||||
<RNPickerSelect
|
||||
darkTheme={t.scheme === 'dark'}
|
||||
placeholder={{}}
|
||||
value={sanitizeAppLanguageSetting(langPrefs.appLanguage)}
|
||||
onValueChange={onChangeAppLanguage}
|
||||
items={APP_LANGUAGES.filter(l => Boolean(l.code2)).map(l => ({
|
||||
<Select.Root
|
||||
value={sanitizeAppLanguageSetting(langPrefs.appLanguage)}
|
||||
onValueChange={onChangeAppLanguage}>
|
||||
<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,
|
||||
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,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<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>
|
||||
</Select.Root>
|
||||
</View>
|
||||
</SettingsList.Group>
|
||||
<SettingsList.Divider />
|
||||
@@ -171,77 +125,26 @@ export function LanguageSettingsScreen({}: Props) {
|
||||
Select your preferred language for translations in your feed.
|
||||
</Trans>
|
||||
</Text>
|
||||
<View style={[a.relative, web([a.w_full, {maxWidth: 400}])]}>
|
||||
<RNPickerSelect
|
||||
darkTheme={t.scheme === 'dark'}
|
||||
placeholder={{}}
|
||||
value={langPrefs.primaryLanguage}
|
||||
onValueChange={onChangePrimaryLanguage}
|
||||
items={LANGUAGES.filter(l => Boolean(l.code2)).map(l => ({
|
||||
<Select.Root
|
||||
value={langPrefs.primaryLanguage}
|
||||
onValueChange={onChangePrimaryLanguage}>
|
||||
<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),
|
||||
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,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<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>
|
||||
</Select.Root>
|
||||
</View>
|
||||
</SettingsList.Group>
|
||||
<SettingsList.Divider />
|
||||
|
||||
@@ -184,10 +184,14 @@ export function Signup({onPressBack}: {onPressBack: () => void}) {
|
||||
<Divider />
|
||||
|
||||
<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 />
|
||||
<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>{' '}
|
||||
<InlineLinkText
|
||||
label={_(msg`Contact support`)}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {nanoid} from 'nanoid/non-secure'
|
||||
import {POST_IMG_MAX} from '#/lib/constants'
|
||||
import {getImageDim} from '#/lib/media/manip'
|
||||
import {openCropper} from '#/lib/media/picker'
|
||||
import {type PickerImage} from '#/lib/media/picker.shared'
|
||||
import {getDataUriSize} from '#/lib/media/util'
|
||||
import {isNative} from '#/platform/detection'
|
||||
|
||||
@@ -194,7 +195,7 @@ export function resetImageManipulation(
|
||||
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 [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
|
||||
|
||||
if (base64 !== undefined && getDataUriSize(base64) <= POST_IMG_MAX.size) {
|
||||
const size = base64 ? getDataUriSize(base64) : 0
|
||||
if (base64 && size <= POST_IMG_MAX.size) {
|
||||
minQualityPercentage = qualityPercentage
|
||||
newDataUri = {
|
||||
path: await moveIfNecessary(res.uri),
|
||||
width: res.width,
|
||||
height: res.height,
|
||||
mime: 'image/jpeg',
|
||||
size,
|
||||
}
|
||||
} else {
|
||||
maxQualityPercentage = qualityPercentage
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
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 {type PickerImage} from '#/lib/media/picker.shared'
|
||||
|
||||
export interface EditProfileModal {
|
||||
name: 'edit-profile'
|
||||
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||
onUpdate?: () => void
|
||||
}
|
||||
|
||||
export interface CreateOrEditListModal {
|
||||
name: 'create-or-edit-list'
|
||||
@@ -26,15 +19,6 @@ export interface UserAddRemoveListsModal {
|
||||
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 {
|
||||
name: 'delete-account'
|
||||
}
|
||||
@@ -71,9 +55,6 @@ export type Modal =
|
||||
| DeleteAccountModal
|
||||
| ChangePasswordModal
|
||||
|
||||
// Temp
|
||||
| EditProfileModal
|
||||
|
||||
// Curation
|
||||
| ContentLanguagesSettingsModal
|
||||
| PostLanguagesSettingsModal
|
||||
@@ -82,9 +63,6 @@ export type Modal =
|
||||
| CreateOrEditListModal
|
||||
| UserAddRemoveListsModal
|
||||
|
||||
// Posts
|
||||
| CropImageModal
|
||||
|
||||
// Bluesky access
|
||||
| WaitlistModal
|
||||
| InviteCodesModal
|
||||
@@ -110,20 +88,6 @@ const ModalControlContext = React.createContext<{
|
||||
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<{}>) {
|
||||
const [activeModals, setActiveModals] = React.useState<Modal[]>([])
|
||||
|
||||
@@ -145,9 +109,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
return wasActive
|
||||
})
|
||||
|
||||
unstable__openModal = openModal
|
||||
unstable__closeModal = closeModal
|
||||
|
||||
const state = React.useMemo(
|
||||
() => ({
|
||||
isModalActive: activeModals.length > 0,
|
||||
|
||||
@@ -14,9 +14,9 @@ import chunk from 'lodash.chunk'
|
||||
|
||||
import {uploadBlob} from '#/lib/api'
|
||||
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 {useAgent, useSession} from '../session'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
import {invalidate as invalidateMyLists} from './my-lists'
|
||||
import {RQKEY as PROFILE_LISTS_RQKEY} from './profile-lists'
|
||||
|
||||
@@ -47,7 +47,7 @@ export interface ListCreateMutateParams {
|
||||
name: string
|
||||
description: string
|
||||
descriptionFacets: Facet[] | undefined
|
||||
avatar: PickerImage | null | undefined
|
||||
avatar: ImageMeta | null | undefined
|
||||
}
|
||||
export function useListCreateMutation() {
|
||||
const {currentAccount} = useSession()
|
||||
@@ -115,7 +115,7 @@ export interface ListMetadataMutateParams {
|
||||
name: string
|
||||
description: string
|
||||
descriptionFacets: Facet[] | undefined
|
||||
avatar: PickerImage | null | undefined
|
||||
avatar: ImageMeta | null | undefined
|
||||
}
|
||||
export function useListMetadataMutation() {
|
||||
const {currentAccount} = useSession()
|
||||
|
||||
@@ -20,9 +20,10 @@ import {
|
||||
import {uploadBlob} from '#/lib/api'
|
||||
import {until} from '#/lib/async/until'
|
||||
import {useToggleMutationQueue} from '#/lib/hooks/useToggleMutationQueue'
|
||||
import {type PickerImage} from '#/lib/media/picker.shared'
|
||||
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 ImageMeta} from '#/state/gallery'
|
||||
import {STALE} from '#/state/queries'
|
||||
import {resetProfilePostsQueries} from '#/state/queries/post-feed'
|
||||
import {
|
||||
@@ -30,10 +31,9 @@ import {
|
||||
useUnstableProfileViewCache,
|
||||
} from '#/state/queries/unstable-profile-cache'
|
||||
import {useUpdateProfileVerificationCache} from '#/state/queries/verification/useUpdateProfileVerificationCache'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
import * as userActionHistory from '#/state/userActionHistory'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
import {updateProfileShadow} from '../cache/profile-shadow'
|
||||
import {useAgent, useSession} from '../session'
|
||||
import {
|
||||
ProgressGuideAction,
|
||||
useProgressGuideControls,
|
||||
@@ -131,8 +131,8 @@ interface ProfileUpdateParams {
|
||||
| ((
|
||||
existing: Un$Typed<AppBskyActorProfile.Record>,
|
||||
) => Un$Typed<AppBskyActorProfile.Record>)
|
||||
newUserAvatar?: PickerImage | undefined | null
|
||||
newUserBanner?: PickerImage | undefined | null
|
||||
newUserAvatar?: ImageMeta | undefined | null
|
||||
newUserBanner?: ImageMeta | undefined | null
|
||||
checkCommitted?: (res: AppBskyActorGetProfile.Response) => boolean
|
||||
}
|
||||
export function useProfileUpdateMutation() {
|
||||
|
||||
@@ -325,3 +325,11 @@ input[type='range'][orient='vertical']::-moz-range-thumb {
|
||||
background-color: var(--backgroundLight);
|
||||
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,7 +78,9 @@ export const SplashScreen = ({
|
||||
a.justify_center,
|
||||
a.align_center,
|
||||
]}>
|
||||
<AppLanguageDropdown />
|
||||
<View>
|
||||
<AppLanguageDropdown />
|
||||
</View>
|
||||
</View>
|
||||
<View style={{height: insets.bottom}} />
|
||||
</ErrorBoundary>
|
||||
|
||||
@@ -154,9 +154,11 @@ function Footer() {
|
||||
a.absolute,
|
||||
a.inset_0,
|
||||
{top: 'auto'},
|
||||
a.p_xl,
|
||||
a.px_xl,
|
||||
a.py_lg,
|
||||
a.border_t,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.flex_wrap,
|
||||
a.gap_xl,
|
||||
a.flex_1,
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import React from 'react'
|
||||
import type React from 'react'
|
||||
|
||||
import {ComposerImage} from '#/state/gallery'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {type ComposerImage} from '#/state/gallery'
|
||||
import type * as Dialog from '#/components/Dialog'
|
||||
|
||||
export type EditImageDialogProps = {
|
||||
control: Dialog.DialogOuterProps['control']
|
||||
image: ComposerImage
|
||||
image?: ComposerImage
|
||||
onChange: (next: ComposerImage) => void
|
||||
aspectRatio?: number
|
||||
circularCrop?: boolean
|
||||
}
|
||||
|
||||
export const EditImageDialog = ({}: EditImageDialogProps): React.ReactNode => {
|
||||
|
||||
@@ -1,43 +1,130 @@
|
||||
import 'react-image-crop/dist/ReactCrop.css'
|
||||
|
||||
import React from 'react'
|
||||
import {useCallback, useImperativeHandle, useRef, useState} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import ReactCrop, {PercentCrop} from 'react-image-crop'
|
||||
import ReactCrop, {type PercentCrop} from 'react-image-crop'
|
||||
|
||||
import {
|
||||
ImageSource,
|
||||
ImageTransformation,
|
||||
type ImageSource,
|
||||
type ImageTransformation,
|
||||
manipulateImage,
|
||||
} from '#/state/gallery'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {EditImageDialogProps} from './EditImageDialog'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {type EditImageDialogProps} from './EditImageDialog'
|
||||
|
||||
export const EditImageDialog = (props: EditImageDialogProps) => {
|
||||
export function EditImageDialog(props: EditImageDialogProps) {
|
||||
return (
|
||||
<Dialog.Outer control={props.control}>
|
||||
<Dialog.Handle />
|
||||
<EditImageInner key={props.image.source.id} {...props} />
|
||||
<DialogInner {...props} />
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
|
||||
const EditImageInner = ({control, image, onChange}: EditImageDialogProps) => {
|
||||
function DialogInner({
|
||||
control,
|
||||
image,
|
||||
onChange,
|
||||
circularCrop,
|
||||
aspectRatio,
|
||||
}: EditImageDialogProps) {
|
||||
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 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 isNew = initialCrop ? true : !isEmpty
|
||||
|
||||
const onPressSubmit = React.useCallback(async () => {
|
||||
const onPressSubmit = useCallback(async () => {
|
||||
const result = await manipulateImage(image, {
|
||||
crop:
|
||||
crop && (crop.width || crop.height) !== 0
|
||||
@@ -50,41 +137,43 @@ const EditImageInner = ({control, image, onChange}: EditImageDialogProps) => {
|
||||
: undefined,
|
||||
})
|
||||
|
||||
onChange(result)
|
||||
control.close()
|
||||
control.close(() => {
|
||||
onChange(result)
|
||||
})
|
||||
}, [crop, image, source, control, onChange])
|
||||
|
||||
useImperativeHandle(
|
||||
saveRef,
|
||||
() => ({
|
||||
save: onPressSubmit,
|
||||
}),
|
||||
[onPressSubmit],
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog.Inner label={_(msg`Edit image`)}>
|
||||
<Dialog.Close />
|
||||
|
||||
<Text style={[a.text_2xl, a.font_bold, a.leading_tight, a.pb_sm]}>
|
||||
<Trans>Edit image</Trans>
|
||||
</Text>
|
||||
|
||||
<View style={[a.align_center]}>
|
||||
<ReactCrop
|
||||
crop={crop}
|
||||
onChange={(_pixelCrop, percentCrop) => setCrop(percentCrop)}
|
||||
className="ReactCrop--no-animate">
|
||||
<img src={source.path} style={{maxHeight: `50vh`}} />
|
||||
</ReactCrop>
|
||||
</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>
|
||||
<View
|
||||
style={[
|
||||
a.mx_auto,
|
||||
a.border,
|
||||
t.atoms.border_contrast_low,
|
||||
a.rounded_xs,
|
||||
a.overflow_hidden,
|
||||
a.align_center,
|
||||
]}>
|
||||
<ReactCrop
|
||||
crop={crop}
|
||||
aspect={aspectRatio}
|
||||
circularCrop={circularCrop}
|
||||
onChange={(_pixelCrop, percentCrop) => setCrop(percentCrop)}
|
||||
className="ReactCrop--no-animate"
|
||||
onDragStart={() => setIsDragging(true)}
|
||||
onDragEnd={() => setIsDragging(false)}>
|
||||
<img src={source.path} style={{maxHeight: `50vh`}} />
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {useCallback, useState} from 'react'
|
||||
import {Keyboard, StyleProp, View, ViewStyle} from 'react-native'
|
||||
import RNPickerSelect from 'react-native-picker-select'
|
||||
import {Keyboard, type StyleProp, View, type ViewStyle} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
@@ -240,19 +239,21 @@ function SubtitleFileRow({
|
||||
numberOfLines={1}>
|
||||
{file.name}
|
||||
</Text>
|
||||
<RNPickerSelect
|
||||
placeholder={{
|
||||
label: _(msg`Select language...`),
|
||||
value: '',
|
||||
}}
|
||||
<select
|
||||
value={language}
|
||||
onValueChange={handleValueChange}
|
||||
items={otherLanguages.map(lang => ({
|
||||
label: `${lang.name} (${langCode(lang)})`,
|
||||
value: langCode(lang),
|
||||
}))}
|
||||
style={{viewContainer: {maxWidth: 200, flex: 1}}}
|
||||
/>
|
||||
onChange={evt => handleValueChange(evt.target.value)}
|
||||
style={{maxWidth: 200, flex: 1}}>
|
||||
<option value="" disabled selected hidden>
|
||||
{/* eslint-disable-next-line bsky-internal/avoid-unwrapped-text */}
|
||||
<Trans>Select language...</Trans>
|
||||
</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>
|
||||
|
||||
|
||||
@@ -15,24 +15,23 @@ import {useLingui} from '@lingui/react'
|
||||
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
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 {enforceLen} from '#/lib/strings/helpers'
|
||||
import {richTextToString} from '#/lib/strings/rich-text-helpers'
|
||||
import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'
|
||||
import {colors, gradients, s} from '#/lib/styles'
|
||||
import {useTheme} from '#/lib/ThemeContext'
|
||||
import {type ImageMeta} from '#/state/gallery'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {
|
||||
useListCreateMutation,
|
||||
useListMetadataMutation,
|
||||
} from '#/state/queries/list'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {ErrorMessage} from '../util/error/ErrorMessage'
|
||||
import {Text} from '../util/text/Text'
|
||||
import * as Toast from '../util/Toast'
|
||||
import {EditableUserAvatar} from '../util/UserAvatar'
|
||||
import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
|
||||
import {Text} from '#/view/com/util/text/Text'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {EditableUserAvatar} from '#/view/com/util/UserAvatar'
|
||||
|
||||
const MAX_NAME = 64 // todo
|
||||
const MAX_DESCRIPTION = 300 // todo
|
||||
@@ -95,7 +94,7 @@ export function Component({
|
||||
const isDescriptionOver = graphemeLength > MAX_DESCRIPTION
|
||||
|
||||
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(
|
||||
(newText: string) => {
|
||||
@@ -112,16 +111,15 @@ export function Component({
|
||||
}, [closeModal])
|
||||
|
||||
const onSelectNewAvatar = useCallback(
|
||||
async (img: PickerImage | null) => {
|
||||
(img: ImageMeta | null) => {
|
||||
if (!img) {
|
||||
setNewAvatar(null)
|
||||
setAvatar(undefined)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const finalImg = await compressIfNeeded(img, 1000000)
|
||||
setNewAvatar(finalImg)
|
||||
setAvatar(finalImg.path)
|
||||
setNewAvatar(img)
|
||||
setAvatar(img.path)
|
||||
} catch (e: any) {
|
||||
setError(cleanError(e))
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import {createCustomBackdrop} from '../util/BottomSheetCustomBackdrop'
|
||||
import * as ChangePasswordModal from './ChangePassword'
|
||||
import * as CreateOrEditListModal from './CreateOrEditList'
|
||||
import * as DeleteAccountModal from './DeleteAccount'
|
||||
import * as EditProfileModal from './EditProfile'
|
||||
import * as InviteCodesModal from './InviteCodes'
|
||||
import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
|
||||
import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
|
||||
@@ -48,10 +47,7 @@ export function ModalsContainer() {
|
||||
|
||||
let snapPoints: (string | number)[] = DEFAULT_SNAPPOINTS
|
||||
let element
|
||||
if (activeModal?.name === 'edit-profile') {
|
||||
snapPoints = EditProfileModal.snapPoints
|
||||
element = <EditProfileModal.Component {...activeModal} />
|
||||
} else if (activeModal?.name === 'create-or-edit-list') {
|
||||
if (activeModal?.name === 'create-or-edit-list') {
|
||||
snapPoints = CreateOrEditListModal.snapPoints
|
||||
element = <CreateOrEditListModal.Component {...activeModal} />
|
||||
} 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 * as ChangePasswordModal from './ChangePassword'
|
||||
import * as CreateOrEditListModal from './CreateOrEditList'
|
||||
import * as CropImageModal from './CropImage.web'
|
||||
import * as DeleteAccountModal from './DeleteAccount'
|
||||
import * as EditProfileModal from './EditProfile'
|
||||
import * as InviteCodesModal from './InviteCodes'
|
||||
import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
|
||||
import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
|
||||
@@ -45,9 +43,6 @@ function Modal({modal}: {modal: ModalIface}) {
|
||||
}
|
||||
|
||||
const onPressMask = () => {
|
||||
if (modal.name === 'crop-image') {
|
||||
return // dont close on mask presses during crop
|
||||
}
|
||||
closeModal()
|
||||
}
|
||||
const onInnerPress = () => {
|
||||
@@ -56,14 +51,10 @@ function Modal({modal}: {modal: ModalIface}) {
|
||||
}
|
||||
|
||||
let element
|
||||
if (modal.name === 'edit-profile') {
|
||||
element = <EditProfileModal.Component {...modal} />
|
||||
} else if (modal.name === 'create-or-edit-list') {
|
||||
if (modal.name === 'create-or-edit-list') {
|
||||
element = <CreateOrEditListModal.Component {...modal} />
|
||||
} else if (modal.name === 'user-add-remove-lists') {
|
||||
element = <UserAddRemoveLists.Component {...modal} />
|
||||
} else if (modal.name === 'crop-image') {
|
||||
element = <CropImageModal.Component {...modal} />
|
||||
} else if (modal.name === 'delete-account') {
|
||||
element = <DeleteAccountModal.Component />
|
||||
} else if (modal.name === 'invite-codes') {
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import React, {memo} from 'react'
|
||||
import React, {memo, useCallback} from 'react'
|
||||
import {
|
||||
ActivityIndicator,
|
||||
AppState,
|
||||
Dimensions,
|
||||
LayoutAnimation,
|
||||
type ListRenderItemInfo,
|
||||
type StyleProp,
|
||||
StyleSheet,
|
||||
View,
|
||||
type ViewStyle,
|
||||
} 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 {useLingui} from '@lingui/react'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
@@ -51,6 +56,7 @@ import {DiscoverFallbackHeader} from './DiscoverFallbackHeader'
|
||||
import {FeedShutdownMsg} from './FeedShutdownMsg'
|
||||
import {PostFeedErrorMessage} from './PostFeedErrorMessage'
|
||||
import {PostFeedItem} from './PostFeedItem'
|
||||
import {ShowLessFollowup} from './ShowLessFollowup'
|
||||
import {ViewFullThread} from './ViewFullThread'
|
||||
|
||||
type FeedRow =
|
||||
@@ -117,6 +123,10 @@ type FeedRow =
|
||||
type: 'interstitialTrendingVideos'
|
||||
key: string
|
||||
}
|
||||
| {
|
||||
type: 'showLessFollowup'
|
||||
key: string
|
||||
}
|
||||
|
||||
export function getItemsForFeedback(feedRow: FeedRow):
|
||||
| {
|
||||
@@ -200,6 +210,20 @@ let PostFeed = ({
|
||||
const {rightNavVisible} = useLayoutBreakpoints()
|
||||
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 opts = React.useMemo(
|
||||
() => ({enabled, ignoreFilterFor}),
|
||||
@@ -321,6 +345,19 @@ let PostFeed = ({
|
||||
const {trendingDisabled, trendingVideoDisabled} = useTrendingSettings()
|
||||
|
||||
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
|
||||
if (feedType === 'following') {
|
||||
feedKind = 'following'
|
||||
@@ -450,43 +487,51 @@ let PostFeed = ({
|
||||
} else if (slice.isIncompleteThread && slice.items.length >= 3) {
|
||||
const beforeLast = slice.items.length - 2
|
||||
const last = slice.items.length - 1
|
||||
arr.push({
|
||||
type: 'sliceItem',
|
||||
key: slice.items[0]._reactKey,
|
||||
slice: slice,
|
||||
indexInSlice: 0,
|
||||
showReplyTo: false,
|
||||
})
|
||||
arr.push(
|
||||
sliceItem({
|
||||
type: 'sliceItem',
|
||||
key: slice.items[0]._reactKey,
|
||||
slice: slice,
|
||||
indexInSlice: 0,
|
||||
showReplyTo: false,
|
||||
}),
|
||||
)
|
||||
arr.push({
|
||||
type: 'sliceViewFullThread',
|
||||
key: slice._reactKey + '-viewFullThread',
|
||||
uri: slice.items[0].uri,
|
||||
})
|
||||
arr.push({
|
||||
type: 'sliceItem',
|
||||
key: slice.items[beforeLast]._reactKey,
|
||||
slice: slice,
|
||||
indexInSlice: beforeLast,
|
||||
showReplyTo:
|
||||
slice.items[beforeLast].parentAuthor?.did !==
|
||||
slice.items[beforeLast].post.author.did,
|
||||
})
|
||||
arr.push({
|
||||
type: 'sliceItem',
|
||||
key: slice.items[last]._reactKey,
|
||||
slice: slice,
|
||||
indexInSlice: last,
|
||||
showReplyTo: false,
|
||||
})
|
||||
arr.push(
|
||||
sliceItem({
|
||||
type: 'sliceItem',
|
||||
key: slice.items[beforeLast]._reactKey,
|
||||
slice: slice,
|
||||
indexInSlice: beforeLast,
|
||||
showReplyTo:
|
||||
slice.items[beforeLast].parentAuthor?.did !==
|
||||
slice.items[beforeLast].post.author.did,
|
||||
}),
|
||||
)
|
||||
arr.push(
|
||||
sliceItem({
|
||||
type: 'sliceItem',
|
||||
key: slice.items[last]._reactKey,
|
||||
slice: slice,
|
||||
indexInSlice: last,
|
||||
showReplyTo: false,
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
for (let i = 0; i < slice.items.length; i++) {
|
||||
arr.push({
|
||||
type: 'sliceItem',
|
||||
key: slice.items[i]._reactKey,
|
||||
slice: slice,
|
||||
indexInSlice: i,
|
||||
showReplyTo: i === 0,
|
||||
})
|
||||
arr.push(
|
||||
sliceItem({
|
||||
type: 'sliceItem',
|
||||
key: slice.items[i]._reactKey,
|
||||
slice: slice,
|
||||
indexInSlice: i,
|
||||
showReplyTo: i === 0,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -531,6 +576,7 @@ let PostFeed = ({
|
||||
gtMobile,
|
||||
isVideoFeed,
|
||||
areVideoFeedsEnabled,
|
||||
hasPressedShowLessUris,
|
||||
])
|
||||
|
||||
// events
|
||||
@@ -650,6 +696,7 @@ let PostFeed = ({
|
||||
isParentNotFound={item.isParentNotFound}
|
||||
hideTopBorder={rowIndex === 0 && indexInSlice === 0}
|
||||
rootPost={slice.items[0].post}
|
||||
onShowLess={onPressShowLess}
|
||||
/>
|
||||
)
|
||||
} else if (row.type === 'sliceViewFullThread') {
|
||||
@@ -684,6 +731,8 @@ let PostFeed = ({
|
||||
sourceContext={sourceContext}
|
||||
/>
|
||||
)
|
||||
} else if (row.type === 'showLessFollowup') {
|
||||
return <ShowLessFollowup />
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
@@ -700,6 +749,7 @@ let PostFeed = ({
|
||||
feedUriOrActorDid,
|
||||
feedTab,
|
||||
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 {
|
||||
AppBskyActorDefs,
|
||||
type AppBskyActorDefs,
|
||||
AppBskyFeedDefs,
|
||||
AppBskyFeedPost,
|
||||
AppBskyFeedThreadgate,
|
||||
AtUri,
|
||||
ModerationDecision,
|
||||
type ModerationDecision,
|
||||
RichText as RichTextAPI,
|
||||
} from '@atproto/api'
|
||||
import {
|
||||
FontAwesomeIcon,
|
||||
FontAwesomeIconStyle,
|
||||
type FontAwesomeIconStyle,
|
||||
} from '@fortawesome/react-native-fontawesome'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
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 {usePalette} from '#/lib/hooks/usePalette'
|
||||
import {makeProfileLink} from '#/lib/routes/links'
|
||||
@@ -25,7 +25,11 @@ import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {countLines} from '#/lib/strings/helpers'
|
||||
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 {precacheProfile} from '#/state/queries/profile'
|
||||
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 {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe'
|
||||
import {PostAlerts} from '#/components/moderation/PostAlerts'
|
||||
import {AppModerationCause} from '#/components/Pills'
|
||||
import {type AppModerationCause} from '#/components/Pills'
|
||||
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
|
||||
import {RichText} from '#/components/RichText'
|
||||
import {SubtleWebHover} from '#/components/SubtleWebHover'
|
||||
@@ -86,9 +90,11 @@ export function PostFeedItem({
|
||||
isParentBlocked,
|
||||
isParentNotFound,
|
||||
rootPost,
|
||||
onShowLess,
|
||||
}: FeedItemProps & {
|
||||
post: AppBskyFeedDefs.PostView
|
||||
rootPost: AppBskyFeedDefs.PostView
|
||||
onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void
|
||||
}): React.ReactNode {
|
||||
const postShadowed = usePostShadow(post)
|
||||
const richText = useMemo(
|
||||
@@ -122,6 +128,7 @@ export function PostFeedItem({
|
||||
isParentBlocked={isParentBlocked}
|
||||
isParentNotFound={isParentNotFound}
|
||||
rootPost={rootPost}
|
||||
onShowLess={onShowLess}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -144,23 +151,27 @@ let FeedItemInner = ({
|
||||
isParentBlocked,
|
||||
isParentNotFound,
|
||||
rootPost,
|
||||
onShowLess,
|
||||
}: FeedItemProps & {
|
||||
richText: RichTextAPI
|
||||
post: Shadow<AppBskyFeedDefs.PostView>
|
||||
rootPost: AppBskyFeedDefs.PostView
|
||||
onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void
|
||||
}): React.ReactNode => {
|
||||
const queryClient = useQueryClient()
|
||||
const {openComposer} = useComposerControls()
|
||||
const pal = usePalette('default')
|
||||
const {_} = useLingui()
|
||||
|
||||
const [hover, setHover] = useState(false)
|
||||
|
||||
const href = useMemo(() => {
|
||||
const urip = new AtUri(post.uri)
|
||||
return makeProfileLink(post.author, 'post', urip.rkey)
|
||||
}, [post.uri, post.author])
|
||||
const {sendInteraction} = useFeedFeedbackContext()
|
||||
|
||||
const onPressReply = React.useCallback(() => {
|
||||
const onPressReply = useCallback(() => {
|
||||
sendInteraction({
|
||||
item: post.uri,
|
||||
event: 'app.bsky.feed.defs#interactionReply',
|
||||
@@ -178,7 +189,7 @@ let FeedItemInner = ({
|
||||
})
|
||||
}, [post, record, openComposer, moderation, sendInteraction, feedContext])
|
||||
|
||||
const onOpenAuthor = React.useCallback(() => {
|
||||
const onOpenAuthor = useCallback(() => {
|
||||
sendInteraction({
|
||||
item: post.uri,
|
||||
event: 'app.bsky.feed.defs#clickthroughAuthor',
|
||||
@@ -186,7 +197,7 @@ let FeedItemInner = ({
|
||||
})
|
||||
}, [sendInteraction, post, feedContext])
|
||||
|
||||
const onOpenReposter = React.useCallback(() => {
|
||||
const onOpenReposter = useCallback(() => {
|
||||
sendInteraction({
|
||||
item: post.uri,
|
||||
event: 'app.bsky.feed.defs#clickthroughReposter',
|
||||
@@ -194,7 +205,7 @@ let FeedItemInner = ({
|
||||
})
|
||||
}, [sendInteraction, post, feedContext])
|
||||
|
||||
const onOpenEmbed = React.useCallback(() => {
|
||||
const onOpenEmbed = useCallback(() => {
|
||||
sendInteraction({
|
||||
item: post.uri,
|
||||
event: 'app.bsky.feed.defs#clickthroughEmbed',
|
||||
@@ -202,7 +213,7 @@ let FeedItemInner = ({
|
||||
})
|
||||
}, [sendInteraction, post, feedContext])
|
||||
|
||||
const onBeforePress = React.useCallback(() => {
|
||||
const onBeforePress = useCallback(() => {
|
||||
sendInteraction({
|
||||
item: post.uri,
|
||||
event: 'app.bsky.feed.defs#clickthroughItem',
|
||||
@@ -240,7 +251,6 @@ let FeedItemInner = ({
|
||||
? rootPost.threadgate.record
|
||||
: undefined
|
||||
|
||||
const [hover, setHover] = useState(false)
|
||||
return (
|
||||
<Link
|
||||
testID={`feedItem-by-${post.author.handle}`}
|
||||
@@ -427,6 +437,7 @@ let FeedItemInner = ({
|
||||
logContext="FeedItem"
|
||||
feedContext={feedContext}
|
||||
threadgateRecord={threadgateRecord}
|
||||
onShowLess={onShowLess}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
@@ -461,7 +472,7 @@ let PostContent = ({
|
||||
const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({
|
||||
threadgateRecord,
|
||||
})
|
||||
const additionalPostAlerts: AppModerationCause[] = React.useMemo(() => {
|
||||
const additionalPostAlerts: AppModerationCause[] = useMemo(() => {
|
||||
const isPostHiddenByThreadgate = threadgateHiddenReplies.has(post.uri)
|
||||
const rootPostUri = bsky.dangerousIsType<AppBskyFeedPost.Record>(
|
||||
post.record,
|
||||
@@ -482,7 +493,7 @@ let PostContent = ({
|
||||
: []
|
||||
}, [post, currentAccount?.did, threadgateHiddenReplies])
|
||||
|
||||
const onPressShowMore = React.useCallback(() => {
|
||||
const onPressShowMore = useCallback(() => {
|
||||
setLimitLines(false)
|
||||
}, [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, ViewStyle} from 'react-native'
|
||||
import {View, type ViewStyle} from 'react-native'
|
||||
import type React from 'react'
|
||||
|
||||
/**
|
||||
* This utility function captures events and stops
|
||||
|
||||
+148
-105
@@ -1,4 +1,4 @@
|
||||
import React, {memo, useMemo} from 'react'
|
||||
import React, {memo, useCallback, useMemo, useState} from 'react'
|
||||
import {
|
||||
Image,
|
||||
Pressable,
|
||||
@@ -14,36 +14,38 @@ import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
import {
|
||||
useCameraPermission,
|
||||
usePhotoLibraryPermission,
|
||||
} 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 {colors} from '#/lib/styles'
|
||||
import {logger} from '#/logger'
|
||||
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 {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 {
|
||||
Camera_Filled_Stroke2_Corner0_Rounded as CameraFilled,
|
||||
Camera_Stroke2_Corner0_Rounded as Camera,
|
||||
Camera_Filled_Stroke2_Corner0_Rounded as CameraFilledIcon,
|
||||
Camera_Stroke2_Corner0_Rounded as CameraIcon,
|
||||
} from '#/components/icons/Camera'
|
||||
import {StreamingLive_Stroke2_Corner0_Rounded as Library} from '#/components/icons/StreamingLive'
|
||||
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
|
||||
import {StreamingLive_Stroke2_Corner0_Rounded as LibraryIcon} from '#/components/icons/StreamingLive'
|
||||
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
|
||||
import {Link} from '#/components/Link'
|
||||
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
||||
import * as Menu from '#/components/Menu'
|
||||
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
import {
|
||||
openCamera,
|
||||
openCropper,
|
||||
openPicker,
|
||||
type RNImage,
|
||||
} from '../../../lib/media/picker'
|
||||
|
||||
export type UserAvatarType = 'user' | 'algo' | 'list' | 'labeler'
|
||||
|
||||
@@ -63,7 +65,7 @@ interface UserAvatarProps extends BaseUserAvatarProps {
|
||||
}
|
||||
|
||||
interface EditableUserAvatarProps extends BaseUserAvatarProps {
|
||||
onSelectNewAvatar: (img: RNImage | null) => void
|
||||
onSelectNewAvatar: (img: PickerImage | null) => void
|
||||
}
|
||||
|
||||
interface PreviewableUserAvatarProps extends BaseUserAvatarProps {
|
||||
@@ -195,8 +197,8 @@ let UserAvatar = ({
|
||||
onLoad,
|
||||
style,
|
||||
}: UserAvatarProps): React.ReactNode => {
|
||||
const pal = usePalette('default')
|
||||
const backgroundColor = pal.colors.backgroundLight
|
||||
const t = useTheme()
|
||||
const backgroundColor = t.palette.contrast_25
|
||||
const finalShape = overrideShape ?? (type === 'user' ? 'circle' : 'square')
|
||||
|
||||
const aviStyle = useMemo(() => {
|
||||
@@ -221,15 +223,22 @@ let UserAvatar = ({
|
||||
return null
|
||||
}
|
||||
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
|
||||
icon="exclamation-circle"
|
||||
style={styles.alertIcon}
|
||||
style={{color: t.palette.negative_400}}
|
||||
size={Math.floor(size / 3)}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}, [moderation?.alert, size, pal])
|
||||
}, [moderation?.alert, size, t])
|
||||
|
||||
const containerStyle = useMemo(() => {
|
||||
return [
|
||||
@@ -288,14 +297,18 @@ let EditableUserAvatar = ({
|
||||
onSelectNewAvatar,
|
||||
}: EditableUserAvatarProps): React.ReactNode => {
|
||||
const t = useTheme()
|
||||
const pal = usePalette('default')
|
||||
const {_} = useLingui()
|
||||
const {requestCameraAccessIfNeeded} = useCameraPermission()
|
||||
const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
|
||||
const [rawImage, setRawImage] = useState<ComposerImage | undefined>()
|
||||
const editImageDialogControl = useDialogControl()
|
||||
|
||||
const sheetWrapper = useSheetWrapper()
|
||||
|
||||
const circular = type !== 'algo' && type !== 'list'
|
||||
|
||||
const aviStyle = useMemo(() => {
|
||||
if (type === 'algo' || type === 'list') {
|
||||
if (!circular) {
|
||||
return {
|
||||
width: size,
|
||||
height: size,
|
||||
@@ -307,7 +320,7 @@ let EditableUserAvatar = ({
|
||||
height: size,
|
||||
borderRadius: Math.floor(size / 2),
|
||||
}
|
||||
}, [type, size])
|
||||
}, [circular, size])
|
||||
|
||||
const onOpenCamera = React.useCallback(async () => {
|
||||
if (!(await requestCameraAccessIfNeeded())) {
|
||||
@@ -315,9 +328,11 @@ let EditableUserAvatar = ({
|
||||
}
|
||||
|
||||
onSelectNewAvatar(
|
||||
await openCamera({
|
||||
aspect: [1, 1],
|
||||
}),
|
||||
await compressIfNeeded(
|
||||
await openCamera({
|
||||
aspect: [1, 1],
|
||||
}),
|
||||
),
|
||||
)
|
||||
}, [onSelectNewAvatar, requestCameraAccessIfNeeded])
|
||||
|
||||
@@ -337,91 +352,129 @@ let EditableUserAvatar = ({
|
||||
}
|
||||
|
||||
try {
|
||||
const croppedImage = await openCropper({
|
||||
imageUri: item.path,
|
||||
shape: 'circle',
|
||||
aspectRatio: 1,
|
||||
})
|
||||
onSelectNewAvatar(croppedImage)
|
||||
if (isNative) {
|
||||
onSelectNewAvatar(
|
||||
await compressIfNeeded(
|
||||
await openCropper({
|
||||
imageUri: item.path,
|
||||
shape: circular ? 'circle' : 'rectangle',
|
||||
aspectRatio: 1,
|
||||
}),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
setRawImage(await createComposerImage(item))
|
||||
editImageDialogControl.open()
|
||||
}
|
||||
} catch (e: any) {
|
||||
// Don't log errors for cancelling selection to sentry on ios or android
|
||||
if (!String(e).toLowerCase().includes('cancel')) {
|
||||
logger.error('Failed to crop banner', {error: e})
|
||||
}
|
||||
}
|
||||
}, [onSelectNewAvatar, requestPhotoAccessIfNeeded, sheetWrapper])
|
||||
}, [
|
||||
onSelectNewAvatar,
|
||||
requestPhotoAccessIfNeeded,
|
||||
sheetWrapper,
|
||||
editImageDialogControl,
|
||||
circular,
|
||||
])
|
||||
|
||||
const onRemoveAvatar = React.useCallback(() => {
|
||||
onSelectNewAvatar(null)
|
||||
}, [onSelectNewAvatar])
|
||||
|
||||
return (
|
||||
<Menu.Root>
|
||||
<Menu.Trigger label={_(msg`Edit avatar`)}>
|
||||
{({props}) => (
|
||||
<Pressable {...props} testID="changeAvatarBtn">
|
||||
{avatar ? (
|
||||
<HighPriorityImage
|
||||
testID="userAvatarImage"
|
||||
style={aviStyle}
|
||||
source={{uri: avatar}}
|
||||
accessibilityRole="image"
|
||||
/>
|
||||
) : (
|
||||
<DefaultAvatar type={type} size={size} />
|
||||
)}
|
||||
<View style={[styles.editButtonContainer, pal.btn]}>
|
||||
<CameraFilled height={14} width={14} style={t.atoms.text} />
|
||||
</View>
|
||||
</Pressable>
|
||||
)}
|
||||
</Menu.Trigger>
|
||||
<Menu.Outer showCancel>
|
||||
<Menu.Group>
|
||||
{isNative && (
|
||||
<Menu.Item
|
||||
testID="changeAvatarCameraBtn"
|
||||
label={_(msg`Upload from Camera`)}
|
||||
onPress={onOpenCamera}>
|
||||
<Menu.ItemText>
|
||||
<Trans>Upload from Camera</Trans>
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={Camera} />
|
||||
</Menu.Item>
|
||||
)}
|
||||
const onChangeEditImage = useCallback(
|
||||
async (image: ComposerImage) => {
|
||||
const compressed = await compressImage(image)
|
||||
onSelectNewAvatar(compressed)
|
||||
},
|
||||
[onSelectNewAvatar],
|
||||
)
|
||||
|
||||
<Menu.Item
|
||||
testID="changeAvatarLibraryBtn"
|
||||
label={_(msg`Upload from Library`)}
|
||||
onPress={onOpenLibrary}>
|
||||
<Menu.ItemText>
|
||||
{isNative ? (
|
||||
<Trans>Upload from Library</Trans>
|
||||
return (
|
||||
<>
|
||||
<Menu.Root>
|
||||
<Menu.Trigger label={_(msg`Edit avatar`)}>
|
||||
{({props}) => (
|
||||
<Pressable {...props} testID="changeAvatarBtn">
|
||||
{avatar ? (
|
||||
<HighPriorityImage
|
||||
testID="userAvatarImage"
|
||||
style={aviStyle}
|
||||
source={{uri: avatar}}
|
||||
accessibilityRole="image"
|
||||
/>
|
||||
) : (
|
||||
<Trans>Upload from Files</Trans>
|
||||
<DefaultAvatar type={type} size={size} />
|
||||
)}
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={Library} />
|
||||
</Menu.Item>
|
||||
</Menu.Group>
|
||||
{!!avatar && (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
<Menu.Group>
|
||||
<View
|
||||
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>
|
||||
</Pressable>
|
||||
)}
|
||||
</Menu.Trigger>
|
||||
<Menu.Outer showCancel>
|
||||
<Menu.Group>
|
||||
{isNative && (
|
||||
<Menu.Item
|
||||
testID="changeAvatarRemoveBtn"
|
||||
label={_(msg`Remove Avatar`)}
|
||||
onPress={onRemoveAvatar}>
|
||||
testID="changeAvatarCameraBtn"
|
||||
label={_(msg`Upload from Camera`)}
|
||||
onPress={onOpenCamera}>
|
||||
<Menu.ItemText>
|
||||
<Trans>Remove Avatar</Trans>
|
||||
<Trans>Upload from Camera</Trans>
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={Trash} />
|
||||
<Menu.ItemIcon icon={CameraIcon} />
|
||||
</Menu.Item>
|
||||
</Menu.Group>
|
||||
</>
|
||||
)}
|
||||
</Menu.Outer>
|
||||
</Menu.Root>
|
||||
)}
|
||||
|
||||
<Menu.Item
|
||||
testID="changeAvatarLibraryBtn"
|
||||
label={_(msg`Upload from Library`)}
|
||||
onPress={onOpenLibrary}>
|
||||
<Menu.ItemText>
|
||||
{isNative ? (
|
||||
<Trans>Upload from Library</Trans>
|
||||
) : (
|
||||
<Trans>Upload from Files</Trans>
|
||||
)}
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={LibraryIcon} />
|
||||
</Menu.Item>
|
||||
</Menu.Group>
|
||||
{!!avatar && (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
<Menu.Group>
|
||||
<Menu.Item
|
||||
testID="changeAvatarRemoveBtn"
|
||||
label={_(msg`Remove Avatar`)}
|
||||
onPress={onRemoveAvatar}>
|
||||
<Menu.ItemText>
|
||||
<Trans>Remove Avatar</Trans>
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={TrashIcon} />
|
||||
</Menu.Item>
|
||||
</Menu.Group>
|
||||
</>
|
||||
)}
|
||||
</Menu.Outer>
|
||||
</Menu.Root>
|
||||
|
||||
<EditImageDialog
|
||||
control={editImageDialogControl}
|
||||
image={rawImage}
|
||||
onChange={onChangeEditImage}
|
||||
aspectRatio={1}
|
||||
circularCrop={circular}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
EditableUserAvatar = memo(EditableUserAvatar)
|
||||
@@ -440,7 +493,7 @@ let PreviewableUserAvatar = ({
|
||||
|
||||
const onPress = React.useCallback(() => {
|
||||
onBeforePress?.()
|
||||
precacheProfile(queryClient, profile)
|
||||
unstableCacheProfileView(queryClient, profile)
|
||||
}, [profile, queryClient, onBeforePress])
|
||||
|
||||
const avatarEl = (
|
||||
@@ -494,15 +547,5 @@ const styles = StyleSheet.create({
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.gray5,
|
||||
},
|
||||
alertIconContainer: {
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
borderRadius: 100,
|
||||
},
|
||||
alertIcon: {
|
||||
color: colors.red3,
|
||||
},
|
||||
})
|
||||
|
||||
+141
-103
@@ -1,35 +1,36 @@
|
||||
import React from 'react'
|
||||
import {useCallback, useState} from 'react'
|
||||
import {Pressable, StyleSheet, View} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {type ModerationUI} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
import {
|
||||
useCameraPermission,
|
||||
usePhotoLibraryPermission,
|
||||
} from '#/lib/hooks/usePermissions'
|
||||
import {colors} from '#/lib/styles'
|
||||
import {useTheme} from '#/lib/ThemeContext'
|
||||
import {compressIfNeeded} from '#/lib/media/manip'
|
||||
import {openCamera, openCropper, openPicker} from '#/lib/media/picker'
|
||||
import {type PickerImage} from '#/lib/media/picker.shared'
|
||||
import {logger} from '#/logger'
|
||||
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 {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 {
|
||||
Camera_Filled_Stroke2_Corner0_Rounded as CameraFilled,
|
||||
Camera_Stroke2_Corner0_Rounded as Camera,
|
||||
Camera_Filled_Stroke2_Corner0_Rounded as CameraFilledIcon,
|
||||
Camera_Stroke2_Corner0_Rounded as CameraIcon,
|
||||
} from '#/components/icons/Camera'
|
||||
import {StreamingLive_Stroke2_Corner0_Rounded as Library} from '#/components/icons/StreamingLive'
|
||||
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
|
||||
import {StreamingLive_Stroke2_Corner0_Rounded as LibraryIcon} from '#/components/icons/StreamingLive'
|
||||
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
|
||||
import * as Menu from '#/components/Menu'
|
||||
import {
|
||||
openCamera,
|
||||
openCropper,
|
||||
openPicker,
|
||||
type RNImage,
|
||||
} from '../../../lib/media/picker'
|
||||
|
||||
export function UserBanner({
|
||||
type,
|
||||
@@ -40,28 +41,30 @@ export function UserBanner({
|
||||
type?: 'labeler' | 'default'
|
||||
banner?: string | null
|
||||
moderation?: ModerationUI
|
||||
onSelectNewBanner?: (img: RNImage | null) => void
|
||||
onSelectNewBanner?: (img: PickerImage | null) => void
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
const theme = useTheme()
|
||||
const t = useAlfTheme()
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {requestCameraAccessIfNeeded} = useCameraPermission()
|
||||
const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
|
||||
const sheetWrapper = useSheetWrapper()
|
||||
const [rawImage, setRawImage] = useState<ComposerImage | undefined>()
|
||||
const editImageDialogControl = useDialogControl()
|
||||
|
||||
const onOpenCamera = React.useCallback(async () => {
|
||||
const onOpenCamera = useCallback(async () => {
|
||||
if (!(await requestCameraAccessIfNeeded())) {
|
||||
return
|
||||
}
|
||||
onSelectNewBanner?.(
|
||||
await openCamera({
|
||||
aspect: [3, 1],
|
||||
}),
|
||||
await compressIfNeeded(
|
||||
await openCamera({
|
||||
aspect: [3, 1],
|
||||
}),
|
||||
),
|
||||
)
|
||||
}, [onSelectNewBanner, requestCameraAccessIfNeeded])
|
||||
|
||||
const onOpenLibrary = React.useCallback(async () => {
|
||||
const onOpenLibrary = useCallback(async () => {
|
||||
if (!(await requestPhotoAccessIfNeeded())) {
|
||||
return
|
||||
}
|
||||
@@ -71,105 +74,141 @@ export function UserBanner({
|
||||
}
|
||||
|
||||
try {
|
||||
onSelectNewBanner?.(
|
||||
await openCropper({
|
||||
imageUri: items[0].path,
|
||||
aspectRatio: 3 / 1,
|
||||
}),
|
||||
)
|
||||
if (isNative) {
|
||||
onSelectNewBanner?.(
|
||||
await compressIfNeeded(
|
||||
await openCropper({
|
||||
imageUri: items[0].path,
|
||||
aspectRatio: 3 / 1,
|
||||
}),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
setRawImage(await createComposerImage(items[0]))
|
||||
editImageDialogControl.open()
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (!String(e).includes('Canceled')) {
|
||||
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])
|
||||
|
||||
const onChangeEditImage = useCallback(
|
||||
async (image: ComposerImage) => {
|
||||
const compressed = await compressImage(image)
|
||||
onSelectNewBanner?.(compressed)
|
||||
},
|
||||
[onSelectNewBanner],
|
||||
)
|
||||
|
||||
// setUserBanner is only passed as prop on the EditProfile component
|
||||
return onSelectNewBanner ? (
|
||||
<EventStopper onKeyDown={true}>
|
||||
<Menu.Root>
|
||||
<Menu.Trigger label={_(msg`Edit avatar`)}>
|
||||
{({props}) => (
|
||||
<Pressable {...props} testID="changeBannerBtn">
|
||||
{banner ? (
|
||||
<Image
|
||||
testID="userBannerImage"
|
||||
style={styles.bannerImage}
|
||||
source={{uri: banner}}
|
||||
accessible={true}
|
||||
accessibilityIgnoresInvertColors
|
||||
/>
|
||||
) : (
|
||||
<View
|
||||
testID="userBannerFallback"
|
||||
style={[styles.bannerImage, t.atoms.bg_contrast_25]}
|
||||
/>
|
||||
)}
|
||||
<View style={[styles.editButtonContainer, pal.btn]}>
|
||||
<CameraFilled height={14} width={14} style={t.atoms.text} />
|
||||
</View>
|
||||
</Pressable>
|
||||
)}
|
||||
</Menu.Trigger>
|
||||
<Menu.Outer showCancel>
|
||||
<Menu.Group>
|
||||
{isNative && (
|
||||
<Menu.Item
|
||||
testID="changeBannerCameraBtn"
|
||||
label={_(msg`Upload from Camera`)}
|
||||
onPress={onOpenCamera}>
|
||||
<Menu.ItemText>
|
||||
<Trans>Upload from Camera</Trans>
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={Camera} />
|
||||
</Menu.Item>
|
||||
)}
|
||||
|
||||
<Menu.Item
|
||||
testID="changeBannerLibraryBtn"
|
||||
label={_(msg`Upload from Library`)}
|
||||
onPress={onOpenLibrary}>
|
||||
<Menu.ItemText>
|
||||
{isNative ? (
|
||||
<Trans>Upload from Library</Trans>
|
||||
<>
|
||||
<EventStopper onKeyDown={true}>
|
||||
<Menu.Root>
|
||||
<Menu.Trigger label={_(msg`Edit avatar`)}>
|
||||
{({props}) => (
|
||||
<Pressable {...props} testID="changeBannerBtn">
|
||||
{banner ? (
|
||||
<Image
|
||||
testID="userBannerImage"
|
||||
style={styles.bannerImage}
|
||||
source={{uri: banner}}
|
||||
accessible={true}
|
||||
accessibilityIgnoresInvertColors
|
||||
/>
|
||||
) : (
|
||||
<Trans>Upload from Files</Trans>
|
||||
<View
|
||||
testID="userBannerFallback"
|
||||
style={[styles.bannerImage, t.atoms.bg_contrast_25]}
|
||||
/>
|
||||
)}
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={Library} />
|
||||
</Menu.Item>
|
||||
</Menu.Group>
|
||||
{!!banner && (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
<Menu.Group>
|
||||
<View
|
||||
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>
|
||||
</Pressable>
|
||||
)}
|
||||
</Menu.Trigger>
|
||||
<Menu.Outer showCancel>
|
||||
<Menu.Group>
|
||||
{isNative && (
|
||||
<Menu.Item
|
||||
testID="changeBannerRemoveBtn"
|
||||
label={_(msg`Remove Banner`)}
|
||||
onPress={onRemoveBanner}>
|
||||
testID="changeBannerCameraBtn"
|
||||
label={_(msg`Upload from Camera`)}
|
||||
onPress={onOpenCamera}>
|
||||
<Menu.ItemText>
|
||||
<Trans>Remove Banner</Trans>
|
||||
<Trans>Upload from Camera</Trans>
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={Trash} />
|
||||
<Menu.ItemIcon icon={CameraIcon} />
|
||||
</Menu.Item>
|
||||
</Menu.Group>
|
||||
</>
|
||||
)}
|
||||
</Menu.Outer>
|
||||
</Menu.Root>
|
||||
</EventStopper>
|
||||
)}
|
||||
|
||||
<Menu.Item
|
||||
testID="changeBannerLibraryBtn"
|
||||
label={_(msg`Upload from Library`)}
|
||||
onPress={onOpenLibrary}>
|
||||
<Menu.ItemText>
|
||||
{isNative ? (
|
||||
<Trans>Upload from Library</Trans>
|
||||
) : (
|
||||
<Trans>Upload from Files</Trans>
|
||||
)}
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={LibraryIcon} />
|
||||
</Menu.Item>
|
||||
</Menu.Group>
|
||||
{!!banner && (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
<Menu.Group>
|
||||
<Menu.Item
|
||||
testID="changeBannerRemoveBtn"
|
||||
label={_(msg`Remove Banner`)}
|
||||
onPress={onRemoveBanner}>
|
||||
<Menu.ItemText>
|
||||
<Trans>Remove Banner</Trans>
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={TrashIcon} />
|
||||
</Menu.Item>
|
||||
</Menu.Group>
|
||||
</>
|
||||
)}
|
||||
</Menu.Outer>
|
||||
</Menu.Root>
|
||||
</EventStopper>
|
||||
|
||||
<EditImageDialog
|
||||
control={editImageDialogControl}
|
||||
image={rawImage}
|
||||
onChange={onChangeEditImage}
|
||||
aspectRatio={3}
|
||||
/>
|
||||
</>
|
||||
) : banner &&
|
||||
!((moderation?.blur && isAndroid) /* android crashes with blur */) ? (
|
||||
<Image
|
||||
testID="userBannerImage"
|
||||
style={[
|
||||
styles.bannerImage,
|
||||
{backgroundColor: theme.palette.default.backgroundLight},
|
||||
]}
|
||||
style={[styles.bannerImage, t.atoms.bg_contrast_25]}
|
||||
contentFit="cover"
|
||||
source={{uri: banner}}
|
||||
blurRadius={moderation?.blur ? 100 : 0}
|
||||
@@ -197,7 +236,6 @@ const styles = StyleSheet.create({
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.gray5,
|
||||
},
|
||||
bannerImage: {
|
||||
width: '100%',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, {memo, useMemo, useState} from 'react'
|
||||
import {memo, useMemo, useState} from 'react'
|
||||
import {
|
||||
Pressable,
|
||||
type PressableProps,
|
||||
@@ -6,16 +6,17 @@ import {
|
||||
type ViewStyle,
|
||||
} from 'react-native'
|
||||
import {
|
||||
AppBskyFeedDefs,
|
||||
AppBskyFeedPost,
|
||||
AppBskyFeedThreadgate,
|
||||
RichText as RichTextAPI,
|
||||
type AppBskyFeedDefs,
|
||||
type AppBskyFeedPost,
|
||||
type AppBskyFeedThreadgate,
|
||||
type RichText as RichTextAPI,
|
||||
} from '@atproto/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import type React from 'react'
|
||||
|
||||
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 {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid'
|
||||
import {useMenuControl} from '#/components/Menu'
|
||||
@@ -34,6 +35,7 @@ let PostDropdownBtn = ({
|
||||
size,
|
||||
timestamp,
|
||||
threadgateRecord,
|
||||
onShowLess,
|
||||
}: {
|
||||
testID: string
|
||||
post: Shadow<AppBskyFeedDefs.PostView>
|
||||
@@ -45,6 +47,7 @@ let PostDropdownBtn = ({
|
||||
size?: 'lg' | 'md' | 'sm'
|
||||
timestamp: string
|
||||
threadgateRecord?: AppBskyFeedThreadgate.Record
|
||||
onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void
|
||||
}): React.ReactNode => {
|
||||
const theme = useTheme()
|
||||
const alf = useAlf()
|
||||
@@ -100,6 +103,7 @@ let PostDropdownBtn = ({
|
||||
richText={richText}
|
||||
timestamp={timestamp}
|
||||
threadgateRecord={threadgateRecord}
|
||||
onShowLess={onShowLess}
|
||||
/>
|
||||
)}
|
||||
</Menu.Root>
|
||||
|
||||
@@ -17,6 +17,8 @@ import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
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 {getCurrentRoute} from '#/lib/routes/helpers'
|
||||
import {makeProfileLink} from '#/lib/routes/links'
|
||||
@@ -60,6 +62,7 @@ import {
|
||||
} from '#/components/dialogs/PostInteractionSettingsDialog'
|
||||
import {SendViaChatDialog} from '#/components/dms/dialogs/ShareViaChatDialog'
|
||||
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 {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard'
|
||||
import {CodeBrackets_Stroke2_Corner0_Rounded as CodeBrackets} from '#/components/icons/CodeBrackets'
|
||||
@@ -98,6 +101,7 @@ let PostDropdownMenuItems = ({
|
||||
richText,
|
||||
timestamp,
|
||||
threadgateRecord,
|
||||
onShowLess,
|
||||
}: {
|
||||
testID: string
|
||||
post: Shadow<AppBskyFeedDefs.PostView>
|
||||
@@ -109,6 +113,7 @@ let PostDropdownMenuItems = ({
|
||||
size?: 'lg' | 'md' | 'sm'
|
||||
timestamp: string
|
||||
threadgateRecord?: AppBskyFeedThreadgate.Record
|
||||
onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void
|
||||
}): React.ReactNode => {
|
||||
const {hasSession, currentAccount} = useSession()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
@@ -300,8 +305,15 @@ let PostDropdownMenuItems = ({
|
||||
item: postUri,
|
||||
feedContext: postFeedContext,
|
||||
})
|
||||
Toast.show(_(msg({message: 'Feedback sent!', context: 'toast'})))
|
||||
}, [feedFeedback, postUri, postFeedContext, _])
|
||||
if (onShowLess) {
|
||||
onShowLess({
|
||||
item: postUri,
|
||||
feedContext: postFeedContext,
|
||||
})
|
||||
} else {
|
||||
Toast.show(_(msg({message: 'Feedback sent!', context: 'toast'})))
|
||||
}
|
||||
}, [feedFeedback, postUri, postFeedContext, _, onShowLess])
|
||||
|
||||
const onSelectChatToShareTo = React.useCallback(
|
||||
(conversation: string) => {
|
||||
@@ -430,6 +442,13 @@ let PostDropdownMenuItems = ({
|
||||
shareText(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 (
|
||||
<>
|
||||
<Menu.Outer>
|
||||
@@ -539,6 +558,19 @@ let PostDropdownMenuItems = ({
|
||||
<Menu.ItemText>{_(msg`Show less like this`)}</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={EmojiSad} position="right" />
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -8,11 +8,11 @@ import {
|
||||
} from 'react-native'
|
||||
import * as Clipboard from 'expo-clipboard'
|
||||
import {
|
||||
AppBskyFeedDefs,
|
||||
AppBskyFeedPost,
|
||||
AppBskyFeedThreadgate,
|
||||
type AppBskyFeedDefs,
|
||||
type AppBskyFeedPost,
|
||||
type AppBskyFeedThreadgate,
|
||||
AtUri,
|
||||
RichText as RichTextAPI,
|
||||
type RichText as RichTextAPI,
|
||||
} from '@atproto/api'
|
||||
import {msg, plural} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
@@ -26,7 +26,7 @@ import {makeProfileLink} from '#/lib/routes/links'
|
||||
import {shareUrl} from '#/lib/sharing'
|
||||
import {useGate} from '#/lib/statsig/statsig'
|
||||
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 {
|
||||
usePostLikeMutationQueue,
|
||||
@@ -60,6 +60,7 @@ let PostCtrls = ({
|
||||
onPostReply,
|
||||
logContext,
|
||||
threadgateRecord,
|
||||
onShowLess,
|
||||
}: {
|
||||
big?: boolean
|
||||
post: Shadow<AppBskyFeedDefs.PostView>
|
||||
@@ -71,6 +72,7 @@ let PostCtrls = ({
|
||||
onPostReply?: (postUri: string | undefined) => void
|
||||
logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo'
|
||||
threadgateRecord?: AppBskyFeedThreadgate.Record
|
||||
onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void
|
||||
}): React.ReactNode => {
|
||||
const t = useTheme()
|
||||
const {_, i18n} = useLingui()
|
||||
@@ -378,6 +380,7 @@ let PostCtrls = ({
|
||||
hitSlop={POST_CTRL_HITSLOP}
|
||||
timestamp={post.indexedAt}
|
||||
threadgateRecord={threadgateRecord}
|
||||
onShowLess={onShowLess}
|
||||
/>
|
||||
</View>
|
||||
{isDiscoverDebugUser && feedContext && (
|
||||
|
||||
@@ -65,7 +65,7 @@ let NavSignupCard = ({}: {}): React.ReactNode => {
|
||||
</View>
|
||||
|
||||
<View style={[a.mt_md, a.w_full, {height: 32}]}>
|
||||
<AppLanguageDropdown style={{marginTop: 0}} />
|
||||
<AppLanguageDropdown />
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
|
||||
@@ -137,7 +137,7 @@ export function DesktopRightNav({routeName}: {routeName: string}) {
|
||||
|
||||
{!hasSession && leftNavMinimal && (
|
||||
<View style={[a.w_full, {height: 32}]}>
|
||||
<AppLanguageDropdown style={{marginTop: 0}} />
|
||||
<AppLanguageDropdown />
|
||||
</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"
|
||||
integrity sha512-QZdE3NMX74rTuIl82I+n12XGwpDWKb8zfs5EpwsnGi/D/n7O2Jd4tO5ivH+muEG/OCJOMq5aeaVDqqaQOhTkcA==
|
||||
|
||||
expo-image-crop-tool@^0.1.6:
|
||||
version "0.1.6"
|
||||
resolved "https://registry.yarnpkg.com/expo-image-crop-tool/-/expo-image-crop-tool-0.1.6.tgz#fd65ea2a143fef9a45a0d0cd2b967ec1f63a0bad"
|
||||
integrity sha512-I0KVaLur+4EBMf6jtp7uVQNS26DgyGWERn2sng6iKc1I2QygILU8jj5/sMqzCB7fGPpzGn7e0S9u6EmR2YJHfg==
|
||||
expo-image-crop-tool@^0.1.8:
|
||||
version "0.1.8"
|
||||
resolved "https://registry.yarnpkg.com/expo-image-crop-tool/-/expo-image-crop-tool-0.1.8.tgz#3e9f34825cf5d7dad1ef2786615571b078ece4e7"
|
||||
integrity sha512-UlS1zV7JewUzuZzVT9aA0vFD1+dt+pU60ILgt3ntQl4G9SeDJ9bB/+ylz9dzn6BjZecUQkGJmbCQ3H7jGZeZMA==
|
||||
|
||||
expo-image-loader@~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"
|
||||
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:
|
||||
version "4.1.2"
|
||||
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"
|
||||
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:
|
||||
version "5.0.0"
|
||||
resolved "https://codeload.github.com/bluesky-social/react-native-progress/tar.gz/5a372f4f2ce5feb26f4f47b6a4d187ab9b923ab4"
|
||||
|
||||
Reference in New Issue
Block a user