Merge branch 'main' into hailey/79fab
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user