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