Merge branch 'main' into hailey/79fab

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