diff --git a/LICENSE b/LICENSE index 6fffcb890c..3797936f8f 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright 2023–2025 Bluesky PBC +Copyright 2023–2025 Bluesky Social PBC Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: diff --git a/__mocks__/react-native-image-crop-picker.js b/__mocks__/react-native-image-crop-picker.js deleted file mode 100644 index 9f901dfacb..0000000000 --- a/__mocks__/react-native-image-crop-picker.js +++ /dev/null @@ -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: ''})) diff --git a/modules/bottom-sheet/src/BottomSheetNativeComponent.tsx b/modules/bottom-sheet/src/BottomSheetNativeComponent.tsx index 869b89e3d4..d367ac300c 100644 --- a/modules/bottom-sheet/src/BottomSheetNativeComponent.tsx +++ b/modules/bottom-sheet/src/BottomSheetNativeComponent.tsx @@ -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, diff --git a/package.json b/package.json index 2a854b6780..28770560b7 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/alf/atoms.ts b/src/alf/atoms.ts index c60af48625..68aa3cc882 100644 --- a/src/alf/atoms.ts +++ b/src/alf/atoms.ts @@ -204,6 +204,9 @@ export const atoms = { flex_grow: { flexGrow: 1, }, + flex_grow_0: { + flexGrow: 0, + }, flex_shrink: { flexShrink: 1, }, diff --git a/src/components/AppLanguageDropdown.tsx b/src/components/AppLanguageDropdown.tsx index de2e50fc8c..9837ce5ced 100644 --- a/src/components/AppLanguageDropdown.tsx +++ b/src/components/AppLanguageDropdown.tsx @@ -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[0]) => { + (value: string) => { if (!value) return if (sanitizedLang !== value) { setLangPrefs.setAppLanguage(sanitizeAppLanguageSetting(value)) @@ -32,43 +34,48 @@ export function AppLanguageDropdown(_props: ViewStyleProp) { ) return ( - - Boolean(l.code2)).map(l => ({ + + + {({props}) => ( + + )} + + ( + + + {label} + + )} + 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, - }, - }} /> - - - - - + ) } diff --git a/src/components/AppLanguageDropdown.web.tsx b/src/components/AppLanguageDropdown.web.tsx deleted file mode 100644 index d51b53ac07..0000000000 --- a/src/components/AppLanguageDropdown.web.tsx +++ /dev/null @@ -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) => { - 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 ( - - - - {APP_LANGUAGES.find(l => l.code2 === sanitizedLang)?.name} - - - - - - - ) -} diff --git a/src/components/Dialog/context.ts b/src/components/Dialog/context.ts index eb892403f7..2ecf5ba618 100644 --- a/src/components/Dialog/context.ts +++ b/src/components/Dialog/context.ts @@ -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({ +export const Context = createContext({ close: () => {}, isNativeDialog: false, nativeSnapPoint: BottomSheetSnapPoint.Hidden, @@ -18,18 +25,18 @@ export const Context = React.createContext({ }) export function useDialogContext() { - return React.useContext(Context) + return useContext(Context) } export function useDialogControl(): DialogOuterProps['control'] { - const id = React.useId() - const control = React.useRef({ + const id = useId() + const control = useRef({ 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( + return useMemo( () => ({ id, ref: control, diff --git a/src/components/Dialog/shared.tsx b/src/components/Dialog/shared.tsx index 44a4f6b0bb..40d0408788 100644 --- a/src/components/Dialog/shared.tsx +++ b/src/components/Dialog/shared.tsx @@ -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 + onLayout?: (event: LayoutChangeEvent) => void }) { const t = useTheme() return ( ({ + const Context = createContext({ outlet: null, append: () => {}, remove: () => {}, }) function Provider(props: React.PropsWithChildren<{}>) { - const map = React.useRef({}) - const [outlet, setOutlet] = React.useState(null) + const map = useRef({}) + const [outlet, setOutlet] = useState(null) - const append = React.useCallback((id, component) => { + const append = useCallback((id, component) => { if (map.current[id]) return - map.current[id] = {component} + map.current[id] = {component} setOutlet(<>{Object.values(map.current)}) }, []) - const remove = React.useCallback(id => { - delete map.current[id] + const remove = useCallback(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]) diff --git a/src/components/Select/index.tsx b/src/components/Select/index.tsx new file mode 100644 index 0000000000..4e8e53216e --- /dev/null +++ b/src/components/Select/index.tsx @@ -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 + +const Context = createContext(null) + +const ValueTextContext = createContext< + [any, React.Dispatch>] +>([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() + + const ctx = useMemo( + () => ({ + control, + value, + onValueChange, + disabled, + }), + [control, value, onValueChange, disabled], + ) + return ( + + + {children} + + + ) +} + +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 ( + + ) + } +} + +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 ( + {text} + ) +} + +export function Icon({}: IconProps) { + return +} + +export function Content({ + items, + valueExtractor = defaultItemValueExtractor, + ...props +}: ContentProps) { + 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 ( + + + + ) +} + +function ContentInner({ + items, + renderItem, + valueExtractor, + ...context +}: ContentProps & 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( + () => ( + + ), + [control, _], + ) + + return ( + + setHeaderHeight(evt.nativeEvent.layout.height)} + style={[a.absolute, a.top_0, a.left_0, a.right_0, a.z_10]}> + + Select an option + + + + + ) +} + +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 ( + + ) +} + +export function ItemText({children}: ItemTextProps) { + const {selected} = useItemContext() + const t = useTheme() + + // eslint-disable-next-line bsky-internal/avoid-unwrapped-text + return ( + + {children} + + ) +} + +export function ItemIndicator({icon: Icon = CheckIcon}: ItemIndicatorProps) { + const {selected} = useItemContext() + + return {selected && } +} diff --git a/src/components/Select/index.web.tsx b/src/components/Select/index.web.tsx new file mode 100644 index 0000000000..e9d26631c5 --- /dev/null +++ b/src/components/Select/index.web.tsx @@ -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(null) + +export function Root(props: RootProps) { + return ( + + + + ) +} + +const RadixTriggerPassThrough = forwardRef( + ( + props: { + children: ( + props: RadixPassThroughTriggerProps & { + ref: React.Ref + }, + ) => 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 ( + + + {props => + children({ + isNative: false, + state: { + hovered, + focused, + pressed: false, + }, + props: { + ...props, + onFocus: onFocus, + onBlur: onBlur, + onMouseEnter, + onMouseLeave, + accessibilityLabel: label, + }, + }) + } + + + ) + } else { + return ( + + {children} + + ) + } +} + +export function ValueText({children: _, style, ...props}: ValueProps) { + return ( + + + + ) +} + +export function Icon({style}: IconProps) { + const t = useTheme() + return ( + + + + ) +} + +export function Content({items, renderItem}: ContentProps) { + 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 ( + + + + + + + + {items.map((item, index) => renderItem(item, index, selectedValue))} + + + + + + + + ) +} + +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 ( + + {children} + + ) +} + +export const ItemText = RadixSelect.ItemText + +export function ItemIndicator({icon: Icon = CheckIcon}: ItemIndicatorProps) { + return ( + + + + ) +} diff --git a/src/components/Select/types.ts b/src/components/Select/types.ts new file mode 100644 index 0000000000..5c1b80a3be --- /dev/null +++ b/src/components/Select/types.ts @@ -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 +} + +/* + * 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 = { + /** + * 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}) => ( + * + * + * {label} + * + * )} + * ``` + */ + 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 + value: string + label: string + children: React.ReactNode + style?: StyleProp +} + +export type ItemTextProps = { + children: React.ReactNode +} + +export type ItemIndicatorProps = { + icon?: React.ComponentType +} diff --git a/src/lib/media/manip.ts b/src/lib/media/manip.ts index 911554a10f..13f20ad61e 100644 --- a/src/lib/media/manip.ts +++ b/src/lib/media/manip.ts @@ -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 { if (img.size < maxSize) { return img diff --git a/src/lib/media/manip.web.ts b/src/lib/media/manip.web.ts index ffcf0c5335..f23b955d66 100644 --- a/src/lib/media/manip.web.ts +++ b/src/lib/media/manip.web.ts @@ -1,3 +1,5 @@ +/// + import {type PickerImage} from './picker.shared' import {type Dimensions} from './types' import {blobToDataUri, getDataUriSize} from './util' diff --git a/src/lib/media/picker.shared.ts b/src/lib/media/picker.shared.ts index 21e6808324..8fd76f4145 100644 --- a/src/lib/media/picker.shared.ts +++ b/src/lib/media/picker.shared.ts @@ -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, diff --git a/src/lib/media/picker.web.tsx b/src/lib/media/picker.web.tsx index b7d0d6f06d..c1e4e4ab7f 100644 --- a/src/lib/media/picker.web.tsx +++ b/src/lib/media/picker.web.tsx @@ -1,35 +1,18 @@ -/// - 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 { - // 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 { - // 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.', + ) } diff --git a/src/screens/Profile/Header/EditProfileDialog.tsx b/src/screens/Profile/Header/EditProfileDialog.tsx index 8a9f0d5404..8d86a023b1 100644 --- a/src/screens/Profile/Header/EditProfileDialog.tsx +++ b/src/screens/Profile/Header/EditProfileDialog.tsx @@ -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({ Save + {isUpdatingProfile && } ), [ diff --git a/src/screens/Profile/Header/ProfileHeaderLabeler.tsx b/src/screens/Profile/Header/ProfileHeaderLabeler.tsx index d355b99873..cdb95423ff 100644 --- a/src/screens/Profile/Header/ProfileHeaderLabeler.tsx +++ b/src/screens/Profile/Header/ProfileHeaderLabeler.tsx @@ -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}> diff --git a/src/screens/Profile/Header/ProfileHeaderStandard.tsx b/src/screens/Profile/Header/ProfileHeaderStandard.tsx index 1c4c4d9f32..2dff101e6d 100644 --- a/src/screens/Profile/Header/ProfileHeaderStandard.tsx +++ b/src/screens/Profile/Header/ProfileHeaderStandard.tsx @@ -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]}> diff --git a/src/screens/Settings/LanguageSettings.tsx b/src/screens/Settings/LanguageSettings.tsx index 7266dda4ae..d035e65926 100644 --- a/src/screens/Settings/LanguageSettings.tsx +++ b/src/screens/Settings/LanguageSettings.tsx @@ -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 export function LanguageSettingsScreen({}: Props) { const {_} = useLingui() @@ -32,7 +39,7 @@ export function LanguageSettingsScreen({}: Props) { }, [openModal]) const onChangePrimaryLanguage = useCallback( - (value: Parameters[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[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. - - Boolean(l.code2)).map(l => ({ + + + + + + ( + + + {label} + + )} + 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, - }, - }} /> - - - - - + @@ -171,77 +125,26 @@ export function LanguageSettingsScreen({}: Props) { Select your preferred language for translations in your feed. - - Boolean(l.code2)).map(l => ({ + + + + + + ( + + + {label} + + )} + 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, - }, - }} /> - - - - - + diff --git a/src/screens/Signup/index.tsx b/src/screens/Signup/index.tsx index c980400104..03f4e2cdd2 100644 --- a/src/screens/Signup/index.tsx +++ b/src/screens/Signup/index.tsx @@ -184,10 +184,14 @@ export function Signup({onPressBack}: {onPressBack: () => void}) { + style={[a.w_full, a.py_lg, a.flex_row, a.gap_md, a.align_center]}> + style={[ + a.flex_1, + t.atoms.text_contrast_medium, + !gtMobile && a.text_md, + ]}> Having trouble?{' '} { +export async function compressImage(img: ComposerImage): Promise { 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 { ) 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 diff --git a/src/state/modals/index.tsx b/src/state/modals/index.tsx index 3e738898a3..7ebcec4c79 100644 --- a/src/state/modals/index.tsx +++ b/src/state/modals/index.tsx @@ -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([]) @@ -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, diff --git a/src/state/queries/list.ts b/src/state/queries/list.ts index 38e8cd62d8..c0d5edfb11 100644 --- a/src/state/queries/list.ts +++ b/src/state/queries/list.ts @@ -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() diff --git a/src/state/queries/profile.ts b/src/state/queries/profile.ts index 9f40ab7f61..eb65fef7c2 100644 --- a/src/state/queries/profile.ts +++ b/src/state/queries/profile.ts @@ -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, ) => Un$Typed) - newUserAvatar?: PickerImage | undefined | null - newUserBanner?: PickerImage | undefined | null + newUserAvatar?: ImageMeta | undefined | null + newUserBanner?: ImageMeta | undefined | null checkCommitted?: (res: AppBskyActorGetProfile.Response) => boolean } export function useProfileUpdateMutation() { diff --git a/src/style.css b/src/style.css index 9d72db4a1a..53e4d369b0 100644 --- a/src/style.css +++ b/src/style.css @@ -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); +} diff --git a/src/view/com/auth/SplashScreen.tsx b/src/view/com/auth/SplashScreen.tsx index 9de36665cb..220f38fdd4 100644 --- a/src/view/com/auth/SplashScreen.tsx +++ b/src/view/com/auth/SplashScreen.tsx @@ -78,7 +78,9 @@ export const SplashScreen = ({ a.justify_center, a.align_center, ]}> - + + + diff --git a/src/view/com/auth/SplashScreen.web.tsx b/src/view/com/auth/SplashScreen.web.tsx index 30ebb391fc..f3488e485d 100644 --- a/src/view/com/auth/SplashScreen.web.tsx +++ b/src/view/com/auth/SplashScreen.web.tsx @@ -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, diff --git a/src/view/com/composer/photos/EditImageDialog.tsx b/src/view/com/composer/photos/EditImageDialog.tsx index 4263587fd4..9799f7b82f 100644 --- a/src/view/com/composer/photos/EditImageDialog.tsx +++ b/src/view/com/composer/photos/EditImageDialog.tsx @@ -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 => { diff --git a/src/view/com/composer/photos/EditImageDialog.web.tsx b/src/view/com/composer/photos/EditImageDialog.web.tsx index ebe528abc0..9a170736a3 100644 --- a/src/view/com/composer/photos/EditImageDialog.web.tsx +++ b/src/view/com/composer/photos/EditImageDialog.web.tsx @@ -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 ( - + ) } -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}>(null) + + const cancelButton = useCallback( + () => ( + + ), + [control, _, pending], + ) + + const saveButton = useCallback( + () => ( + + ), + [_, pending], + ) + + return ( + + + Edit image + + + }> + {image && ( + + )} + + ) +} + +function EditImageInner({ + image, + onChange, + saveRef, + circularCrop = false, + aspectRatio, +}: Required> & + Omit & { + saveRef: React.RefObject<{save: () => Promise}> + }) { + 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 ( - - - - - Edit image - - - - setCrop(percentCrop)} - className="ReactCrop--no-animate"> - - - - - - - - + + setCrop(percentCrop)} + className="ReactCrop--no-animate" + onDragStart={() => setIsDragging(true)} + onDragEnd={() => setIsDragging(false)}> + + + {/* Eat clicks when dragging, otherwise mousing up over the backdrop + causes the dialog to close */} + {isDragging && } + ) } diff --git a/src/view/com/composer/videos/SubtitleDialog.tsx b/src/view/com/composer/videos/SubtitleDialog.tsx index 13d1b7ce55..298e70896a 100644 --- a/src/view/com/composer/videos/SubtitleDialog.tsx +++ b/src/view/com/composer/videos/SubtitleDialog.tsx @@ -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} - ({ - 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}}> + + {otherLanguages.map(lang => ( + + ))} + diff --git a/src/view/com/modals/CreateOrEditList.tsx b/src/view/com/modals/CreateOrEditList.tsx index a7eae15ddb..3687dce901 100644 --- a/src/view/com/modals/CreateOrEditList.tsx +++ b/src/view/com/modals/CreateOrEditList.tsx @@ -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(list?.avatar) - const [newAvatar, setNewAvatar] = useState() + const [newAvatar, setNewAvatar] = useState() 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)) } diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx index 8fd927f167..1ec4052d09 100644 --- a/src/view/com/modals/Modal.tsx +++ b/src/view/com/modals/Modal.tsx @@ -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 = - } else if (activeModal?.name === 'create-or-edit-list') { + if (activeModal?.name === 'create-or-edit-list') { snapPoints = CreateOrEditListModal.snapPoints element = } else if (activeModal?.name === 'user-add-remove-lists') { diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx index 5247800990..3374c31327 100644 --- a/src/view/com/modals/Modal.web.tsx +++ b/src/view/com/modals/Modal.web.tsx @@ -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 = - } else if (modal.name === 'create-or-edit-list') { + if (modal.name === 'create-or-edit-list') { element = } else if (modal.name === 'user-add-remove-lists') { element = - } else if (modal.name === 'crop-image') { - element = } else if (modal.name === 'delete-account') { element = } else if (modal.name === 'invite-codes') { diff --git a/src/view/com/posts/PostFeed.tsx b/src/view/com/posts/PostFeed.tsx index 3a6b8f6601..181b35026d 100644 --- a/src/view/com/posts/PostFeed.tsx +++ b/src/view/com/posts/PostFeed.tsx @@ -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(), + ) + 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) => { + 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 } else { return null } @@ -700,6 +749,7 @@ let PostFeed = ({ feedUriOrActorDid, feedTab, feedCacheKey, + onPressShowLess, ], ) diff --git a/src/view/com/posts/PostFeedItem.tsx b/src/view/com/posts/PostFeedItem.tsx index 499b9ccd56..facd31e5fc 100644 --- a/src/view/com/posts/PostFeedItem.tsx +++ b/src/view/com/posts/PostFeedItem.tsx @@ -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 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 ( @@ -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( post.record, @@ -482,7 +493,7 @@ let PostContent = ({ : [] }, [post, currentAccount?.did, threadgateHiddenReplies]) - const onPressShowMore = React.useCallback(() => { + const onPressShowMore = useCallback(() => { setLimitLines(false) }, [setLimitLines]) diff --git a/src/view/com/posts/ShowLessFollowup.tsx b/src/view/com/posts/ShowLessFollowup.tsx new file mode 100644 index 0000000000..01412b9a13 --- /dev/null +++ b/src/view/com/posts/ShowLessFollowup.tsx @@ -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 ( + + + + + + Thank you for your feedback! It has been sent to the feed operator. + + + + + ) +} diff --git a/src/view/com/util/EventStopper.tsx b/src/view/com/util/EventStopper.tsx index 8f5f5cf54d..24bc47afd2 100644 --- a/src/view/com/util/EventStopper.tsx +++ b/src/view/com/util/EventStopper.tsx @@ -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 diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx index 2450c111b6..326a2fff8a 100644 --- a/src/view/com/util/UserAvatar.tsx +++ b/src/view/com/util/UserAvatar.tsx @@ -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 ( - + ) - }, [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() + 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 ( - - - {({props}) => ( - - {avatar ? ( - - ) : ( - - )} - - - - - )} - - - - {isNative && ( - - - Upload from Camera - - - - )} + const onChangeEditImage = useCallback( + async (image: ComposerImage) => { + const compressed = await compressImage(image) + onSelectNewAvatar(compressed) + }, + [onSelectNewAvatar], + ) - - - {isNative ? ( - Upload from Library + return ( + <> + + + {({props}) => ( + + {avatar ? ( + ) : ( - Upload from Files + )} - - - - - {!!avatar && ( - <> - - + + + + + )} + + + + {isNative && ( + testID="changeAvatarCameraBtn" + label={_(msg`Upload from Camera`)} + onPress={onOpenCamera}> - Remove Avatar + Upload from Camera - + - - - )} - - + )} + + + + {isNative ? ( + Upload from Library + ) : ( + Upload from Files + )} + + + + + {!!avatar && ( + <> + + + + + Remove Avatar + + + + + + )} + + + + + ) } 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, }, }) diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx index ab7f25b800..3600f5c24e 100644 --- a/src/view/com/util/UserBanner.tsx +++ b/src/view/com/util/UserBanner.tsx @@ -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() + 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 ? ( - - - - {({props}) => ( - - {banner ? ( - - ) : ( - - )} - - - - - )} - - - - {isNative && ( - - - Upload from Camera - - - - )} - - - - {isNative ? ( - Upload from Library + <> + + + + {({props}) => ( + + {banner ? ( + ) : ( - Upload from Files + )} - - - - - {!!banner && ( - <> - - + + + + + )} + + + + {isNative && ( + testID="changeBannerCameraBtn" + label={_(msg`Upload from Camera`)} + onPress={onOpenCamera}> - Remove Banner + Upload from Camera - + - - - )} - - - + )} + + + + {isNative ? ( + Upload from Library + ) : ( + Upload from Files + )} + + + + + {!!banner && ( + <> + + + + + Remove Banner + + + + + + )} + + + + + + ) : banner && !((moderation?.blur && isAndroid) /* android crashes with blur */) ? ( @@ -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} /> )} diff --git a/src/view/com/util/forms/PostDropdownBtnMenuItems.tsx b/src/view/com/util/forms/PostDropdownBtnMenuItems.tsx index 6be813e365..9c3d709d93 100644 --- a/src/view/com/util/forms/PostDropdownBtnMenuItems.tsx +++ b/src/view/com/util/forms/PostDropdownBtnMenuItems.tsx @@ -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 @@ -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 ( <> @@ -539,6 +558,19 @@ let PostDropdownMenuItems = ({ {_(msg`Show less like this`)} + + {IS_INTERNAL && + DISCOVER_DEBUG_DIDS[currentAccount?.did ?? ''] && ( + + + {_(msg`Report topic misclassification`)} + + + + )} )} diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx index fe583e8017..d97654a633 100644 --- a/src/view/com/util/post-ctrls/PostCtrls.tsx +++ b/src/view/com/util/post-ctrls/PostCtrls.tsx @@ -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 @@ -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} /> {isDiscoverDebugUser && feedContext && ( diff --git a/src/view/shell/NavSignupCard.tsx b/src/view/shell/NavSignupCard.tsx index e32c24dc43..000f5824c0 100644 --- a/src/view/shell/NavSignupCard.tsx +++ b/src/view/shell/NavSignupCard.tsx @@ -65,7 +65,7 @@ let NavSignupCard = ({}: {}): React.ReactNode => { - + ) diff --git a/src/view/shell/desktop/RightNav.tsx b/src/view/shell/desktop/RightNav.tsx index 18ce42ee8b..26795e0fd9 100644 --- a/src/view/shell/desktop/RightNav.tsx +++ b/src/view/shell/desktop/RightNav.tsx @@ -137,7 +137,7 @@ export function DesktopRightNav({routeName}: {routeName: string}) { {!hasSession && leftNavMinimal && ( - + )} diff --git a/yarn.lock b/yarn.lock index c01b6e116c..8f2aaf3518 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"