diff --git a/__tests__/lib/string.test.ts b/__tests__/lib/string.test.ts index 753262a4f6..38966c35cb 100644 --- a/__tests__/lib/string.test.ts +++ b/__tests__/lib/string.test.ts @@ -453,6 +453,7 @@ describe('parseEmbedPlayerFromUrl', () => { 'https://bandcamp.com', 'https://static.klipy.com/ii/abc123/73/ac/someFile.gif?hh=200&ww=300', + 'https://static.klipy.com/ii/abc123/73/ac/someFile.gif?hh=200&ww=300&mp4=videoSlugMp4&webm=videoSlugWebm', 'https://static.klipy.com/ii/abc123/73/ac/someFile.gif?hh=200', 'https://static.klipy.com/ii/abc123/73/ac/someFile.gif', 'https://static.klipy.com/other/path.gif?hh=200&ww=300', @@ -853,6 +854,19 @@ describe('parseEmbedPlayerFromUrl', () => { undefined, undefined, + { + type: 'klipy_gif', + source: 'klipy', + isGif: true, + hideDetails: true, + playerUri: 'https://k.gifs.bsky.app/ii/abc123/73/ac/someFile.gif', + dimensions: { + width: 300, + height: 200, + }, + }, + // With video slug params — on native (test env), keeps gif filename, + // strips mp4/webm params. On web, would swap to video filename. { type: 'klipy_gif', source: 'klipy', diff --git a/assets/icons/messagePlus_stroke2_corner0_rounded.svg b/assets/icons/messagePlus_stroke2_corner0_rounded.svg new file mode 100644 index 0000000000..bf9e277fb8 --- /dev/null +++ b/assets/icons/messagePlus_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/bskyembed/src/color-mode.ts b/bskyembed/src/color-mode.ts index b34624e312..3b9219cb3c 100644 --- a/bskyembed/src/color-mode.ts +++ b/bskyembed/src/color-mode.ts @@ -9,7 +9,11 @@ export function applyTheme(theme: 'light' | 'dark') { document.documentElement.classList.add(theme) } -export function initSystemColorMode() { +export function initSystemColorMode({additionalBodyClasses = ''} = {}) { + if (additionalBodyClasses) { + document.body.classList.add(additionalBodyClasses) + } + applyTheme( window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' diff --git a/bskyembed/src/screens/landing.tsx b/bskyembed/src/screens/landing.tsx index b4bb0f7e91..9c7cd68a40 100644 --- a/bskyembed/src/screens/landing.tsx +++ b/bskyembed/src/screens/landing.tsx @@ -28,7 +28,7 @@ export const EMBED_SCRIPT = `${EMBED_SERVICE}/static/embed.js` const root = document.getElementById('app') if (!root) throw new Error('No root element') -initSystemColorMode() +initSystemColorMode({additionalBodyClasses: 'dark:bg-dimmedBgDarken'}) const agent = new AtpAgent({ service: 'https://public.api.bsky.app', @@ -119,7 +119,7 @@ function LandingPage() { }, [uri]) return ( -
+
diff --git a/eslint.config.mjs b/eslint.config.mjs index a0f0db9140..8e7bb941a8 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -250,6 +250,14 @@ export default defineConfig( '@typescript-eslint/prefer-promise-reject-errors': 'warn', '@typescript-eslint/await-thenable': 'warn', + "no-restricted-imports": ["error", { + "paths": [{ + "name": "react", + "importNames": ["React", "default"], + "message": "React is already in the global type namespace. Use named imports for runtime modules." + }] + }], + /** * Turn off rules that we haven't enforced thus far */ diff --git a/modules/bottom-sheet/src/BottomSheetNativeComponent.tsx b/modules/bottom-sheet/src/BottomSheetNativeComponent.tsx index 2604e0c0b8..0c3c700e90 100644 --- a/modules/bottom-sheet/src/BottomSheetNativeComponent.tsx +++ b/modules/bottom-sheet/src/BottomSheetNativeComponent.tsx @@ -1,4 +1,4 @@ -import * as React from 'react' +import {Component, createRef} from 'react' import { Dimensions, type LayoutChangeEvent, @@ -39,14 +39,14 @@ const IS_IOS15 = const IS_NON_E2E_ANDROID = Platform.OS === 'android' && Number(Platform.Version) < 35 -export class BottomSheetNativeComponent extends React.Component< +export class BottomSheetNativeComponent extends Component< BottomSheetViewProps, { open: boolean viewHeight?: number } > { - ref = React.createRef() + ref = createRef() static contextType = PortalContext @@ -129,6 +129,7 @@ export class BottomSheetNativeComponent extends React.Component< function BottomSheetNativeComponentInner({ children, backgroundColor, + maxHeight, onLayout, onStateChange, nativeViewRef, @@ -156,6 +157,7 @@ function BottomSheetNativeComponentInner({ return ( - + {children} diff --git a/package.json b/package.json index 66f6ddd0a6..08e3e22bea 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,7 @@ "icons:optimize": "svgo -f ./assets/icons" }, "dependencies": { - "@atproto/api": "^0.19.8", + "@atproto/api": "^0.19.9", "@bitdrift/react-native": "^0.6.8", "@braintree/sanitize-url": "^6.0.2", "@bsky.app/alf": "^0.1.7", @@ -89,8 +89,8 @@ "@bsky.app/expo-scroll-edge-effect": "^0.1.4", "@bsky.app/expo-translate-text": "^0.2.9", "@bsky.app/react-native-mmkv": "2.12.5", - "@bsky.app/sift": "^0.3.2", - "@bsky.app/tapper": "^0.5.0", + "@bsky.app/sift": "^0.3.3", + "@bsky.app/tapper": "^0.5.1", "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet", "@emoji-mart/data": "^1.2.1", "@emoji-mart/react": "^1.1.1", @@ -275,7 +275,7 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-native": "^5.0.0", "eslint-plugin-react-native-a11y": "^3.5.1", - "eslint-plugin-simple-import-sort": "^12.1.1", + "eslint-plugin-simple-import-sort": "^13.0.0", "file-loader": "6.2.0", "globals": "^17.0.0", "husky": "^8.0.3", diff --git a/patches/react-native-keyboard-controller+1.21.5.patch b/patches/react-native-keyboard-controller+1.21.5.patch new file mode 100644 index 0000000000..d721bbe494 --- /dev/null +++ b/patches/react-native-keyboard-controller+1.21.5.patch @@ -0,0 +1,48 @@ +diff --git a/node_modules/react-native-keyboard-controller/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts b/node_modules/react-native-keyboard-controller/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts +index 24a25ae..2c5ff6d 100644 +--- a/node_modules/react-native-keyboard-controller/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts ++++ b/node_modules/react-native-keyboard-controller/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts +@@ -1,8 +1,6 @@ + import { useCallback } from "react"; +-import { Platform } from "react-native"; + import { scrollTo, useAnimatedReaction } from "react-native-reanimated"; + +-import { IS_FABRIC } from "../../../architecture"; + import { isScrollAtEnd, shouldShiftContent } from "../useChatKeyboard/helpers"; + + import type { KeyboardLiftBehavior } from "../useChatKeyboard/types"; +@@ -52,7 +50,6 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void { + scroll, + layout, + size, +- contentOffsetY, + inverted, + keyboardLiftBehavior, + freeze, +@@ -62,20 +59,14 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void { + (target: number) => { + "worklet"; + +- if (contentOffsetY && IS_FABRIC) { +- // eslint-disable-next-line react-compiler/react-compiler +- contentOffsetY.value = target; +- } else if (Platform.OS === "android") { +- // Defer scrollTo so the animatedProps inset commit lands first; +- // otherwise the native ScrollView clamps to the old range. +- requestAnimationFrame(() => { +- scrollTo(scrollViewRef, 0, target, false); +- }); +- } else { ++ // Always defer scrollTo so the animatedProps inset commit lands first; ++ // otherwise the native ScrollView clamps contentOffset to the old ++ // contentInset range (iOS Fabric) or the old contentInsetBottom (Android). ++ requestAnimationFrame(() => { + scrollTo(scrollViewRef, 0, target, false); +- } ++ }); + }, +- [scrollViewRef, contentOffsetY], ++ [scrollViewRef], + ); + + useAnimatedReaction( diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 7ef150e9e5..6f661e03e9 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -78,6 +78,7 @@ import HashtagScreen from '#/screens/Hashtag' import {LogScreen} from '#/screens/Log' import {MessagesScreen} from '#/screens/Messages/ChatList' import {MessagesConversationScreen} from '#/screens/Messages/Conversation' +import {MessagesConversationSettingsScreen} from '#/screens/Messages/ConversationSettings' import {MessagesInboxScreen} from '#/screens/Messages/Inbox' import {MessagesSettingsScreen} from '#/screens/Messages/Settings' import {ModerationScreen} from '#/screens/Moderation' @@ -568,6 +569,11 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) { getComponent={() => MessagesConversationScreen} options={{title: title(msg`Chat`), requireAuth: true}} /> + MessagesConversationSettingsScreen} + options={{title: title(msg`Group chat settings`), requireAuth: true}} + /> MessagesSettingsScreen} diff --git a/src/ageAssurance/components/NoAccessScreen.tsx b/src/ageAssurance/components/NoAccessScreen.tsx index 600970459a..83b7f474d1 100644 --- a/src/ageAssurance/components/NoAccessScreen.tsx +++ b/src/ageAssurance/components/NoAccessScreen.tsx @@ -20,8 +20,8 @@ import {AgeAssuranceAppealDialog} from '#/components/ageAssurance/AgeAssuranceAp import {AgeAssuranceBadge} from '#/components/ageAssurance/AgeAssuranceBadge' import {AgeAssuranceInitDialog} from '#/components/ageAssurance/AgeAssuranceInitDialog' import {Button, ButtonIcon, ButtonText} from '#/components/Button' -import {useDialogControl} from '#/components/Dialog' import * as Dialog from '#/components/Dialog' +import {useDialogControl} from '#/components/Dialog' import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings' import {DeviceLocationRequestDialog} from '#/components/dialogs/DeviceLocationRequestDialog' import {Full as Logo} from '#/components/icons/Logo' diff --git a/src/analytics/PassiveAnalytics.tsx b/src/analytics/PassiveAnalytics.tsx index 25dfea929a..34b5f2d275 100644 --- a/src/analytics/PassiveAnalytics.tsx +++ b/src/analytics/PassiveAnalytics.tsx @@ -2,6 +2,8 @@ import {useEffect, useRef} from 'react' import {getCurrentState, onAppStateChange} from '#/lib/appState' import {useAnalytics} from '#/analytics' +import {Features, features} from '#/analytics/features' +import {IS_DEV, IS_TESTFLIGHT} from '#/env' /** * Tracks passive analytics like app foreground/background time. @@ -24,6 +26,20 @@ export function PassiveAnalytics() { ), }) } + + if (IS_DEV || IS_TESTFLIGHT) { + const feats = Object.values(Features).reduce( + (acc, feat) => { + acc[feat] = features.evalFeature(feat) + return acc + }, + {} as Record, + ) + ax.logger.info('FEATURES', { + features: feats, + definitions: features.getFeatures(), + }) + } }) return () => sub.remove() }, [ax]) diff --git a/src/analytics/features/index.ts b/src/analytics/features/index.ts index afd2d2b089..4644d7f1b4 100644 --- a/src/analytics/features/index.ts +++ b/src/analytics/features/index.ts @@ -2,11 +2,13 @@ import {MMKV} from '@bsky.app/react-native-mmkv' import {setPolyfills} from '@growthbook/growthbook' import {GrowthBook} from '@growthbook/growthbook-react' +import {Logger} from '#/logger' import {getNavigationMetadata, type Metadata} from '#/analytics/metadata' import * as env from '#/env' export {Features} from '#/analytics/features/types' +const logger = Logger.create(Logger.Context.Growthbook) const CACHE = new MMKV({id: 'bsky_features_cache'}) setPolyfills({ @@ -44,7 +46,13 @@ export const features = new GrowthBook({ * initialization completes. */ export const init = new Promise(async y => { - await features.init({timeout: TIMEOUT_INIT}) + const res = await features.init({timeout: TIMEOUT_INIT}) + if (!res.success) { + logger.warn('GrowthBook initialization failed or timed out', { + source: res.source, + safeMessage: res.error?.toString(), + }) + } y() }) diff --git a/src/analytics/features/types.ts b/src/analytics/features/types.ts index 7c87c75917..3dbd2ba0fc 100644 --- a/src/analytics/features/types.ts +++ b/src/analytics/features/types.ts @@ -14,6 +14,7 @@ export enum Features { GroupChatsEnable = 'group_chats:enable', DmsNewMessageComposerEnable = 'dms:new_message_composer:enable', KlipyGifProviderEnable = 'klipy_gif_provider:enable', + PostGalleryEmbedEnable = 'post_gallery_embed:enable', AATest = 'aa-test', } diff --git a/src/analytics/metrics/types.ts b/src/analytics/metrics/types.ts index 1f6b25278b..1d74e1c5b8 100644 --- a/src/analytics/metrics/types.ts +++ b/src/analytics/metrics/types.ts @@ -563,6 +563,9 @@ export type Events = { | 'ChatsList' | 'SendViaChatDialog' } + 'groupchat:create': { + logContext: 'NewChatDialog' + } 'starterPack:addUser': { starterPack?: string } @@ -1043,4 +1046,19 @@ export type Events = { 'profile:associated:germ:click-self-info': {} 'profile:associated:germ:self-disconnect': {} 'profile:associated:germ:self-reconnect': {} + + // Gallery carousel events + 'post:gallery:swipe': { + fromImage: number + toImage: number + totalImages: number + } + 'post:gallery:openLightbox': { + fromImage: number + totalImages: number + } + 'post:gallery:impression': { + totalImages: number + postUri: string + } } diff --git a/src/components/Autocomplete/Autocomplete.tsx b/src/components/Autocomplete/Autocomplete.tsx index daee24847d..f8cbfd6dd4 100644 --- a/src/components/Autocomplete/Autocomplete.tsx +++ b/src/components/Autocomplete/Autocomplete.tsx @@ -58,7 +58,17 @@ export function Autocomplete({ data={data} onSelect={onSelect} onDismiss={onDismiss} - style={[ + outerStyle={[ + a.rounded_md, + a.w_full, + t.atoms.shadow_lg, + IS_WEB + ? { + maxWidth: 300, + } + : {}, + ]} + innerStyle={[ a.overflow_hidden, a.rounded_md, a.border, diff --git a/src/components/AvatarBubbles.tsx b/src/components/AvatarBubbles.tsx new file mode 100644 index 0000000000..44fd7f4e90 --- /dev/null +++ b/src/components/AvatarBubbles.tsx @@ -0,0 +1,274 @@ +import {useCallback, useEffect} from 'react' +import {type StyleProp, View, type ViewStyle} from 'react-native' +import Animated, { + Easing, + interpolate, + useAnimatedStyle, + useSharedValue, + withDelay, + withTiming, +} from 'react-native-reanimated' + +import {useSession} from '#/state/session' +import {UserAvatar} from '#/view/com/util/UserAvatar' +import {atoms as a, useTheme} from '#/alf' +import {Person_Filled_Corner2_Rounded as PersonIcon} from '#/components/icons/Person' +import type * as bsky from '#/types/bsky' + +type Props = { + animate?: boolean + profiles: bsky.profile.AnyProfileView[] + size?: 'small' | 'medium' | 'large' | number +} + +export function AvatarBubbles({ + animate = false, + profiles: allProfiles, + size = 'large', +}: Props) { + const {currentAccount} = useSession() + const profiles = allProfiles.filter(p => p.did !== currentAccount?.did) + const containerSize = + typeof size === 'number' + ? size + : size === 'small' + ? 40 + : size === 'medium' + ? 56 + : 120 + const scale = + typeof size === 'number' + ? size / 120 + : size === 'small' + ? 40 / 120 + : size === 'medium' + ? 56 / 120 + : 1 + const marginOffset = size === 'small' || size === 'medium' ? -2 : 0 + + const initialValue = animate ? 0 : 1 + const p0 = useSharedValue(initialValue) + const p1 = useSharedValue(initialValue) + const p2 = useSharedValue(initialValue) + const p3 = useSharedValue(initialValue) + + const animateScale = (p: Animated.SharedValue, index: number) => { + p.set(0) + p.set(() => + withDelay( + 500 + index * 100, + withTiming(1, { + duration: 250, + easing: Easing.out(Easing.back(1.75)), + }), + ), + ) + } + + const playScaleAnimation = useCallback(() => { + animateScale(p0, 0) + animateScale(p1, 1) + animateScale(p2, 2) + animateScale(p3, 3) + }, [p0, p1, p2, p3]) + + useEffect(() => { + if (!animate) return + playScaleAnimation() + }, [animate, playScaleAnimation]) + + let avatars = ( + <> + + + + ) + + if (profiles.length === 3) { + avatars = ( + <> + + + + + ) + } + + if (profiles.length >= 4) { + avatars = ( + <> + + + + + + ) + } + + return ( + + + {avatars} + + + ) +} + +function AvatarBubble({ + profile, + scale, + size, + style, + x, + y, + includeProfileBorder, +}: { + profile?: bsky.profile.AnyProfileView + scale: Animated.SharedValue + size: number + style?: StyleProp + x: number + y: number + includeProfileBorder?: boolean +}) { + const t = useTheme() + + const animatedStyle = useAnimatedStyle(() => ({ + transform: [ + {translateX: x}, + {translateY: y}, + {scale: interpolate(scale.get(), [0, 1], [0, 1])}, + ], + })) + + return ( + + {profile ? ( + + ) : ( + + )} + + ) +} + +function Avatar({ + profile, + size = 76, +}: { + profile: bsky.profile.AnyProfileView + size?: number +}) { + return ( + + ) +} + +function AvatarPlaceholder({size = 76}: {size?: number}) { + const t = useTheme() + + return ( + + + + ) +} diff --git a/src/components/ContextMenu/index.tsx b/src/components/ContextMenu/index.tsx index e94eaf7795..ea4badde82 100644 --- a/src/components/ContextMenu/index.tsx +++ b/src/components/ContextMenu/index.tsx @@ -235,7 +235,13 @@ export function Root({children}: {children: React.ReactNode}) { return {children} } -export function Trigger({children, label, contentLabel, style}: TriggerProps) { +export function Trigger({ + children, + label, + contentLabel, + style, + onTap, +}: TriggerProps) { const context = useContextMenuContext() const playHaptic = useHaptics() const insets = useSafeAreaInsets() @@ -294,6 +300,17 @@ export function Trigger({children, label, contentLabel, style}: TriggerProps) { } }, [context, insets]) + const tapGesture = useMemo(() => { + const gesture = Gesture.Tap() + .numberOfTaps(1) + .cancelsTouchesInView(false) + .runOnJS(true) + if (onTap) { + gesture.onEnd(() => void onTap()) + } + return gesture + }, [onTap]) + const doubleTapGesture = useMemo(() => { return Gesture.Tap() .numberOfTaps(2) @@ -346,8 +363,10 @@ export function Trigger({children, label, contentLabel, style}: TriggerProps) { }) }, [open, hoverablesSV, onTouchUpMenuItem, hoveredItemSV, translationSV]) + // Order matters here: doubleTapGesture must come before tapGesture. const composedGestures = Gesture.Exclusive( doubleTapGesture, + tapGesture, pressAndHoldGesture, ) @@ -482,7 +501,11 @@ function TriggerClone({ ) } -export function AuxiliaryView({children, align = 'left'}: AuxiliaryViewProps) { +export function AuxiliaryView({ + children, + align = 'left', + style, +}: AuxiliaryViewProps) { const context = useContextMenuContext() const {width: screenWidth} = useWindowDimensions() const {top: topInset} = useSafeAreaInsets() @@ -556,6 +579,7 @@ export function AuxiliaryView({children, align = 'left'}: AuxiliaryViewProps) { : {right: screenWidth - measurement.x - measurement.width}, animatedStyle, a.z_20, + style, ]}> {children} diff --git a/src/components/ContextMenu/types.ts b/src/components/ContextMenu/types.ts index 7d4f3019a8..e2f1522d1e 100644 --- a/src/components/ContextMenu/types.ts +++ b/src/components/ContextMenu/types.ts @@ -21,6 +21,7 @@ export type { export type AuxiliaryViewProps = { children?: React.ReactNode align?: 'left' | 'right' + style?: StyleProp } export type ItemProps = Omit & { @@ -83,6 +84,14 @@ export type TriggerProps = { hint?: string role?: AccessibilityRole style?: StyleProp + /** + * Callback for single taps. Composed with the double-tap and + * press-and-hold gestures via `Gesture.Exclusive`, so a double tap + * does not also fire this handler. + * + * @platform ios, android + */ + onTap?: () => void } export type TriggerChildProps = | { diff --git a/src/components/Dialog/context.ts b/src/components/Dialog/context.ts index b7e3c78d5e..5c36af6ae5 100644 --- a/src/components/Dialog/context.ts +++ b/src/components/Dialog/context.ts @@ -23,6 +23,7 @@ export const Context = createContext({ disableDrag: false, setDisableDrag: () => {}, isWithinDialog: false, + isHeightConstrained: false, }) Context.displayName = 'DialogContext' diff --git a/src/components/Dialog/index.tsx b/src/components/Dialog/index.tsx index 3b1048240b..af724a4ab2 100644 --- a/src/components/Dialog/index.tsx +++ b/src/components/Dialog/index.tsx @@ -157,6 +157,8 @@ export function Outer({ [open, close], ) + const isHeightConstrained = nativeOptions?.maxHeight != null + const context = useMemo( () => ({ close, @@ -165,8 +167,9 @@ export function Outer({ disableDrag, setDisableDrag, isWithinDialog: true, + isHeightConstrained, }), - [close, snapPoint, disableDrag, setDisableDrag], + [close, snapPoint, disableDrag, setDisableDrag, isHeightConstrained], ) return ( @@ -180,7 +183,9 @@ export function Outer({ onStateChange={onStateChange} disableDrag={disableDrag}> - + {children} @@ -213,10 +218,11 @@ export function Inner({children, style, header}: DialogInnerProps) { export const ScrollableInner = forwardRef( function ScrollableInner( - {children, contentContainerStyle, header, ...props}, + {children, contentContainerStyle, header, style, ...props}, ref, ) { - const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext() + const {nativeSnapPoint, disableDrag, setDisableDrag, isHeightConstrained} = + useDialogContext() const isAtMaxSnapPoint = nativeSnapPoint === BottomSheetSnapPoint.Full const insets = useSafeAreaInsets() const [keyboardHeight, setKeyboardHeight] = useState(() => @@ -243,6 +249,7 @@ export const ScrollableInner = forwardRef( return ( {}, isWithinDialog: true, + isHeightConstrained: false, }), [close], ) @@ -196,6 +197,7 @@ export function Inner({ a.border, t.atoms.bg, { + cursor: 'default', // The overlay applies `cursor: 'pointer'` to all children. maxWidth: 600, borderColor: t.palette.contrast_200, shadowColor: t.palette.black, diff --git a/src/components/Dialog/types.ts b/src/components/Dialog/types.ts index 938d7c744d..865083d501 100644 --- a/src/components/Dialog/types.ts +++ b/src/components/Dialog/types.ts @@ -45,6 +45,7 @@ export type DialogContextProps = { setDisableDrag: React.Dispatch> // in the event that the hook is used outside of a dialog isWithinDialog: boolean + isHeightConstrained: boolean } export type DialogControlOpenOptions = { diff --git a/src/components/EmojiPicker/index.tsx b/src/components/EmojiPicker/index.tsx new file mode 100644 index 0000000000..facc131275 --- /dev/null +++ b/src/components/EmojiPicker/index.tsx @@ -0,0 +1,40 @@ +import {type PickerProps, type RootProps, type TriggerProps} from './types' + +export * from './types' + +/** + * Provides emoji picker context and wraps children in a {@link Menu.Root}. + * + * On emoji select, fires a `textInputWebEmitter` event (for web text inputs + * that listen for emoji insertions) and forwards to the optional + * `onEmojiSelect` callback. + * + * @platform web + */ +export function Root(_props: RootProps): React.ReactNode { + throw new Error('EmojiPopup is not implemented on native') +} + +/** + * Passthrough to {@link Menu.Trigger}. Accepts the same render-prop children + * pattern. + * + * @platform web + */ +export function Trigger(_props: TriggerProps): React.ReactNode { + throw new Error('EmojiPopup is not implemented on native') +} + +/** + * Renders the emoji picker inside a Radix `DropdownMenu.Portal`. + * + * Holding Shift while selecting an emoji keeps the picker open for + * multi-select. Otherwise the menu closes after each selection. + * + * Must be rendered inside a {@link Root}. + * + * @platform web + */ +export function Picker(_props: PickerProps): React.ReactNode { + throw new Error('EmojiPopup is not implemented on native') +} diff --git a/src/components/EmojiPicker/index.web.tsx b/src/components/EmojiPicker/index.web.tsx new file mode 100644 index 0000000000..8d5e7788ec --- /dev/null +++ b/src/components/EmojiPicker/index.web.tsx @@ -0,0 +1,150 @@ +import {createContext, useContext, useEffect, useMemo, useRef} from 'react' +import EmojiPicker from '@emoji-mart/react' +import {DropdownMenu} from 'radix-ui' + +import {useA11y} from '#/state/a11y' +import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter' +import {atoms as a, flatten} from '#/alf' +import * as Menu from '../Menu' +import {useWebPreloadEmoji} from './preload' +import { + type Emoji, + type PickerProps, + type RootProps, + type TriggerProps, +} from './types' + +export * from './types' + +const EmojiPickerContext = createContext<{ + onEmojiSelect: (emoji: Emoji) => void + nextFocusRef: RootProps['nextFocusRef'] +} | null>(null) + +/** + * Provides emoji picker context and wraps children in a {@link Menu.Root}. + * + * On emoji select, fires a `textInputWebEmitter` event (for web text inputs + * that listen for emoji insertions) and forwards to the optional + * `onEmojiSelect` callback. + * + * @platform web + */ +export function Root({ + children, + control, + onEmojiSelect, + preloadOnMount = true, + nextFocusRef, +}: RootProps) { + useWebPreloadEmoji({immediate: preloadOnMount}) + + const value = useMemo( + () => ({ + onEmojiSelect: (emoji: Emoji) => { + textInputWebEmitter.emit('emoji-inserted', emoji) + + if (onEmojiSelect) onEmojiSelect(emoji) + }, + nextFocusRef, + }), + [onEmojiSelect, nextFocusRef], + ) + + return ( + + {children} + + ) +} + +/** + * Passthrough to {@link Menu.Trigger}. Accepts the same render-prop children + * pattern. + * + * @platform web + */ +export function Trigger(props: TriggerProps) { + return +} + +/** + * Renders the emoji picker inside a Radix `DropdownMenu.Portal`. + * + * Holding Shift while selecting an emoji keeps the picker open for + * multi-select. Otherwise the menu closes after each selection. + * + * Must be rendered inside a {@link Root}. + * + * @platform web + */ +export function Picker({keepOpenWhenShiftHeld = true}: PickerProps) { + const {onEmojiSelect, nextFocusRef} = useEmojiPickerContext() + const {control} = Menu.useMenuContext() + const {reduceMotionEnabled} = useA11y() + const isShiftDown = useRef(false) + + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Shift') { + isShiftDown.current = true + } + } + const onKeyUp = (e: KeyboardEvent) => { + if (e.key === 'Shift') { + isShiftDown.current = false + } + } + window.addEventListener('keydown', onKeyDown, true) + window.addEventListener('keyup', onKeyUp, true) + + return () => { + window.removeEventListener('keydown', onKeyDown, true) + window.removeEventListener('keyup', onKeyUp, true) + } + }, []) + + return ( + + { + if (!nextFocusRef) return + let element = + nextFocusRef instanceof Function + ? nextFocusRef() + : nextFocusRef.current + if (element) { + evt.preventDefault() + element.focus() + } + }}> +
evt.stopPropagation()} + style={flatten([!reduceMotionEnabled && a.zoom_fade_in])}> + { + onEmojiSelect(emoji) + + if (!keepOpenWhenShiftHeld || !isShiftDown.current) { + control.close() + } + }} + /> +
+
+
+ ) +} + +function useEmojiPickerContext() { + const ctx = useContext(EmojiPickerContext) + if (!ctx) + throw new Error( + 'EmojiPicker.Picker must be used within an EmojiPicker.Root component', + ) + return ctx +} diff --git a/src/components/EmojiPicker/preload.ts b/src/components/EmojiPicker/preload.ts new file mode 100644 index 0000000000..d37216b168 --- /dev/null +++ b/src/components/EmojiPicker/preload.ts @@ -0,0 +1,7 @@ +/** + * Native no-op. Emoji data preloading is only needed on web where the picker + * uses `emoji-mart`. + */ +export function useWebPreloadEmoji({}: {immediate?: boolean} = {}) { + return () => Promise.resolve() +} diff --git a/src/view/com/composer/text-input/web/useWebPreloadEmoji.ts b/src/components/EmojiPicker/preload.web.ts similarity index 52% rename from src/view/com/composer/text-input/web/useWebPreloadEmoji.ts rename to src/components/EmojiPicker/preload.web.ts index 27636a14b4..4456153b71 100644 --- a/src/view/com/composer/text-input/web/useWebPreloadEmoji.ts +++ b/src/components/EmojiPicker/preload.web.ts @@ -7,8 +7,14 @@ import {init} from 'emoji-mart' let loadRequested = false /** - * Preload the emoji picker data to prevent flash. - * {@link https://github.com/missive/emoji-mart/blob/16978d04a766eec6455e2e8bb21cd8dc0b3c7436/README.md?plain=1#L194} + * Preloads emoji-mart data so the picker renders instantly when opened. + * + * Returns a function that can be called manually to trigger preloading (e.g. + * on hover). When `immediate` is `true`, preloading starts on mount. + * + * Data is only fetched once per page load — subsequent calls are no-ops. + * + * @see {@link https://github.com/missive/emoji-mart/blob/16978d04a766eec6455e2e8bb21cd8dc0b3c7436/README.md?plain=1#L194 | emoji-mart preloading docs} */ export function useWebPreloadEmoji({immediate}: {immediate?: boolean} = {}) { const preload = useCallback(async () => { diff --git a/src/components/EmojiPicker/types.ts b/src/components/EmojiPicker/types.ts new file mode 100644 index 0000000000..4b6300ffda --- /dev/null +++ b/src/components/EmojiPicker/types.ts @@ -0,0 +1,65 @@ +import {type DialogControlProps} from '../Dialog' +import {type TriggerProps as MenuTriggerProps} from '../Menu/types' + +/** + * Represents an emoji selected from the picker. Sourced from the `emoji-mart` + * library's selection data. + */ +export type Emoji = { + aliases?: string[] + emoticons: string[] + id: string + keywords: string[] + name: string + /** The native unicode character for the emoji, e.g. "😀" */ + native: string + shortcodes?: string + /** The unicode codepoint, e.g. "1f600" */ + unified: string + /** Skin tone variant (1–6), if applicable */ + skin?: number +} + +type FocusableElement = {focus: () => void} + +export interface RootProps { + children: React.ReactNode + control?: DialogControlProps + /** + * Called when the user selects an emoji. On web this fires in addition to + * the `textInputWebEmitter` event, so callers that only need the text + * insertion can omit this. + */ + onEmojiSelect?: (emoji: Emoji) => void + /** + * When `true` (default), preloads emoji data as soon as the component + * mounts so the picker opens instantly. Set to `false` to defer loading + * until the picker is actually opened. + */ + preloadOnMount?: boolean + /** + * Element to return focus to when the picker closes. Accepts either a ref + * or a getter function. + */ + nextFocusRef?: + | React.RefObject + | (() => FocusableElement | null | undefined) +} + +/** + * Props for the trigger button that opens the emoji picker. Extends + * {@link MenuTriggerProps} — accepts the same render-prop children pattern. + */ +export interface TriggerProps extends MenuTriggerProps {} + +/** + * Props for the picker panel itself. + */ +export interface PickerProps { + /** + * When `true`, the picker will remain open after selecting an emoji when the Shift key is held down. + * + * @default true + */ + keepOpenWhenShiftHeld?: boolean +} diff --git a/src/components/Error.tsx b/src/components/Error.tsx index 04f4034c06..77aacdb451 100644 --- a/src/components/Error.tsx +++ b/src/components/Error.tsx @@ -60,8 +60,7 @@ export function Error({ color="primary" label={_(msg`Press to retry`)} onPress={onRetry} - size="large" - style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]}> + size="large"> Retry @@ -73,8 +72,7 @@ export function Error({ color={onRetry ? 'secondary' : 'primary'} label={_(msg`Return to previous page`)} onPress={goBack} - size="large" - style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]}> + size="large"> Go Back diff --git a/src/components/Post/Embed/ImageEmbed.tsx b/src/components/Post/Embed/ImageEmbed.tsx index a3f0d46377..6ca166ff10 100644 --- a/src/components/Post/Embed/ImageEmbed.tsx +++ b/src/components/Post/Embed/ImageEmbed.tsx @@ -1,19 +1,15 @@ import {InteractionManager, View} from 'react-native' -import { - type AnimatedRef, - measure, - type MeasuredDimensions, - runOnJS, - runOnUI, -} from 'react-native-reanimated' +import {type AnimatedRef} from 'react-native-reanimated' import {Image} from 'expo-image' import {useLightboxControls} from '#/state/lightbox' import {type Dimensions} from '#/view/com/lightbox/ImageViewing/@types' import {atoms as a} from '#/alf' import {AutoSizedImage} from '#/components/images/AutoSizedImage' +import {Gallery} from '#/components/images/Gallery' import {ImageLayoutGrid} from '#/components/images/ImageLayoutGrid' import {PostEmbedViewContext} from '#/components/Post/Embed/types' +import {useAnalytics} from '#/analytics' import {type EmbedType} from '#/types/bsky/post' import {type CommonProps} from './types' @@ -23,8 +19,10 @@ export function ImageEmbed({ }: CommonProps & { embed: EmbedType<'images'> }) { + const ax = useAnalytics() const {openLightbox} = useLightboxControls() const {images} = embed.view + const galleryEnabled = ax.features.enabled(ax.features.PostGalleryEmbedEnable) if (images.length > 0) { const items = images.map(img => ({ @@ -33,34 +31,21 @@ export function ImageEmbed({ alt: img.alt, dimensions: img.aspectRatio ?? null, })) - const _openLightbox = ( - index: number, - thumbRects: (MeasuredDimensions | null)[], - fetchedDims: (Dimensions | null)[], - ) => { - openLightbox({ - images: items.map((item, i) => ({ - ...item, - thumbRect: thumbRects[i] ?? null, - thumbDimensions: fetchedDims[i] ?? null, - type: 'image', - })), - index, - }) - } const onPress = ( index: number, refs: AnimatedRef[], fetchedDims: (Dimensions | null)[], ) => { - runOnUI(() => { - 'worklet' - const rects: (MeasuredDimensions | null)[] = [] - for (const r of refs) { - rects.push(measure(r)) - } - runOnJS(_openLightbox)(index, rects, fetchedDims) - })() + openLightbox({ + images: items.map((item, i) => ({ + ...item, + thumbRect: null, + thumbRef: refs[i] ?? null, + thumbDimensions: fetchedDims[i] ?? null, + type: 'image', + })), + index, + }) } const onPressIn = (_: number) => { InteractionManager.runAfterInteractions(() => { @@ -95,6 +80,19 @@ export function ImageEmbed({ ) } + if (galleryEnabled) { + return ( + + + + ) + } + return ( - - {({active}) => ( - <> - {!active && !linkDisabled && ( - - )} - {linkDisabled ? ( - - {contents} - - ) : ( - - {contents} - - )} - - )} - - + + + + {({active}) => ( + <> + {!active && !linkDisabled && ( + + )} + {linkDisabled ? ( + + {contents} + + ) : ( + + {contents} + + )} + + )} + + + ) } diff --git a/src/components/PostControls/PostMenu/index.tsx b/src/components/PostControls/PostMenu/index.tsx index 7ed620bd64..a744efbaa8 100644 --- a/src/components/PostControls/PostMenu/index.tsx +++ b/src/components/PostControls/PostMenu/index.tsx @@ -11,8 +11,8 @@ import {useLingui} from '@lingui/react/macro' import {type Shadow} from '#/state/cache/post-shadow' import {EventStopper} from '#/view/com/util/EventStopper' import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid' -import {useMenuControl} from '#/components/Menu' import * as Menu from '#/components/Menu' +import {useMenuControl} from '#/components/Menu' import {PostControlButton, PostControlButtonIcon} from '../PostControlButton' import {PostMenuItems} from './PostMenuItems' diff --git a/src/components/PostControls/ShareMenu/RecentChats.tsx b/src/components/PostControls/ShareMenu/RecentChats.tsx index 24fcc87b3a..5e3cecf077 100644 --- a/src/components/PostControls/ShareMenu/RecentChats.tsx +++ b/src/components/PostControls/ShareMenu/RecentChats.tsx @@ -6,21 +6,21 @@ import {Trans} from '@lingui/react/macro' import {useNavigation} from '@react-navigation/native' import {isBlockedOrBlocking, isMuted} from '#/lib/moderation/blocked-and-muted' +import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' import {type NavigationProp} from '#/lib/routes/types' -import {sanitizeDisplayName} from '#/lib/strings/display-names' -import {sanitizeHandle} from '#/lib/strings/handles' import {useProfileShadow} from '#/state/cache/profile-shadow' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useListConvosQuery} from '#/state/queries/messages/list-conversations' import {useSession} from '#/state/session' import {UserAvatar} from '#/view/com/util/UserAvatar' import {atoms as a, tokens, useTheme} from '#/alf' +import {AvatarBubbles} from '#/components/AvatarBubbles' import {Button} from '#/components/Button' import {useDialogContext} from '#/components/Dialog' +import {type ConvoWithDetails, parseConvoView} from '#/components/dms/util' import {ProfileBadges} from '#/components/ProfileBadges' import {Text} from '#/components/Typography' import {useAnalytics} from '#/analytics' -import type * as bsky from '#/types/bsky' export function RecentChats({ postUri, @@ -60,23 +60,24 @@ export function RecentChats({ showsHorizontalScrollIndicator={false} nestedScrollEnabled> {convos && convos.length > 0 ? ( - convos.map(convo => { - const otherMember = convo.members.find( - member => member.did !== currentAccount?.did, - ) + convos.map(c => { + const convo = parseConvoView(c, currentAccount?.did) + + if (!convo) return null if ( - !otherMember || - otherMember.handle === 'missing.invalid' || - convo.muted - ) + (convo.kind === 'direct' && + convo.primaryMember.handle === 'missing.invalid') || + convo.view.muted + ) { return null + } return ( onSelectChat(convo.id)} + key={convo.view.id} + convo={convo} + onPress={() => onSelectChat(convo.view.id)} moderationOpts={moderationOpts} /> ) @@ -99,26 +100,33 @@ export function RecentChats({ const WIDTH = 80 function RecentChatItem({ - profile: profileUnshadowed, onPress, moderationOpts, + convo, }: { - profile: bsky.profile.AnyProfileView onPress: () => void moderationOpts: ModerationOpts + convo: ConvoWithDetails }) { const {_} = useLingui() const t = useTheme() - const profile = useProfileShadow(profileUnshadowed) + const primaryProfile = useProfileShadow(convo.primaryMember) - const moderation = moderateProfile(profile, moderationOpts) - const name = sanitizeDisplayName( - profile.displayName || sanitizeHandle(profile.handle), - moderation.ui('displayName'), - ) + const moderation = moderateProfile(primaryProfile, moderationOpts) + const name = + convo.kind === 'group' + ? convo.details.name + : createSanitizedDisplayName( + primaryProfile, + true, + moderation.ui('displayName'), + ) - if (isBlockedOrBlocking(profile) || isMuted(profile)) { + if ( + convo.kind === 'direct' && + (isBlockedOrBlocking(primaryProfile) || isMuted(primaryProfile)) + ) { return null } @@ -133,12 +141,16 @@ function RecentChatItem({ a.justify_start, a.align_center, ]}> - + {convo.kind === 'group' ? ( + + ) : ( + + )} {name} - + {convo.kind === 'direct' && ( + + )} ) diff --git a/src/components/PostControls/ShareMenu/index.tsx b/src/components/PostControls/ShareMenu/index.tsx index 755b60e41f..efac6a3f01 100644 --- a/src/components/PostControls/ShareMenu/index.tsx +++ b/src/components/PostControls/ShareMenu/index.tsx @@ -18,8 +18,8 @@ import {useFeedFeedbackContext} from '#/state/feed-feedback' import {EventStopper} from '#/view/com/util/EventStopper' import {native} from '#/alf' import {ArrowShareRight_Stroke2_Corner2_Rounded as ArrowShareRightIcon} from '#/components/icons/ArrowShareRight' -import {useMenuControl} from '#/components/Menu' import * as Menu from '#/components/Menu' +import {useMenuControl} from '#/components/Menu' import {useAnalytics} from '#/analytics' import {PostControlButton, PostControlButtonIcon} from '../PostControlButton' import {ShareMenuItems} from './ShareMenuItems' diff --git a/src/components/ProgressGuide/FollowDialog.tsx b/src/components/ProgressGuide/FollowDialog.tsx index 775eedd769..5c81153a10 100644 --- a/src/components/ProgressGuide/FollowDialog.tsx +++ b/src/components/ProgressGuide/FollowDialog.tsx @@ -109,21 +109,32 @@ export function FollowDialogWithoutGuide({ let lastSelectedInterest = '' let lastSearchText = '' +const FOR_YOU_TAB = 'all' + function DialogInner({guide}: {guide?: Follow10ProgressGuide}) { const {t: l} = useLingui() const ax = useAnalytics() - const interestsDisplayNames = useInterestsDisplayNames() + const rawInterestsDisplayNames = useInterestsDisplayNames() const {data: preferences} = usePreferencesQuery() const personalizedInterests = preferences?.interests?.tags - const interests = Object.keys(interestsDisplayNames) - .sort(boostInterests(popularInterests)) - .sort(boostInterests(personalizedInterests)) + const interests = useMemo( + () => [ + FOR_YOU_TAB, + ...Object.keys(rawInterestsDisplayNames) + .sort(boostInterests(popularInterests)) + .sort(boostInterests(personalizedInterests)), + ], + [rawInterestsDisplayNames, personalizedInterests], + ) + const interestsDisplayNames = useMemo( + () => ({ + [FOR_YOU_TAB]: l`For You`, + ...rawInterestsDisplayNames, + }), + [l, rawInterestsDisplayNames], + ) const [selectedInterest, setSelectedInterest] = useState( - () => - lastSelectedInterest || - (personalizedInterests && interests.includes(personalizedInterests[0]) - ? personalizedInterests[0] - : interests[0]), + () => lastSelectedInterest || FOR_YOU_TAB, ) const [searchText, setSearchText] = useState(lastSearchText) const moderationOpts = useModerationOpts() @@ -137,14 +148,15 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) { lastSelectedInterest = selectedInterest }, [searchText, selectedInterest]) - const { - data: suggestions, - isFetching: isFetchingSuggestions, - error: suggestionsError, - } = useGetSuggestedUsersForSeeMoreQuery({ - category: selectedInterest, + const isForYou = selectedInterest === FOR_YOU_TAB + + const seeMoreQuery = useGetSuggestedUsersForSeeMoreQuery({ + category: isForYou ? undefined : selectedInterest, limit: 50, }) + const suggestions = seeMoreQuery.data + const isFetchingSuggestions = seeMoreQuery.isFetching + const suggestionsError = seeMoreQuery.error const { data: searchResults, isFetching: isFetchingSearchResults, @@ -277,7 +289,10 @@ function DialogInner({guide}: {guide?: Follow10ProgressGuide}) { recId: recIdForLogging, position: position !== -1 ? position : 0, suggestedDid: item.profile.did, - category: selectedInterestRef.current, + category: + selectedInterestRef.current === FOR_YOU_TAB + ? null + : selectedInterestRef.current, }) } } diff --git a/src/components/StarterPack/ShareDialog.tsx b/src/components/StarterPack/ShareDialog.tsx index 766fdbe9ac..0dd640d3c0 100644 --- a/src/components/StarterPack/ShareDialog.tsx +++ b/src/components/StarterPack/ShareDialog.tsx @@ -10,8 +10,8 @@ import {shareUrl} from '#/lib/sharing' import {getStarterPackOgCard} from '#/lib/strings/starter-pack' import {atoms as a, useBreakpoints, useTheme} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' -import {type DialogControlProps} from '#/components/Dialog' import * as Dialog from '#/components/Dialog' +import {type DialogControlProps} from '#/components/Dialog' import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink' import {Download_Stroke2_Corner0_Rounded as DownloadIcon} from '#/components/icons/Download' import {QrCode_Stroke2_Corner0_Rounded as QrCodeIcon} from '#/components/icons/QrCode' diff --git a/src/components/dialogs/SearchablePeopleList.tsx b/src/components/dialogs/SearchablePeopleList.tsx index 9471d48c04..83b59580f3 100644 --- a/src/components/dialogs/SearchablePeopleList.tsx +++ b/src/components/dialogs/SearchablePeopleList.tsx @@ -8,11 +8,9 @@ import { } from 'react' import {TextInput, View} from 'react-native' import {moderateProfile, type ModerationOpts} from '@atproto/api' -import {msg} from '@lingui/core/macro' -import {useLingui} from '@lingui/react' -import {Trans} from '@lingui/react/macro' +import {Plural, Trans, useLingui} from '@lingui/react/macro' -import {sanitizeDisplayName} from '#/lib/strings/display-names' +import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' import {sanitizeHandle} from '#/lib/strings/handles' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete' @@ -23,7 +21,11 @@ import {type ListMethods} from '#/view/com/util/List' import {android, atoms as a, native, useTheme, web} from '#/alf' import {Button, ButtonIcon} from '#/components/Button' import * as Dialog from '#/components/Dialog' -import {canBeMessaged} from '#/components/dms/util' +import { + canBeMessaged, + type ConvoWithDetails, + parseConvoView, +} from '#/components/dms/util' import {useInteractionState} from '#/components/hooks/useInteractionState' import {MagnifyingGlass_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass' import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' @@ -31,6 +33,9 @@ import * as ProfileCard from '#/components/ProfileCard' import {Text} from '#/components/Typography' import {IS_WEB} from '#/env' import type * as bsky from '#/types/bsky' +import {AvatarBubbles} from '../AvatarBubbles' +import {Error} from '../Error' +import {ProfileBadges} from '../ProfileBadges' export type ProfileItem = { type: 'profile' @@ -38,6 +43,12 @@ export type ProfileItem = { profile: bsky.profile.AnyProfileView } +type ExistingChatItem = { + type: 'existingChat' + key: string + convo: ConvoWithDetails +} + type EmptyItem = { type: 'empty' key: string @@ -54,7 +65,12 @@ type ErrorItem = { key: string } -type Item = ProfileItem | EmptyItem | PlaceholderItem | ErrorItem +type Item = + | ProfileItem + | ExistingChatItem + | EmptyItem + | PlaceholderItem + | ErrorItem export function SearchablePeopleList({ title, @@ -72,12 +88,14 @@ export function SearchablePeopleList({ onSelectChat?: undefined } | { - onSelectChat: (did: string) => void + onSelectChat: ( + chat: {kind: 'user'; did: string} | {kind: 'convo'; id: string}, + ) => void renderProfileCard?: undefined } )) { const t = useTheme() - const {_} = useLingui() + const {t: l} = useLingui() const moderationOpts = useModerationOpts() const control = Dialog.useDialogContext() const [headerHeight, setHeaderHeight] = useState(0) @@ -105,7 +123,7 @@ export function SearchablePeopleList({ _items.push({ type: 'empty', key: 'empty', - message: _(msg`We're having network issues, try again`), + message: l`We're having network issues, try again`, }) } else if (searchText.length) { if (results?.length) { @@ -139,20 +157,27 @@ export function SearchablePeopleList({ const usedDids = new Set() for (const page of convos.pages) { - for (const convo of page.convos) { - const profiles = convo.members.filter( - m => m.did !== currentAccount?.did, - ) + for (const convoView of page.convos) { + const convo = parseConvoView(convoView, currentAccount?.did) - for (const profile of profiles) { - if (usedDids.has(profile.did)) continue + if (!convo) continue - usedDids.add(profile.did) + if (convo.kind === 'group') { + _items.push({ + type: 'existingChat', + key: convo.view.id, + convo, + }) + } else { + if (convo.primaryMember.handle === 'missing.invalid') continue + if (usedDids.has(convo.primaryMember.did)) continue + + usedDids.add(convo.primaryMember.did) _items.push({ - type: 'profile', - key: profile.did, - profile, + type: 'existingChat', + key: convo.view.id, + convo: convo, }) } } @@ -209,7 +234,7 @@ export function SearchablePeopleList({ return _items }, [ - _, + l, searchText, results, isError, @@ -221,12 +246,27 @@ export function SearchablePeopleList({ ]) if (searchText && !isFetching && !items.length && !isError) { - items.push({type: 'empty', key: 'empty', message: _(msg`No results`)}) + items.push({type: 'empty', key: 'empty', message: l`No results`}) } const renderItems = useCallback( ({item}: {item: Item}) => { switch (item.type) { + case 'existingChat': { + if (renderProfileCard) { + // should be unreachable + return null + } else { + return ( + onSelectChat({kind: 'convo', id})} + /> + ) + } + } case 'profile': { if (renderProfileCard) { return {renderProfileCard(item)} @@ -236,7 +276,7 @@ export function SearchablePeopleList({ key={item.key} profile={item.profile} moderationOpts={moderationOpts!} - onPress={onSelectChat} + onPress={did => onSelectChat({kind: 'user', did})} /> ) } @@ -247,11 +287,14 @@ export function SearchablePeopleList({ case 'empty': { return } + case 'error': { + return + } default: return null } }, - [moderationOpts, onSelectChat, renderProfileCard], + [moderationOpts, onSelectChat, renderProfileCard, l], ) useLayoutEffect(() => { @@ -293,7 +336,7 @@ export function SearchablePeopleList({ {IS_WEB ? ( + ) +} + function ProfileCardSkeleton() { const t = useTheme() @@ -488,7 +639,7 @@ function SearchInput({ inputRef: React.RefObject }) { const t = useTheme() - const {_} = useLingui() + const {t: l} = useLingui() const { state: hovered, onIn: onMouseEnter, @@ -512,7 +663,7 @@ function SearchInput({ ) diff --git a/src/components/dms/ActionsWrapper.tsx b/src/components/dms/ActionsWrapper.tsx index c1f54e2394..1e4b40206e 100644 --- a/src/components/dms/ActionsWrapper.tsx +++ b/src/components/dms/ActionsWrapper.tsx @@ -1,7 +1,6 @@ import {View} from 'react-native' import {type ChatBskyConvoDefs} from '@atproto/api' -import {msg} from '@lingui/core/macro' -import {useLingui} from '@lingui/react' +import {useLingui} from '@lingui/react/macro' import {atoms as a} from '#/alf' import {MessageContextMenu} from '#/components/dms/MessageContextMenu' @@ -10,15 +9,18 @@ export function ActionsWrapper({ message, isFromSelf, children, + onTap, }: { message: ChatBskyConvoDefs.MessageView + hasReactions?: boolean isFromSelf: boolean children: React.ReactNode + onTap?: () => void }) { - const {_} = useLingui() + const {t: l} = useLingui() return ( - + {trigger => // will always be true, since this file is platform split trigger.IS_NATIVE && ( @@ -32,7 +34,7 @@ export function ActionsWrapper({ ]} accessible={true} accessibilityActions={[ - {name: 'activate', label: _(msg`Open message options`)}, + {name: 'activate', label: l`Open message options`}, ]} onAccessibilityAction={() => trigger.control.open('full')}> {children} diff --git a/src/components/dms/ActionsWrapper.web.tsx b/src/components/dms/ActionsWrapper.web.tsx index beb6577e0f..05df7b0324 100644 --- a/src/components/dms/ActionsWrapper.web.tsx +++ b/src/components/dms/ActionsWrapper.web.tsx @@ -1,8 +1,7 @@ import {useCallback, useRef, useState} from 'react' import {Pressable, View} from 'react-native' import {type ChatBskyConvoDefs} from '@atproto/api' -import {msg} from '@lingui/core/macro' -import {useLingui} from '@lingui/react' +import {useLingui} from '@lingui/react/macro' import {useConvoActive} from '#/state/messages/convo' import {useSession} from '#/state/session' @@ -16,16 +15,20 @@ import {hasReachedReactionLimit} from './util' export function ActionsWrapper({ message, + hasReactions, isFromSelf, children, + onTap, }: { message: ChatBskyConvoDefs.MessageView + hasReactions?: boolean isFromSelf: boolean children: React.ReactNode + onTap?: () => void }) { const viewRef = useRef(null) const t = useTheme() - const {_} = useLingui() + const {t: l} = useLingui() const convo = useConvoActive() const {currentAccount} = useSession() @@ -57,17 +60,17 @@ export function ActionsWrapper({ ) { convo .removeReaction(message.id, emoji) - .catch(() => Toast.show(_(msg`Failed to remove emoji reaction`))) + .catch(() => Toast.show(l`Failed to remove emoji reaction`)) } else { if (hasReachedReactionLimit(message, currentAccount?.did)) return convo.addReaction(message.id, emoji).catch(() => - Toast.show(_(msg`Failed to add emoji reaction`), { + Toast.show(l`Failed to add emoji reaction`, { type: 'error', }), ) } }, - [_, convo, message, currentAccount?.did], + [l, convo, message, currentAccount?.did], ) return ( @@ -87,6 +90,7 @@ export function ActionsWrapper({ isFromSelf ? [a.mr_xs, {marginLeft: 'auto'}, a.flex_row_reverse] : [a.ml_xs, {marginRight: 'auto'}], + hasReactions ? [a.mb_2xl] : undefined, ]}> {({props, state, IS_NATIVE, control}) => { @@ -133,10 +137,13 @@ export function ActionsWrapper({ }} - {children} - + ) } diff --git a/src/components/dms/AddMembersFlow.tsx b/src/components/dms/AddMembersFlow.tsx new file mode 100644 index 0000000000..103ad0aca4 --- /dev/null +++ b/src/components/dms/AddMembersFlow.tsx @@ -0,0 +1,470 @@ +import { + useCallback, + useLayoutEffect, + useMemo, + useReducer, + useRef, + useState, +} from 'react' +import {LayoutAnimation, type TextInput, View} from 'react-native' +import {Trans, useLingui} from '@lingui/react/macro' + +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete' +import {useProfileFollowsQuery} from '#/state/queries/profile-follows' +import {useSession} from '#/state/session' +import {type ListMethods} from '#/view/com/util/List' +import {android, atoms as a, native, useTheme, web} from '#/alf' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import {canBeMessaged} from '#/components/dms/util' +import * as Toggle from '#/components/forms/Toggle' +import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon} from '#/components/icons/Arrow' +import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' +import {Text} from '#/components/Typography' +import {IS_NATIVE, IS_WEB} from '#/env' +import type * as bsky from '#/types/bsky' +import {ChatProfileTabs} from './ChatProfileTabs' +import {EmptyMemberList} from './components/EmptyMemberList' +import {GroupChatProfileCard} from './components/GroupChatProfileCard' +import {ProfileCardSkeleton} from './components/ProfileCardSkeleton' +import {UserLabel} from './components/UserLabel' +import {UserSearchInput} from './components/UserSearchInput' + +type LabelItem = { + type: 'label' + key: string + message: string +} + +type ProfileItem = { + type: 'profile' + key: string + profile: bsky.profile.AnyProfileView +} + +type EmptyItem = { + type: 'empty' + key: string + message: string +} + +type PlaceholderItem = { + type: 'placeholder' + key: string +} + +type ErrorItem = { + type: 'error' + key: string +} + +type Item = LabelItem | ProfileItem | EmptyItem | PlaceholderItem | ErrorItem + +export type State = { + groupChatDids: string[] + groupChatProfiles: bsky.profile.AnyProfileView[] +} + +export type Action = + | { + type: 'setDids' + groupChatDids: string[] + groupChatProfiles: bsky.profile.AnyProfileView[] + } + | { + type: 'removeDids' + groupChatDids: string[] + groupChatProfiles: bsky.profile.AnyProfileView[] + } + +function reducer(state: State, action: Action): State { + switch (action.type) { + case 'setDids': { + return { + ...state, + groupChatDids: action.groupChatDids, + groupChatProfiles: action.groupChatProfiles, + } + } + case 'removeDids': { + return { + ...state, + groupChatDids: action.groupChatDids, + groupChatProfiles: action.groupChatProfiles, + } + } + } +} + +export function AddMembersFlow({ + title, + onAddMembers, +}: { + title: string + onAddMembers: (dids: string[]) => void +}) { + const t = useTheme() + const {t: l} = useLingui() + const moderationOpts = useModerationOpts() + const control = Dialog.useDialogContext() + const [headerHeight, setHeaderHeight] = useState(0) + const [footerHeight, setFooterHeight] = useState(0) + const listRef = useRef(null) + const {currentAccount} = useSession() + const inputRef = useRef(null) + + const [searchText, setSearchText] = useState('') + + const { + data: results, + isError, + isFetching, + } = useActorAutocompleteQuery(searchText, true, 12) + const {data: follows} = useProfileFollowsQuery(currentAccount?.did) + + const [{groupChatDids, groupChatProfiles}, dispatch] = useReducer(reducer, { + groupChatDids: [], + groupChatProfiles: [], + }) + + const onRemoveDid = useCallback( + (did: string) => { + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) + dispatch({ + type: 'removeDids', + groupChatDids: groupChatDids.filter(d => d !== did), + groupChatProfiles: groupChatProfiles.filter( + profile => profile.did !== did, + ), + }) + }, + [groupChatDids, groupChatProfiles], + ) + + const items = useMemo(() => { + let _items: Item[] = [] + + if (isError) { + _items.push({ + type: 'empty', + key: 'empty', + message: l`We’re having network issues, try again`, + }) + } else if (searchText.length) { + if (results?.length) { + for (const profile of results) { + if (profile.did === currentAccount?.did) continue + _items.push({ + type: 'profile', + key: profile.did, + profile, + }) + } + + _items = _items.sort(item => { + return item.type === 'profile' && canBeMessaged(item.profile) ? -1 : 1 + }) + } + } else { + const placeholders: Item[] = Array(10) + .fill(0) + .map((__, i) => ({ + type: 'placeholder', + key: i + '', + })) + + if (follows) { + for (const page of follows.pages) { + for (const profile of page.follows) { + _items.push({ + type: 'profile', + key: profile.did, + profile, + }) + } + } + + _items = _items.sort(item => { + return item.type === 'profile' && canBeMessaged(item.profile) ? -1 : 1 + }) + } else { + _items.push(...placeholders) + } + } + + if (searchText === '') { + _items.unshift({ + type: 'label', + key: 'suggested', + message: l`Suggested`, + }) + } + + return _items + }, [isError, searchText, l, results, currentAccount?.did, follows]) + + if (searchText && !isFetching && !items.length && !isError) { + items.push({type: 'empty', key: 'empty', message: l`No results`}) + } + + const handlePressBack = useCallback(() => { + control.close() + }, [control]) + + const handlePressAdd = useCallback(() => { + onAddMembers(groupChatDids) + }, [groupChatDids, onAddMembers]) + + const renderItems = useCallback( + ({item}: {item: Item}) => { + switch (item.type) { + case 'label': { + return + } + case 'profile': { + return ( + + ) + } + case 'placeholder': { + return + } + case 'empty': { + return + } + default: + return null + } + }, + [moderationOpts], + ) + + useLayoutEffect(() => { + if (IS_WEB) { + setImmediate(() => { + inputRef?.current?.focus() + }) + } + }, []) + + let buttonLabel = l`Continue to group name` + let buttonText = l`Next` + let showButton = groupChatProfiles.length > 0 + let isButtonDisabled = !showButton + + const showChatProfileTabs = groupChatProfiles.length > 0 + + const listHeader = useMemo( + () => ( + setHeaderHeight(evt.nativeEvent.layout.height)}> + + + {IS_NATIVE ? ( + + ) : null} + + {title} + + {IS_WEB ? ( + + ) : showButton ? ( + + ) : null} + + + { + setSearchText(text) + listRef.current?.scrollToOffset({offset: 0, animated: false}) + }} + onEscape={control.close} + /> + + + {showChatProfileTabs ? ( + + + + ) : null} + + ), + [ + buttonLabel, + control, + groupChatProfiles, + handlePressAdd, + handlePressBack, + isButtonDisabled, + l, + onRemoveDid, + searchText, + showButton, + showChatProfileTabs, + t.atoms.bg, + t.atoms.border_contrast_low, + t.atoms.text_contrast_high, + title, + ], + ) + + const setGroupChatMembers = (dids: string[]) => { + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) + + const added = dids.filter(d => !groupChatDids.includes(d)) + const removed = groupChatDids.filter(d => !dids.includes(d)) + const newDids = [ + ...groupChatDids.filter(d => !removed.includes(d)), + ...added, + ] + + const kept = groupChatProfiles.filter(p => dids.includes(p.did)) + const keptDids = new Set(kept.map(p => p.did)) + const addedProfiles = items + .filter( + (item): item is ProfileItem => + item.type === 'profile' && + dids.includes(item.profile.did) && + !keptDids.has(item.profile.did), + ) + .map(item => item.profile) + .sort((a, b) => dids.indexOf(a.did) - dids.indexOf(b.did)) + + dispatch({ + type: 'setDids', + groupChatDids: newDids, + groupChatProfiles: [...kept, ...addedProfiles], + }) + } + + return ( + + item.key} + style={[ + web([a.py_0, {height: '100vh', maxHeight: 600}, a.px_0]), + native({height: '100%'}), + ]} + webInnerContentContainerStyle={[a.py_0, {paddingBottom: footerHeight}]} + webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]} + scrollIndicatorInsets={{top: headerHeight, bottom: footerHeight}} + keyboardDismissMode="on-drag" + footer={ + IS_WEB ? ( + setFooterHeight(evt.nativeEvent.layout.height)}> + + + + + + ) : null + } + /> + + ) +} diff --git a/src/components/dms/ConvoMenu.tsx b/src/components/dms/ConvoMenu.tsx index ae9a4a3c4a..587a25e95b 100644 --- a/src/components/dms/ConvoMenu.tsx +++ b/src/components/dms/ConvoMenu.tsx @@ -25,9 +25,9 @@ import {AfterReportDialog} from '#/components/dms/AfterReportDialog' import {BlockedByListDialog} from '#/components/dms/BlockedByListDialog' import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt' import {ReportConversationPrompt} from '#/components/dms/ReportConversationPrompt' -import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeft} from '#/components/icons/ArrowBoxLeft' -import {Bubble_Stroke2_Corner2_Rounded as Bubble} from '#/components/icons/Bubble' -import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid' +import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeftIcon} from '#/components/icons/ArrowBoxLeft' +import {Bubble_Stroke2_Corner2_Rounded as BubbleIcon} from '#/components/icons/Bubble' +import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsHorizontalIcon} from '#/components/icons/DotGrid' import {Flag_Stroke2_Corner0_Rounded as Flag} from '#/components/icons/Flag' import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute' import { @@ -95,7 +95,7 @@ let ConvoMenu = ({ shape="round" variant="ghost" style={[a.bg_transparent]}> - + )}
@@ -220,9 +220,9 @@ function MenuContent({ } if (userBlock) { - queueUnblock() + void queueUnblock() } else { - queueBlock() + void queueBlock() } }, [userBlock, listBlocks, blockedByListControl, queueBlock, queueUnblock]) @@ -233,7 +233,7 @@ function MenuContent({ Leave conversation - + ) : ( <> @@ -245,7 +245,7 @@ function MenuContent({ Mark as read - + )} Leave conversation - + diff --git a/src/components/dms/DateDivider.tsx b/src/components/dms/DateDivider.tsx index dfc2d53da5..21724ba3ea 100644 --- a/src/components/dms/DateDivider.tsx +++ b/src/components/dms/DateDivider.tsx @@ -1,8 +1,6 @@ import {memo} from 'react' import {View} from 'react-native' -import {msg} from '@lingui/core/macro' -import {useLingui} from '@lingui/react' -import {Trans} from '@lingui/react/macro' +import {Trans, useLingui} from '@lingui/react/macro' import {subDays} from 'date-fns' import {atoms as a, useTheme} from '#/alf' @@ -29,8 +27,8 @@ const longDateFormatterWithYear = new Intl.DateTimeFormat(undefined, { }) let DateDivider = ({date: dateStr}: {date: string}): React.ReactNode => { - const {_} = useLingui() const t = useTheme() + const {t: l} = useLingui() let date: string const time = timeFormatter.format(new Date(dateStr)) @@ -42,9 +40,9 @@ let DateDivider = ({date: dateStr}: {date: string}): React.ReactNode => { const oneWeekAgo = subDays(today, 7) if (localDateString(today) === localDateString(timestamp)) { - date = _(msg`Today`) + date = l`Today` } else if (localDateString(yesterday) === localDateString(timestamp)) { - date = _(msg`Yesterday`) + date = l`Yesterday` } else { if (timestamp < oneWeekAgo) { if (timestamp.getFullYear() === today.getFullYear()) { @@ -58,7 +56,7 @@ let DateDivider = ({date: dateStr}: {date: string}): React.ReactNode => { } return ( - + { a.px_md, ]}> - - {date} - {' '} - at {time} + {date} at {time} diff --git a/src/components/dms/DateDividerToggle.tsx b/src/components/dms/DateDividerToggle.tsx new file mode 100644 index 0000000000..97489f8401 --- /dev/null +++ b/src/components/dms/DateDividerToggle.tsx @@ -0,0 +1,44 @@ +import {createContext, useCallback, useContext, useState} from 'react' + +type DateDividerToggleContextType = { + isDividerToggled: (id: string) => boolean + toggleDivider: (id: string) => void +} + +const DateDividerToggleContext = createContext({ + isDividerToggled: () => false, + toggleDivider: () => {}, +}) + +export function DateDividerToggleProvider({ + children, +}: { + children: React.ReactNode +}) { + const [toggledIds, setToggledIds] = useState(new Set()) + + const toggleDivider = useCallback((id: string) => { + setToggledIds(prev => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + }, []) + + const isDividerToggled = useCallback( + (id: string) => toggledIds.has(id), + [toggledIds], + ) + + return ( + + {children} + + ) +} + +export function useDateDividerToggle() { + return useContext(DateDividerToggleContext) +} diff --git a/src/components/dms/EmojiReactionPicker.web.tsx b/src/components/dms/EmojiReactionPicker.web.tsx index 6be85efb4c..1a78a55458 100644 --- a/src/components/dms/EmojiReactionPicker.web.tsx +++ b/src/components/dms/EmojiReactionPicker.web.tsx @@ -1,18 +1,14 @@ import {useState} from 'react' import {Pressable, View} from 'react-native' import {type ChatBskyConvoDefs} from '@atproto/api' -import EmojiPicker from '@emoji-mart/react' -import {msg} from '@lingui/core/macro' -import {useLingui} from '@lingui/react' +import {useLingui} from '@lingui/react/macro' import {DropdownMenu} from 'radix-ui' import {useSession} from '#/state/session' -import {type Emoji} from '#/view/com/composer/text-input/web/EmojiPicker' -import {useWebPreloadEmoji} from '#/view/com/composer/text-input/web/useWebPreloadEmoji' import {atoms as a, flatten, useTheme} from '#/alf' +import * as EmojiPicker from '#/components/EmojiPicker' import {DotGrid3x1_Stroke2_Corner0_Rounded as DotGridIcon} from '#/components/icons/DotGrid' import * as Menu from '#/components/Menu' -import {type TriggerProps} from '#/components/Menu/types' import {Text} from '#/components/Typography' import {hasAlreadyReacted, hasReachedReactionLimit} from './util' @@ -22,19 +18,21 @@ export function EmojiReactionPicker({ onEmojiSelect, }: { message: ChatBskyConvoDefs.MessageView - children?: TriggerProps['children'] + children?: EmojiPicker.TriggerProps['children'] onEmojiSelect: (emoji: string) => void }) { if (!children) throw new Error('EmojiReactionPicker requires the children prop on web') - const {_} = useLingui() + const {t: l} = useLingui() return ( - - {children} + onEmojiSelect(emoji.native)}> + + {children} + - + ) } @@ -49,8 +47,6 @@ function MenuInner({ const {control} = Menu.useMenuContext() const {currentAccount} = useSession() - useWebPreloadEmoji({immediate: true}) - const [expanded, setExpanded] = useState(false) const [prevOpen, setPrevOpen] = useState(control.isOpen) @@ -62,10 +58,6 @@ function MenuInner({ } } - const handleEmojiPickerResponse = (emoji: Emoji) => { - handleEmojiSelect(emoji.native) - } - const handleEmojiSelect = (emoji: string) => { control.close() onEmojiSelect(emoji) @@ -74,18 +66,7 @@ function MenuInner({ const limitReacted = hasReachedReactionLimit(message, currentAccount?.did) return expanded ? ( - - -
evt.stopPropagation()}> - -
-
-
+ ) : ( diff --git a/src/components/dms/InitiateChatFlow.tsx b/src/components/dms/InitiateChatFlow.tsx index 4b171d964f..bc4824f47c 100644 --- a/src/components/dms/InitiateChatFlow.tsx +++ b/src/components/dms/InitiateChatFlow.tsx @@ -6,7 +6,7 @@ import { useRef, useState, } from 'react' -import {LayoutAnimation, TextInput, View} from 'react-native' +import {LayoutAnimation, type TextInput, View} from 'react-native' import {moderateProfile, type ModerationOpts} from '@atproto/api' import {Trans, useLingui} from '@lingui/react/macro' @@ -23,13 +23,11 @@ import * as Dialog from '#/components/Dialog' import {canBeMessaged} from '#/components/dms/util' import * as TextField from '#/components/forms/TextField' import * as Toggle from '#/components/forms/Toggle' -import {useInteractionState} from '#/components/hooks/useInteractionState' import { ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon, ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon, } from '#/components/icons/Arrow' import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon} from '#/components/icons/Chevron' -import {MagnifyingGlass_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass' import {PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon} from '#/components/icons/Person' import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' import * as ProfileCard from '#/components/ProfileCard' @@ -37,6 +35,11 @@ import {Text} from '#/components/Typography' import {IS_NATIVE, IS_WEB} from '#/env' import type * as bsky from '#/types/bsky' import {ChatProfileTabs} from './ChatProfileTabs' +import {EmptyMemberList} from './components/EmptyMemberList' +import {GroupChatProfileCard} from './components/GroupChatProfileCard' +import {ProfileCardSkeleton} from './components/ProfileCardSkeleton' +import {UserLabel} from './components/UserLabel' +import {UserSearchInput} from './components/UserSearchInput' type NewGroupChatItem = { type: 'newGroupChat' @@ -49,7 +52,7 @@ type LabelItem = { message: string } -export type ProfileItem = { +type ProfileItem = { type: 'profile' key: string profile: bsky.profile.AnyProfileView @@ -184,6 +187,7 @@ function reducer(state: State, action: Action): State { } } } + export function InitiateChatFlow({ title, onSelectChat, @@ -382,7 +386,7 @@ export function InitiateChatFlow({ ) } case 'label': { - return ) : ( - { @@ -813,59 +817,6 @@ function DefaultProfileCard({ ) } -function GroupChatProfileCard({ - profile, - moderationOpts, -}: { - profile: bsky.profile.AnyProfileView - moderationOpts: ModerationOpts -}) { - const t = useTheme() - const enabled = canBeMessaged(profile) - const moderation = moderateProfile(profile, moderationOpts) - const handle = sanitizeHandle(profile.handle, '@') - const displayName = sanitizeDisplayName( - profile.displayName || sanitizeHandle(profile.handle), - moderation.ui('displayName'), - ) - - return ( - - - - - - - {enabled ? ( - - ) : ( - - {handle} can’t be messaged - - )} - - - - {enabled ? : null} - - ) -} - function GroupChatMemberProfileCard({ profile, moderationOpts, @@ -902,106 +853,3 @@ function GroupChatMemberProfileCard({
) } - -function ProfileCardSkeleton() { - return ( - - - - - ) -} - -function Label({message}: {message: string}) { - const t = useTheme() - return ( - - - {message} - - - ) -} - -function Empty({message}: {message: string}) { - const t = useTheme() - return ( - - - {message} - - - (╯°□°)╯︵ ┻━┻ - - ) -} - -function SearchInput({ - value, - onChangeText, - onEscape, - inputRef, -}: { - value: string - onChangeText: (text: string) => void - onEscape: () => void - inputRef: React.RefObject -}) { - const t = useTheme() - const {t: l} = useLingui() - const { - state: hovered, - onIn: onMouseEnter, - onOut: onMouseLeave, - } = useInteractionState() - const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() - const interacted = hovered || focused - - return ( - - - { - if (nativeEvent.key === 'Escape') { - onEscape() - } - }} - autoCorrect={false} - autoComplete="off" - autoCapitalize="none" - autoFocus - accessibilityLabel={l`Search profiles`} - accessibilityHint={l`Searches for profiles`} - /> - - ) -} diff --git a/src/components/dms/MessageContextMenu.tsx b/src/components/dms/MessageContextMenu.tsx index dda99c77e2..3a923133f1 100644 --- a/src/components/dms/MessageContextMenu.tsx +++ b/src/components/dms/MessageContextMenu.tsx @@ -2,8 +2,7 @@ import {memo, useCallback} from 'react' import {LayoutAnimation, Platform} from 'react-native' import * as Clipboard from 'expo-clipboard' import {type ChatBskyConvoDefs, RichText} from '@atproto/api' -import {msg} from '@lingui/core/macro' -import {useLingui} from '@lingui/react' +import {useLingui} from '@lingui/react/macro' import {useQueryClient} from '@tanstack/react-query' import {useGoogleTranslate} from '#/lib/hooks/useGoogleTranslate' @@ -12,13 +11,14 @@ import {useConvoActive} from '#/state/messages/convo' import {useLanguagePrefs} from '#/state/preferences' import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache' import {useSession} from '#/state/session' +import {atoms as a} from '#/alf' import * as ContextMenu from '#/components/ContextMenu' import {type TriggerProps} from '#/components/ContextMenu/types' import {AfterReportDialog} from '#/components/dms/AfterReportDialog' -import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble' +import {BubbleQuestion_Stroke2_Corner0_Rounded as TranslateIcon} from '#/components/icons/Bubble' import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard' -import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' -import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning' +import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' +import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning' import {ReportDialog} from '#/components/moderation/ReportDialog' import * as Prompt from '#/components/Prompt' import {usePromptControl} from '#/components/Prompt' @@ -31,11 +31,13 @@ import {hasReachedReactionLimit} from './util' export let MessageContextMenu = ({ message, children, + onTap, }: { message: ChatBskyConvoDefs.MessageView children: TriggerProps['children'] + onTap?: () => void }): React.ReactNode => { - const {_} = useLingui() + const {t: l} = useLingui() const ax = useAnalytics() const {currentAccount} = useSession() const queryClient = useQueryClient() @@ -47,6 +49,7 @@ export let MessageContextMenu = ({ const translate = useGoogleTranslate() const isFromSelf = message.sender?.did === currentAccount?.did + const isGroupChatEnabled = ax.features.enabled(ax.features.GroupChatsEnable) const onCopyMessage = useCallback(() => { const str = richTextToString( @@ -58,10 +61,10 @@ export let MessageContextMenu = ({ ) void Clipboard.setStringAsync(str) - Toast.show(_(msg`Copied to clipboard`), { + Toast.show(l`Copied to clipboard`, { type: 'success', }) - }, [_, message.text, message.facets]) + }, [l, message.text, message.facets]) const onPressTranslateMessage = useCallback(() => { void translate(message.text, langPrefs.primaryLanguage) @@ -79,11 +82,9 @@ export let MessageContextMenu = ({ LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) convo .deleteMessage(message.id) - .then(() => - Toast.show(_(msg({message: 'Message deleted', context: 'toast'}))), - ) - .catch(() => Toast.show(_(msg`Failed to delete message`))) - }, [_, convo, message.id]) + .then(() => Toast.show(l({message: 'Message deleted', context: 'toast'}))) + .catch(() => Toast.show(l`Failed to delete message`)) + }, [l, convo, message.id]) const onEmojiSelect = useCallback( (emoji: string) => { @@ -96,17 +97,17 @@ export let MessageContextMenu = ({ ) { convo .removeReaction(message.id, emoji) - .catch(() => Toast.show(_(msg`Failed to remove emoji reaction`))) + .catch(() => Toast.show(l`Failed to remove emoji reaction`)) } else { if (hasReachedReactionLimit(message, currentAccount?.did)) return convo.addReaction(message.id, emoji).catch(() => - Toast.show(_(msg`Failed to add emoji reaction`), { + Toast.show(l`Failed to add emoji reaction`, { type: 'error', }), ) } }, - [_, convo, message, currentAccount?.did], + [l, convo, message, currentAccount?.did], ) const sender = convo.convo.members.find( @@ -117,7 +118,9 @@ export let MessageContextMenu = ({ <> {IS_NATIVE && ( - + + label={l`Message options`} + contentLabel={l`Message from @${ + sender?.handle ?? 'unknown' // should always be defined + }: ${message.text}`} + onTap={onTap}> {children} - + {message.text.length > 0 && ( <> - {_(msg`Translate`)} - + {l`Translate`} + - {_(msg`Copy message text`)} + {l`Copy message text`} @@ -159,23 +163,22 @@ export let MessageContextMenu = ({ )} deleteControl.open()}> - {_(msg`Delete for me`)} - + {l`Delete for me`} + {!isFromSelf && ( reportControl.open()}> - {_(msg`Report`)} - + {l`Report`} + )} - - diff --git a/src/components/dms/MessageItem.tsx b/src/components/dms/MessageItem.tsx index 386b85d7f9..b1c288de78 100644 --- a/src/components/dms/MessageItem.tsx +++ b/src/components/dms/MessageItem.tsx @@ -1,13 +1,21 @@ -import {memo, useCallback, useMemo} from 'react' +import {memo, useCallback, useEffect, useMemo, useRef} from 'react' import { type GestureResponderEvent, + LayoutAnimation, + Pressable, type StyleProp, type TextStyle, View, + type ViewStyle, } from 'react-native' import Animated, { + FadeIn, + FadeOut, LayoutAnimationConfig, LinearTransition, + useAnimatedStyle, + useSharedValue, + withTiming, ZoomIn, ZoomOut, } from 'react-native-reanimated' @@ -16,217 +24,480 @@ import { ChatBskyConvoDefs, RichText as RichTextAPI, } from '@atproto/api' -import {type I18n} from '@lingui/core' -import {msg} from '@lingui/core/macro' -import {useLingui} from '@lingui/react' +import {plural} from '@lingui/core/macro' +import {Trans, useLingui} from '@lingui/react/macro' +import {useQueryClient} from '@tanstack/react-query' +import {makeProfileLink} from '#/lib/routes/links' import {sanitizeDisplayName} from '#/lib/strings/display-names' +import {sanitizeHandle} from '#/lib/strings/handles' import {useConvoActive} from '#/state/messages/convo' import {type ConvoItem} from '#/state/messages/convo/types' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache' import {useSession} from '#/state/session' -import {TimeElapsed} from '#/view/com/util/TimeElapsed' -import {atoms as a, native, useTheme} from '#/alf' +import {atoms as a, native, platform, useTheme} from '#/alf' import {isOnlyEmoji} from '#/alf/typography' +import {useDialogControl} from '#/components/Dialog' import {ActionsWrapper} from '#/components/dms/ActionsWrapper' -import {InlineLinkText} from '#/components/Link' +import {InlineLinkText, Link} from '#/components/Link' +import * as ProfileCard from '#/components/ProfileCard' import {RichText} from '#/components/RichText' import {Text} from '#/components/Typography' -import {IS_NATIVE} from '#/env' +import type * as bsky from '#/types/bsky' import {DateDivider} from './DateDivider' +import {useDateDividerToggle} from './DateDividerToggle' import {MessageItemEmbed} from './MessageItemEmbed' -import {localDateString} from './util' +import {ReactionsDialog} from './ReactionsDialog' + +const AVATAR_SIZE = 28 +const CLUSTERED_MESSAGE_GAP = 2 +const BORDER_RADIUS = 18 +const SQUARED_BORDER_RADIUS = 4 +const DISPLAY_NAME_INSET = 22 + +const CLUSTERED_MESSAGE_THRESHOLD_MS = 5 * 60 * 1000 +const MESSAGE_GAP_THRESHOLD_MS = 60 * 60 * 1000 + +function isWithinCluster({ + isPending, + adjacentMessage, + isFromSameSender, + currentSentAt, + direction, +}: { + isPending: boolean + adjacentMessage: + | ChatBskyConvoDefs.MessageView + | ChatBskyConvoDefs.DeletedMessageView + | null + isFromSameSender: boolean + currentSentAt: string + direction: 'prev' | 'next' +}): boolean { + if (!isFromSameSender) return true + if (isPending && adjacentMessage) return false + if (ChatBskyConvoDefs.isMessageView(adjacentMessage)) { + const thisDate = new Date(currentSentAt) + const adjDate = new Date(adjacentMessage.sentAt) + const diff = + direction === 'next' + ? adjDate.getTime() - thisDate.getTime() + : thisDate.getTime() - adjDate.getTime() + return diff > CLUSTERED_MESSAGE_THRESHOLD_MS + } + return true +} let MessageItem = ({ item, + isGroupChat = false, + profile, }: { item: ConvoItem & {type: 'message' | 'pending-message'} + isGroupChat?: boolean + profile?: bsky.profile.AnyProfileView }): React.ReactNode => { const t = useTheme() const {currentAccount} = useSession() - const {_} = useLingui() + const {t: l} = useLingui() const {convo} = useConvoActive() + const moderationOpts = useModerationOpts() + const queryClient = useQueryClient() + + const reactionsControl = useDialogControl() + const reactionTapRef = useRef(false) const {message, nextMessage, prevMessage} = item const isPending = item.type === 'pending-message' + const displayName = sanitizeDisplayName( + profile?.displayName || sanitizeHandle(profile?.handle ?? ''), + ) + const isFromSelf = message.sender?.did === currentAccount?.did + const prevIsMessage = ChatBskyConvoDefs.isMessageView(prevMessage) const nextIsMessage = ChatBskyConvoDefs.isMessageView(nextMessage) - const isNextFromSelf = - nextIsMessage && nextMessage.sender?.did === currentAccount?.did + const isPrevFromSameSender = + prevIsMessage && prevMessage.sender?.did === message.sender?.did + const isNextFromSameSender = + nextIsMessage && nextMessage.sender?.did === message.sender?.did - const isNextFromSameSender = isNextFromSelf === isFromSelf + const isFirstInCluster = useMemo( + () => + isWithinCluster({ + isPending, + adjacentMessage: prevMessage, + isFromSameSender: isPrevFromSameSender, + currentSentAt: message.sentAt, + direction: 'prev', + }), + [isPending, prevMessage, isPrevFromSameSender, message.sentAt], + ) - const isNewDay = useMemo(() => { - if (!prevMessage) return true + const isLastInCluster = useMemo( + () => + isWithinCluster({ + isPending, + adjacentMessage: nextMessage, + isFromSameSender: isNextFromSameSender, + currentSentAt: message.sentAt, + direction: 'next', + }), + [isPending, nextMessage, isNextFromSameSender, message.sentAt], + ) - const thisDate = new Date(message.sentAt) - const prevDate = new Date(prevMessage.sentAt) + const hasLargeGapFromPrev = + !ChatBskyConvoDefs.isMessageView(prevMessage) || + new Date(message.sentAt).getTime() - + new Date(prevMessage.sentAt).getTime() > + MESSAGE_GAP_THRESHOLD_MS - return localDateString(thisDate) !== localDateString(prevDate) - }, [message, prevMessage]) + const {isDividerToggled, toggleDivider} = useDateDividerToggle() + const isDateDividerToggled = isDividerToggled(message.id) + const isNextDateDividerToggled = + nextMessage != null && isDividerToggled(nextMessage.id) + const showDateDivider = hasLargeGapFromPrev - const isLastMessageOfDay = useMemo(() => { - if (!nextMessage || !nextIsMessage) return true + const effectiveFirstInCluster = isFirstInCluster || isDateDividerToggled + const effectiveLastInCluster = isLastInCluster || isNextDateDividerToggled + const isInCluster = !(effectiveFirstInCluster && effectiveLastInCluster) + const isInMiddleOfCluster = + isInCluster && !effectiveFirstInCluster && !effectiveLastInCluster - const thisDate = new Date(message.sentAt) - const prevDate = new Date(nextMessage.sentAt) + const hasReactions = message.reactions && message.reactions.length > 0 + const squaredBottomCorner = + !hasReactions && + isInCluster && + (isInMiddleOfCluster || effectiveFirstInCluster) + const squaredTopCorner = + isInCluster && (isInMiddleOfCluster || effectiveLastInCluster) - return localDateString(thisDate) !== localDateString(prevDate) - }, [message.sentAt, nextIsMessage, nextMessage]) - - const needsTail = isLastMessageOfDay || !isNextFromSameSender - - const isLastInGroup = useMemo(() => { - // if this message is pending, it means the next message is pending too - if (isPending && nextMessage) { - return false - } - - // or, if there's a 5 minute gap between this message and the next - if (ChatBskyConvoDefs.isMessageView(nextMessage)) { - const thisDate = new Date(message.sentAt) - const nextDate = new Date(nextMessage.sentAt) - - const diff = nextDate.getTime() - thisDate.getTime() - - // 5 minutes - return diff > 5 * 60 * 1000 - } - - return true - }, [message, nextMessage, isPending]) - - const pendingColor = t.palette.primary_200 + const pendingColor = t.palette.primary_300 const rt = useMemo(() => { return new RichTextAPI({text: message.text, facets: message.facets}) }, [message.text, message.facets]) + const hasEmbedAndText = + AppBskyEmbedRecord.isView(message.embed) && rt.text.length > 0 + + const targetBottomRadius = + squaredBottomCorner || hasEmbedAndText + ? SQUARED_BORDER_RADIUS + : BORDER_RADIUS + const targetTopRadius = squaredTopCorner + ? SQUARED_BORDER_RADIUS + : BORDER_RADIUS + + const bottomRadiusSV = useSharedValue(targetBottomRadius) + const topRadiusSV = useSharedValue(targetTopRadius) + + const showDisplayName = + isGroupChat && !isFromSelf && isFirstInCluster && !isOnlyEmoji(message.text) + const showAvatar = isGroupChat && !isFromSelf && isLastInCluster + + useEffect(() => { + bottomRadiusSV.set(withTiming(targetBottomRadius, {duration: 300})) + }, [targetBottomRadius, bottomRadiusSV]) + + useEffect(() => { + topRadiusSV.set(withTiming(targetTopRadius, {duration: 300})) + }, [targetTopRadius, topRadiusSV]) + + const borderRadiusStyle = useAnimatedStyle(() => + isFromSelf + ? { + borderBottomRightRadius: bottomRadiusSV.get(), + borderTopRightRadius: topRadiusSV.get(), + } + : { + borderBottomLeftRadius: bottomRadiusSV.get(), + borderTopLeftRadius: topRadiusSV.get(), + }, + ) + + const avatar = profile ? ( + unstableCacheProfileView(queryClient, profile)}> + + + ) : ( + + ) + + const groupedReactions = useMemo(() => { + const reactions = message.reactions ?? [] + const grouped = new Map< + string, + { + key: string + value: string + senders: ChatBskyConvoDefs.ReactionViewSender[] + count: number + } + >() + for (const reaction of reactions) { + if (!reaction) continue + const existing = grouped.get(reaction.value) + if (existing) { + existing.senders.push(reaction.sender) + existing.count++ + } else { + grouped.set(reaction.value, { + key: reaction.value, + value: reaction.value, + senders: [reaction.sender], + count: 1, + }) + } + } + return Array.from(grouped.values()) + }, [message.reactions]) + + const reactions = useMemo(() => message.reactions ?? [], [message.reactions]) + + const reactionsLabel = useMemo(() => { + if (reactions.length === 0) return '' + if (reactions.length === 1) { + const reaction = reactions[0] + const sender = reaction.sender + if (sender.did === currentAccount?.did) { + return l`You reacted ${reaction.value}` + } else { + const senderDid = reaction.sender.did + const sender = convo.members.find(member => member.did === senderDid) + if (sender) { + return l`${sanitizeDisplayName( + sender.displayName || sender.handle, + )} reacted ${reaction.value}` + } + return l`Someone reacted ${reaction.value}` + } + } + return l`${plural(reactions.length, { + one: '# person', + other: '# people', + })} reacted – ${groupedReactions.map(g => g.value).join(' ')}` + }, [reactions, groupedReactions, currentAccount?.did, convo.members, l]) + const appliedReactions = ( - {message.reactions && message.reactions.length > 0 && ( + {hasReactions ? ( - + - {message.reactions.map((reaction, _i, reactions) => { - let label - if (reaction.sender.did === currentAccount?.did) { - label = _(msg`You reacted ${reaction.value}`) - } else { - const senderDid = reaction.sender.did - const sender = convo.members.find( - member => member.did === senderDid, - ) - if (sender) { - label = _( - msg`${sanitizeDisplayName( - sender.displayName || sender.handle, - )} reacted ${reaction.value}`, - ) - } else { - label = _(msg`Someone reacted ${reaction.value}`) + ]} + onPressIn={() => { + // Don't toggle the date divider when tapping a reaction. + reactionTapRef.current = true + }} + onPressOut={() => { + // Include a delay here to account for tap-and-drag before release. + setTimeout(() => { + reactionTapRef.current = false + }, 100) + }} + onPress={() => (isGroupChat ? reactionsControl.open() : undefined)}> + {groupedReactions.map(group => ( + 1 && native(ZoomOut.delay(200)) } - } - return ( - 1 && native(ZoomOut.delay(200))} - layout={native(LinearTransition.delay(300))} - key={reaction.sender.did + reaction.value} - style={[a.p_2xs]} - accessible={true} - accessibilityLabel={label} - accessibilityHint={_( - msg`Double tap or long press the message to add a reaction`, - )}> - - {reaction.value} - - - ) - })} - + layout={native(LinearTransition.delay(300))} + key={group.value} + style={[a.py_2xs]}> + + {group.value} + + + ))} + {groupedReactions.length !== reactions.length && + reactions.length > 1 ? ( + + + {reactions.length} + + + ) : null} + - )} + ) : null} + ) + const messageInset = platform({ + ios: isFromSelf ? a.mr_md : isGroupChat ? a.ml_md : a.ml_sm, + android: isFromSelf ? a.mr_sm : isGroupChat ? a.ml_sm : undefined, + web: isFromSelf ? a.mr_sm : isGroupChat ? a.ml_sm : undefined, + }) + return ( <> - {isNewDay && } + {(showDateDivider || isDateDividerToggled) && ( + + + + )} - - {AppBskyEmbedRecord.isView(message.embed) && ( - - )} - {rt.text.length > 0 && ( + style={[messageInset, isFirstInCluster && !showDateDivider && a.mt_sm]}> + + {showAvatar ? ( - + style={[ + a.absolute, + a.bottom_0, + a.z_50, + { + transform: [{translateY: hasReactions ? -24 : 0}], + }, + ]}> + {avatar} - )} - - {IS_NATIVE && appliedReactions} - - - {!IS_NATIVE && appliedReactions} - - {isLastInGroup && ( + ) : null} + + {showDisplayName ? ( + + {displayName} + + ) : null} + { + if (reactionTapRef.current) return + if (!hasLargeGapFromPrev) { + LayoutAnimation.configureNext( + LayoutAnimation.Presets.easeInEaseOut, + ) + toggleDivider(message.id) + } + }}> + {rt.text.length > 0 && ( + + + + )} + {AppBskyEmbedRecord.isView(message.embed) && ( + + )} + {appliedReactions} + + + + {effectiveLastInCluster && ( )} @@ -244,8 +515,7 @@ let MessageItemMetadata = ({ style: StyleProp }): React.ReactNode => { const t = useTheme() - const {_} = useLingui() - const {message} = item + const {t: l} = useLingui() const handleRetry = useCallback( (e: GestureResponderEvent) => { @@ -258,75 +528,33 @@ let MessageItemMetadata = ({ [item], ) - const relativeTimestamp = useCallback( - (i18n: I18n, timestamp: string) => { - const date = new Date(timestamp) - const now = new Date() + const errorColor = t.palette.negative_400 - const time = i18n.date(date, { - hour: 'numeric', - minute: 'numeric', - }) - - const diff = now.getTime() - date.getTime() - - // if under 30 seconds - if (diff < 1000 * 30) { - return _(msg`Now`) - } - - return time - }, - [_], - ) - - return ( - - - {({timeElapsed}) => ( - - {timeElapsed} - - )} - - - {item.type === 'pending-message' && item.failed && ( - <> - {' '} - ·{' '} - - {_(msg`Failed to send`)} + switch (item.type) { + case 'pending-message': + return item.failed ? ( + + + Message failed to send. {item.retry && ( <> {' '} - ·{' '} - {_(msg`Retry`)} + style={[a.text_xs, {color: errorColor}]}> + Tap to retry + . )} - - )} - - ) + + ) : null + default: + return null + } } MessageItemMetadata = memo(MessageItemMetadata) export {MessageItemMetadata} diff --git a/src/components/dms/MessageItemEmbed.tsx b/src/components/dms/MessageItemEmbed.tsx index 67f07dd4fc..ba48b6e123 100644 --- a/src/components/dms/MessageItemEmbed.tsx +++ b/src/components/dms/MessageItemEmbed.tsx @@ -2,14 +2,24 @@ import {memo} from 'react' import {useWindowDimensions, View} from 'react-native' import {type $Typed, type AppBskyEmbedRecord} from '@atproto/api' -import {atoms as a, native, tokens, useTheme, web} from '#/alf' +import {atoms as a, native, useTheme, web} from '#/alf' import {Embed, PostEmbedViewContext} from '#/components/Post/Embed' import {MessageContextProvider} from './MessageContext' +const CLUSTERED_MESSAGE_GAP = 2 +const BORDER_RADIUS = 20 +const SQUARED_BORDER_RADIUS = 4 + let MessageItemEmbed = ({ embed, + isFromSelf, + squaredTopCorner, + squaredBottomCorner, }: { embed: $Typed + isFromSelf: boolean + squaredTopCorner: boolean + squaredBottomCorner: boolean }): React.ReactNode => { const t = useTheme() const screen = useWindowDimensions() @@ -18,7 +28,7 @@ let MessageItemEmbed = ({ - + diff --git a/src/components/dms/MessagesListHeader.tsx b/src/components/dms/MessagesListHeader.tsx index 3f7694a342..4d7c2d7e33 100644 --- a/src/components/dms/MessagesListHeader.tsx +++ b/src/components/dms/MessagesListHeader.tsx @@ -5,24 +5,29 @@ import { type ModerationCause, type ModerationDecision, } from '@atproto/api' -import {msg} from '@lingui/core/macro' -import {useLingui} from '@lingui/react' +import {useLingui} from '@lingui/react/macro' +import {useNavigation} from '@react-navigation/native' +import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' import {makeProfileLink} from '#/lib/routes/links' -import {sanitizeDisplayName} from '#/lib/strings/display-names' +import {type NavigationProp} from '#/lib/routes/types' +import {logger} from '#/logger' import {type Shadow} from '#/state/cache/profile-shadow' import {isConvoActive, useConvo} from '#/state/messages/convo' import {type ConvoItem} from '#/state/messages/convo/types' +import {useSession} from '#/state/session' import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' -import {atoms as a, useTheme, web} from '#/alf' +import {atoms as a, useTheme} from '#/alf' +import {AvatarBubbles} from '#/components/AvatarBubbles' +import {Button, ButtonIcon} from '#/components/Button' import {ConvoMenu} from '#/components/dms/ConvoMenu' -import {Bell2Off_Filled_Corner0_Rounded as BellStroke} from '#/components/icons/Bell2' +import {Bell2Off_Filled_Corner0_Rounded as BellOffIcon} from '#/components/icons/Bell2' +import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsHorizontalIcon} from '#/components/icons/DotGrid' import * as Layout from '#/components/Layout' import {Link} from '#/components/Link' -import {PostAlerts} from '#/components/moderation/PostAlerts' import {ProfileBadges} from '#/components/ProfileBadges' import {Text} from '#/components/Typography' -import {IS_WEB} from '#/env' +import {IS_LIQUID_GLASS, IS_WEB} from '#/env' const PFP_SIZE = IS_WEB ? 40 : Layout.HEADER_SLOT_SIZE @@ -48,7 +53,7 @@ export function MessagesListHeader({ }, [moderation]) return ( - + @@ -72,19 +77,12 @@ export function MessagesListHeader({ - @@ -108,22 +106,27 @@ function HeaderReady({ userBlock?: ModerationCause } }) { - const {_} = useLingui() + const {t: l} = useLingui() const t = useTheme() const convoState = useConvo() + const {currentAccount} = useSession() + + const navigation = useNavigation() + + const groupInfo = convoState.getGroupInfo?.() + const isGroupChat = groupInfo != null const isDeletedAccount = profile?.handle === 'missing.invalid' - const displayName = isDeletedAccount - ? _(msg`Deleted Account`) - : sanitizeDisplayName( - profile.displayName || profile.handle, - moderation.ui('displayName'), - ) + const displayName = isGroupChat + ? (groupInfo.name ?? l`${profile.handle}'s group chat`) + : isDeletedAccount + ? l`Deleted Account` + : createSanitizedDisplayName(profile, true, moderation.ui('displayName')) - // @ts-ignore findLast is polyfilled - esb const latestMessageFromOther = convoState.items.findLast( (item: ConvoItem) => - item.type === 'message' && item.message.sender.did === profile.did, + item.type === 'message' && + item.message.sender.did !== currentAccount?.did, ) const latestReportableMessage = @@ -131,85 +134,95 @@ function HeaderReady({ ? latestMessageFromOther.message : undefined + const handleNavigateToSettings = () => { + const convoId = convoState.convo?.id + if (convoId) { + navigation.navigate('MessagesConversationSettings', { + conversation: convoId, + }) + } else { + logger.error(`handleNavigateToSettings: missing convo ID`) + } + } + return ( - - - - - - {displayName} - - - - {!isDeletedAccount && ( - - @{profile.handle} + {isGroupChat ? ( + + + + {displayName} + + + ) : ( + + + + + + {displayName} + + {convoState.convo?.muted && ( <> - {' '} - ·{' '} - + {' '} + ·{' '} + + )} - - )} - - + + + + )} - {isConvoActive(convoState) && ( - - )} + {isConvoActive(convoState) ? ( + isGroupChat ? ( + + ) : ( + + ) + ) : null} - - - - ) } diff --git a/src/components/dms/ReactionsDialog.tsx b/src/components/dms/ReactionsDialog.tsx new file mode 100644 index 0000000000..f040e234e2 --- /dev/null +++ b/src/components/dms/ReactionsDialog.tsx @@ -0,0 +1,391 @@ +import {useRef, useState} from 'react' +import { + LayoutAnimation, + Pressable, + type ScrollView, + useWindowDimensions, + View, +} from 'react-native' +import Animated from 'react-native-reanimated' +import {type ChatBskyConvoDefs} from '@atproto/api' +import {Trans, useLingui} from '@lingui/react/macro' + +import {HITSLOP_10} from '#/lib/constants' +import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' +import {sanitizeHandle} from '#/lib/strings/handles' +import {type ActiveConvoStates, useConvoActive} from '#/state/messages/convo' +import {useSession} from '#/state/session' +import {DraggableScrollView} from '#/view/com/pager/DraggableScrollView' +import {UserAvatar} from '#/view/com/util/UserAvatar' +import {atoms as a, useTheme, web} from '#/alf' +import * as Dialog from '#/components/Dialog' +import * as Toast from '#/components/Toast' +import {Text} from '#/components/Typography' +import {IS_NATIVE, IS_WEB} from '#/env' +import type * as bsky from '#/types/bsky' + +type Reaction = { + key: string + value: string + senders: ChatBskyConvoDefs.ReactionViewSender[] + count: number +} + +export function ReactionsDialog({ + control, + members, + message, + reactions, + groupedReactions, +}: { + control: Dialog.DialogControlProps + members: bsky.profile.AnyProfileView[] + message: ChatBskyConvoDefs.MessageView + reactions?: ChatBskyConvoDefs.ReactionView[] + groupedReactions?: Reaction[] +}) { + const {t: l} = useLingui() + + const {height: screenHeight} = useWindowDimensions() + const {currentAccount} = useSession() + const convo = useConvoActive() + + const [selected, setSelected] = useState('all') + + const handleFilter = (value: string) => { + setSelected(value) + } + + const filteredReactions = reactions?.filter( + r => selected === 'all' || r.value === selected, + ) + + const header = ( + <> + + + Reactions + + + + + + ) + + return ( + setSelected('all')} + nativeOptions={{ + preventExpansion: true, + minHeight: screenHeight / 2, + maxHeight: screenHeight / 2, + }}> + + {IS_NATIVE ? header : null} + + {filteredReactions + ?.sort((a, b) => { + if (a.sender.did === currentAccount?.did) return -1 + if (b.sender.did === currentAccount?.did) return 1 + return 0 + }) + .map(reaction => { + const sender = members.find(m => m.did === reaction.sender.did) + if (!sender) return null + return ( + + ) + })} + + + ) +} + +function ReactionRow({ + control, + convo, + currentAccount, + message, + profile, + reaction, + allReactions, + selected, + setSelected, +}: { + control: Dialog.DialogControlProps + convo: ActiveConvoStates + currentAccount?: bsky.profile.AnyProfileView + message: ChatBskyConvoDefs.MessageView + profile: bsky.profile.AnyProfileView + reaction: ChatBskyConvoDefs.ReactionView + allReactions: ChatBskyConvoDefs.ReactionView[] + selected: string + setSelected: React.Dispatch> +}) { + const t = useTheme() + const {t: l} = useLingui() + + const isFromSelf = currentAccount?.did === profile.did + + const displayName = createSanitizedDisplayName(profile, true) + const handle = sanitizeHandle(profile?.handle ?? '', '@') + + const handleOnPress = () => { + const remainingReactions = + allReactions?.filter( + r => + !(r.value === reaction.value && r.sender.did === currentAccount?.did), + ) ?? [] + + if (remainingReactions.length === 0) { + control.close() + } else if ( + selected !== 'all' && + !remainingReactions.some(r => r.value === reaction.value) + ) { + // tab no longer exists + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) + setSelected('all') + } + + convo + .removeReaction(message.id, reaction.value) + .catch(() => Toast.show(l`Failed to remove emoji reaction`)) + } + + const inner = ( + <> + + + + + {displayName} + + + {isFromSelf ? l`Tap to remove` : handle} + + + + + + {reaction.value} + + + + ) + + if (isFromSelf) { + return ( + + {inner} + + ) + } + + return ( + + {inner} + + ) +} + +function ReactionTabs({ + groupedReactions, + selected, + totalReactions, + onFilter, +}: { + groupedReactions?: Reaction[] + selected: string + totalReactions: number + onFilter: (value: string) => void +}) { + const t = useTheme() + const {t: l} = useLingui() + + const scrollViewRef = useRef(null) + const scrollState = useRef({x: 0, width: 0}) + const tabLayouts = useRef>(new Map()) + + const handlePress = (value: string) => { + onFilter(value) + + // Scroll a partially-visible tab fully into view. + const layout = tabLayouts.current.get(value) + if (layout && scrollViewRef.current && scrollState.current.width > 0) { + const tabLeft = layout.x + const tabRight = layout.x + layout.width + const viewLeft = scrollState.current.x + const viewRight = viewLeft + scrollState.current.width + + if (tabLeft < viewLeft) { + scrollViewRef.current.scrollTo({ + x: Math.max(0, tabLeft - 24), + animated: true, + }) + } else if (tabRight > viewRight) { + scrollViewRef.current.scrollTo({ + x: tabRight - scrollState.current.width + 24, + animated: true, + }) + } + } + } + + const handleTabLayout = (key: string, layout: {x: number; width: number}) => { + tabLayouts.current.set(key, layout) + } + + const tabs = [ + { + key: 'all', + value: l`All`, + senders: [], + count: totalReactions, + } as Reaction, + ...(groupedReactions ?? []), + ] + + return ( + + { + scrollState.current = { + x: e.nativeEvent.contentOffset.x, + width: e.nativeEvent.layoutMeasurement.width, + } + }} + onLayout={e => { + scrollState.current.width = e.nativeEvent.layout.width + }}> + + {tabs?.map((reaction, index) => ( + + ))} + + + + ) +} + +function ReactionTab({ + index, + reaction, + selected, + total, + onPress, + onTabLayout, +}: { + index: number + reaction: Reaction + selected: string + total: number + onPress: (value: string) => void + onTabLayout: (key: string, layout: {x: number; width: number}) => void +}) { + const t = useTheme() + const {t: l} = useLingui() + + return ( + { + onTabLayout(reaction.key, { + x: e.nativeEvent.layout.x, + width: e.nativeEvent.layout.width, + }) + }} + onPress={() => onPress(reaction.key)}> + + {l`${reaction.value} ${reaction.count}`} + + + ) +} diff --git a/src/components/dms/components/EmptyMemberList.tsx b/src/components/dms/components/EmptyMemberList.tsx new file mode 100644 index 0000000000..4fbe980a46 --- /dev/null +++ b/src/components/dms/components/EmptyMemberList.tsx @@ -0,0 +1,16 @@ +import {View} from 'react-native' + +import {atoms as a, useTheme} from '#/alf' +import {Text} from '#/components/Typography' + +export function EmptyMemberList({message}: {message: string}) { + const t = useTheme() + return ( + + + {message} + + (╯°□°)╯︵ ┻━┻ + + ) +} diff --git a/src/components/dms/components/GroupChatProfileCard.tsx b/src/components/dms/components/GroupChatProfileCard.tsx new file mode 100644 index 0000000000..49ef5cc5b0 --- /dev/null +++ b/src/components/dms/components/GroupChatProfileCard.tsx @@ -0,0 +1,65 @@ +import {View} from 'react-native' +import {moderateProfile, type ModerationOpts} from '@atproto/api' +import {Trans} from '@lingui/react/macro' + +import {sanitizeDisplayName} from '#/lib/strings/display-names' +import {sanitizeHandle} from '#/lib/strings/handles' +import {atoms as a, useTheme} from '#/alf' +import {canBeMessaged} from '#/components/dms/util' +import * as Toggle from '#/components/forms/Toggle' +import * as ProfileCard from '#/components/ProfileCard' +import {Text} from '#/components/Typography' +import type * as bsky from '#/types/bsky' + +export function GroupChatProfileCard({ + profile, + moderationOpts, +}: { + profile: bsky.profile.AnyProfileView + moderationOpts: ModerationOpts +}) { + const t = useTheme() + const enabled = canBeMessaged(profile) + const moderation = moderateProfile(profile, moderationOpts) + const handle = sanitizeHandle(profile.handle, '@') + const displayName = sanitizeDisplayName( + profile.displayName || sanitizeHandle(profile.handle), + moderation.ui('displayName'), + ) + + return ( + + + + + + + {enabled ? ( + + ) : ( + + {handle} can’t be messaged + + )} + + + + {enabled ? : null} + + ) +} diff --git a/src/components/dms/components/ProfileCardSkeleton.tsx b/src/components/dms/components/ProfileCardSkeleton.tsx new file mode 100644 index 0000000000..60e7228ab6 --- /dev/null +++ b/src/components/dms/components/ProfileCardSkeleton.tsx @@ -0,0 +1,21 @@ +import {View} from 'react-native' + +import {atoms as a} from '#/alf' +import * as ProfileCard from '#/components/ProfileCard' + +export function ProfileCardSkeleton() { + return ( + + + + + ) +} diff --git a/src/components/dms/components/UserLabel.tsx b/src/components/dms/components/UserLabel.tsx new file mode 100644 index 0000000000..ebee466789 --- /dev/null +++ b/src/components/dms/components/UserLabel.tsx @@ -0,0 +1,15 @@ +import {View} from 'react-native' + +import {atoms as a, useTheme} from '#/alf' +import {Text} from '#/components/Typography' + +export function UserLabel({message}: {message: string}) { + const t = useTheme() + return ( + + + {message} + + + ) +} diff --git a/src/components/dms/components/UserSearchInput.tsx b/src/components/dms/components/UserSearchInput.tsx new file mode 100644 index 0000000000..c83a48bb18 --- /dev/null +++ b/src/components/dms/components/UserSearchInput.tsx @@ -0,0 +1,68 @@ +import {TextInput, View} from 'react-native' +import {useLingui} from '@lingui/react/macro' + +import {atoms as a, useTheme, web} from '#/alf' +import {useInteractionState} from '#/components/hooks/useInteractionState' +import {MagnifyingGlass_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass' + +export function UserSearchInput({ + value, + onChangeText, + onEscape, + inputRef, +}: { + value: string + onChangeText: (text: string) => void + onEscape: () => void + inputRef: React.RefObject +}) { + const t = useTheme() + const {t: l} = useLingui() + const { + state: hovered, + onIn: onMouseEnter, + onOut: onMouseLeave, + } = useInteractionState() + const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() + const interacted = hovered || focused + + return ( + + + { + if (nativeEvent.key === 'Escape') { + onEscape() + } + }} + autoCorrect={false} + autoComplete="off" + autoCapitalize="none" + autoFocus + accessibilityLabel={l`Search profiles`} + accessibilityHint={l`Searches for profiles`} + /> + + ) +} diff --git a/src/components/dms/dialogs/NewChatDialog.tsx b/src/components/dms/dialogs/NewChatDialog.tsx index 72b417665c..6686a6ffe2 100644 --- a/src/components/dms/dialogs/NewChatDialog.tsx +++ b/src/components/dms/dialogs/NewChatDialog.tsx @@ -3,13 +3,14 @@ import {Trans, useLingui} from '@lingui/react/macro' import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification' import {logger} from '#/logger' +import {useCreateGroupChat} from '#/state/queries/messages/create-group-chat' import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members' import {FAB} from '#/view/com/util/fab/FAB' import {useTheme} from '#/alf' import * as Dialog from '#/components/Dialog' import {SearchablePeopleList} from '#/components/dialogs/SearchablePeopleList' import {InitiateChatFlow} from '#/components/dms/InitiateChatFlow' -import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' +import {MessagePlus_Stroke2_Corner0_Rounded as NewChatIcon} from '#/components/icons/Message' import * as Toast from '#/components/Toast' import {useAnalytics} from '#/analytics' @@ -38,12 +39,28 @@ export function NewChat({ }, onError: error => { logger.error('Failed to create chat', {safeMessage: error}) - Toast.show(l`An issue occurred starting the chat`, { + Toast.show(l`An issue occurred starting the chat, please try again`, { type: 'error', }) }, }) + const {mutate: createGroupChat} = useCreateGroupChat({ + onSuccess: data => { + onNewChat(data.convo.id) + ax.metric('groupchat:create', {logContext: 'NewChatDialog'}) + }, + onError: error => { + logger.error('Failed to create groupchat', {safeMessage: error}) + Toast.show( + l`An issue occurred creating the group chat, please try again`, + { + type: 'error', + }, + ) + }, + }) + const onCreateChat = useCallback( (did: string) => { control.close(() => createChat([did])) @@ -52,10 +69,21 @@ export function NewChat({ ) const onCreateGroupChat = useCallback( - (_dids: string[], _groupName: string) => { - control.close() + (members: string[], name: string) => { + control.close(() => { + createGroupChat({members, name}) + }) }, - [control], + [control, createGroupChat], + ) + + const onSelectExistingChat = useCallback( + (chatId: string) => { + control.close(() => { + onNewChat(chatId) + }) + }, + [control, onNewChat], ) const onPress = useCallback(() => { @@ -74,7 +102,7 @@ export function NewChat({ } + icon={} accessibilityRole="button" accessibilityLabel={l`New chat`} accessibilityHint="" @@ -93,7 +121,13 @@ export function NewChat({ ) : ( { + if (chat.kind === 'user') { + onCreateChat(chat.did) + } else { + onSelectExistingChat(chat.id) + } + }} sortByMessageDeclaration /> )} diff --git a/src/components/dms/dialogs/ShareViaChatDialog.tsx b/src/components/dms/dialogs/ShareViaChatDialog.tsx index faf3545519..30cd80862d 100644 --- a/src/components/dms/dialogs/ShareViaChatDialog.tsx +++ b/src/components/dms/dialogs/ShareViaChatDialog.tsx @@ -53,6 +53,13 @@ function SendViaChatDialogInner({ }, }) + const onSelectExistingChat = useCallback( + (chatId: string) => { + control.close(() => onSelectChat(chatId)) + }, + [control, onSelectChat], + ) + const onCreateChat = useCallback( (did: string) => { control.close(() => createChat([did])) @@ -63,7 +70,13 @@ function SendViaChatDialogInner({ return ( { + if (chat.kind === 'user') { + onCreateChat(chat.did) + } else { + onSelectExistingChat(chat.id) + } + }} showRecentConvos sortByMessageDeclaration /> diff --git a/src/components/dms/dialogs/TextInput.tsx b/src/components/dms/dialogs/TextInput.tsx deleted file mode 100644 index b4e77e3e07..0000000000 --- a/src/components/dms/dialogs/TextInput.tsx +++ /dev/null @@ -1 +0,0 @@ -export {BottomSheetTextInput as TextInput} from '@discord/bottom-sheet/src' diff --git a/src/components/dms/dialogs/TextInput.web.tsx b/src/components/dms/dialogs/TextInput.web.tsx deleted file mode 100644 index 5371a534f1..0000000000 --- a/src/components/dms/dialogs/TextInput.web.tsx +++ /dev/null @@ -1 +0,0 @@ -export {TextInput} from 'react-native' diff --git a/src/components/dms/util.ts b/src/components/dms/util.ts index 2bcc9c3bdf..491023cf2f 100644 --- a/src/components/dms/util.ts +++ b/src/components/dms/util.ts @@ -1,7 +1,8 @@ -import {type ChatBskyConvoDefs} from '@atproto/api' +import {type $Typed, ChatBskyActorDefs, ChatBskyConvoDefs} from '@atproto/api' import {EMOJI_REACTION_LIMIT} from '#/lib/constants' -import type * as bsky from '#/types/bsky' +import {logger} from '#/logger' +import * as bsky from '#/types/bsky' export function canBeMessaged(profile: bsky.profile.AnyProfileView) { switch (profile.associated?.chat?.allowIncoming) { @@ -54,3 +55,99 @@ export function hasReachedReactionLimit( ) return myReactions.length >= EMOJI_REACTION_LIMIT } + +type GroupConvoMember = ChatBskyActorDefs.ProfileViewBasic & { + // can be missing if account deleted + kind?: $Typed +} + +type DirectConvoMember = ChatBskyActorDefs.ProfileViewBasic & { + kind: $Typed +} + +export type ConvoWithDetails = {view: ChatBskyConvoDefs.ConvoView} & ( + | { + kind: 'group' + details: ChatBskyConvoDefs.GroupConvo + primaryMember: GroupConvoMember // the owner + members: Array + } + | { + kind: 'direct' + details: ChatBskyConvoDefs.DirectConvo + primaryMember: DirectConvoMember // the other user + members: Array + } +) + +/** + * Converts a raw convoView into something easier to use (i.e. extracts chat owner) + * and enforces the correct type for convo members. + */ +export function parseConvoView( + convoView: ChatBskyConvoDefs.ConvoView, + ownDid: string | undefined, +): ConvoWithDetails | null { + if ( + bsky.dangerousIsType( + convoView.kind, + ChatBskyConvoDefs.isGroupConvo, + ) + ) { + let owner: GroupConvoMember | undefined = undefined + + for (const member of convoView.members) { + if ( + bsky.dangerousIsType( + member.kind, + ChatBskyActorDefs.isGroupConvoMember, + ) + ) { + if (member.kind.role === 'owner') { + // have to do a type assertion here + // this works: {...member, kind: member.kind} + // however that's creating a new object for no good reason + owner = member as GroupConvoMember + } + } else { + throw new Error( + 'Expected a GroupConvoMember, got an unknown kind of member', + ) + } + } + + if (!owner) { + throw new Error('No owner found in group convo') + } + + return { + view: convoView, + kind: 'group', + details: convoView.kind, + primaryMember: owner, + members: convoView.members as Array, + } + } else if ( + bsky.dangerousIsType( + convoView.kind, + ChatBskyConvoDefs.isDirectConvo, + ) + ) { + const otherUser = convoView.members.find(m => m.did !== ownDid) + + if (!otherUser) { + throw new Error('No other user found in direct convo') + } + + return { + view: convoView, + kind: 'direct', + details: convoView.kind, + primaryMember: otherUser as DirectConvoMember, + members: convoView.members as Array, + } + } else { + logger.warn('Unknown convo kind: ' + JSON.stringify(convoView.kind)) + return null + } +} diff --git a/src/components/icons/Message.tsx b/src/components/icons/Message.tsx index e3ca70f01b..35d6deb222 100644 --- a/src/components/icons/Message.tsx +++ b/src/components/icons/Message.tsx @@ -15,3 +15,7 @@ export const Message_Stroke2_Corner0_Rounded_Filled = createSinglePathSVG({ export const Message_Stroke2_Corner0_Rounded = createSinglePathSVG({ path: 'M4 12a8 8 0 1 1 4.445 7.169 1 1 0 0 0-.629-.088l-3.537.662.7-3.415a1 1 0 0 0-.09-.66A7.961 7.961 0 0 1 4 12Zm8-10C6.477 2 2 6.477 2 12c0 1.523.341 2.968.951 4.262l-.93 4.537a1 1 0 0 0 1.163 1.184l4.68-.876A9.968 9.968 0 0 0 12 22c5.523 0 10-4.477 10-10S17.523 2 12 2ZM7.5 13.25a1.25 1.25 0 1 1 0-2.5 1.25 1.25 0 0 1 0 2.5Zm4.5 0a1.25 1.25 0 1 1 0-2.5 1.25 1.25 0 0 1 0 2.5Zm4.5 0a1.25 1.25 0 1 1 0-2.5 1.25 1.25 0 0 1 0 2.5Z', }) + +export const MessagePlus_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10a10 10 0 0 1-4.136-.893l-4.68.876A1 1 0 0 1 2.02 20.8l.93-4.537A10 10 0 0 1 2 12C2 6.477 6.477 2 12 2Zm0 2a8 8 0 0 0-7.111 11.668 1 1 0 0 1 .09.66l-.7 3.415 3.537-.662c.214-.04.435-.009.63.088A8 8 0 1 0 12 4Zm0 4a1 1 0 0 1 1 1v2h2a1 1 0 1 1 0 2h-2v2a1 1 0 1 1-2 0v-2H9a1 1 0 1 1 0-2h2V9a1 1 0 0 1 1-1Z', +}) diff --git a/src/components/images/Gallery/const.ts b/src/components/images/Gallery/const.ts new file mode 100644 index 0000000000..443b65ecb8 --- /dev/null +++ b/src/components/images/Gallery/const.ts @@ -0,0 +1,3 @@ +export const ITEM_GAP = 8 // tokens.space.sm +export const MIN_ASPECT_RATIO = 2 / 3 // portrait limit +export const MAX_ASPECT_RATIO = 3 / 2 // landscape limit diff --git a/src/components/images/Gallery/index.tsx b/src/components/images/Gallery/index.tsx new file mode 100644 index 0000000000..7c3a84e9d0 --- /dev/null +++ b/src/components/images/Gallery/index.tsx @@ -0,0 +1,531 @@ +import { + cloneElement, + createContext, + isValidElement, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react' +import {FlatList, Pressable, useWindowDimensions, View} from 'react-native' +import Animated, { + type AnimatedRef, + useAnimatedRef, +} from 'react-native-reanimated' +import {Image} from 'expo-image' +import {type AppBskyEmbedImages} from '@atproto/api' +import {utils} from '@bsky.app/alf' +import {Trans, useLingui} from '@lingui/react/macro' +import debounce from 'lodash.debounce' + +import {type Dimensions} from '#/lib/media/types' +import {mergeRefs} from '#/lib/merge-refs' +import {useA11y} from '#/state/a11y' +import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge' +import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture' +import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' +import {ArrowsDiagonalOut_Stroke2_Corner0_Rounded as Fullscreen} from '#/components/icons/ArrowsDiagonal' +import {AutoSizedImage} from '#/components/images/AutoSizedImage' +import { + ITEM_GAP, + MAX_ASPECT_RATIO, + MIN_ASPECT_RATIO, +} from '#/components/images/Gallery/const' +import {useKeyboardHandlers} from '#/components/images/Gallery/useKeyboardHandlers' +import {usePointerHandlers} from '#/components/images/Gallery/usePointerHandlers' +import {getAspectRatio} from '#/components/images/Gallery/utils' +import {MediaInsetBorder} from '#/components/MediaInsetBorder' +import {PostEmbedViewContext} from '#/components/Post/Embed/types' +import {Text} from '#/components/Typography' +import {useAnalytics} from '#/analytics' +import {IS_WEB} from '#/env' + +export * from './const' +export * from './maybeApplyGalleryOffsetStyles' + +interface GalleryProps { + images: AppBskyEmbedImages.ViewImage[] + onPress?: ( + index: number, + containerRefs: AnimatedRef[], + fetchedDims: (Dimensions | null)[], + ) => void + onPressIn?: (index: number) => void + viewContext?: PostEmbedViewContext +} + +const Context = createContext<{ + bleedRef: React.RefObject + bleedWidth: number +}>({ + bleedRef: {current: null}, + bleedWidth: 0, +}) + +export function GalleryBleed({children}: {children: React.ReactNode}) { + const ref = useRef(null) + const [bleedWidth, setBleedWidth] = useState(0) + + if (!isValidElement(children)) { + throw new Error('GalleryBleed children must be a single React element') + } + + const node = children as React.ReactElement + + return ( + + {cloneElement(node, { + ref: mergeRefs([ref, node?.props?.ref]), + onLayout: (e: {nativeEvent: {layout: {width: number}}}) => { + setBleedWidth(e.nativeEvent.layout.width) + node.props.onLayout?.(e) + }, + style: [node.props.style, a.overflow_hidden], + })} + + ) +} + +export function useGalleryBleed() { + return useContext(Context) +} + +export function Gallery({ + images, + onPress, + onPressIn, + viewContext, +}: GalleryProps) { + const {t: l} = useLingui() + const ax = useAnalytics() + const {screenReaderEnabled} = useA11y() + const largeAltBadge = useLargeAltBadgeEnabled() + const bps = useBreakpoints() + const window = useWindowDimensions() + const contentHeight = useMemo(() => { + if (bps.gtMobile) { + return 300 + } else if (bps.gtPhone) { + return 260 + } else { + return 200 + } + }, [bps]) + const isWithinQuote = + viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia + const hideBadges = isWithinQuote + + /* + * Container overflow styles + * + * Uses measureLayout to get the Gallery's offset relative to the GalleryBleed + * ancestor. This is a layout-relative measurement that doesn't depend on + * scroll position, so it works correctly for off-screen FlatList items. + */ + const {bleedRef, bleedWidth} = useGalleryBleed() + const contentRef = useRef(null) + const [contentDims, setContentDims] = useState<{x: number; width: number}>() + const measure = () => { + if (contentRef.current && bleedRef.current) { + contentRef.current.measureLayout( + bleedRef.current, + (x, _y, w) => { + setContentDims({x, width: w}) + }, + () => {}, + ) + } + } + const width = bleedWidth || Math.min(600, window.width) + const insetLeft = contentDims?.x ?? 0 + const insetRight = + bleedWidth > 0 + ? bleedWidth - (contentDims?.x ?? 0) - (contentDims?.width ?? 0) + : 0 + /* End container overflow styles */ + + const flatListRef = useRef(null) + const itemWidthsRef = useRef>(new Map()) + const itemRefsRef = useRef>(new Map()) + const containerRefsRef = useRef>>(new Map()) + const thumbDimsRef = useRef>(new Map()) + const currentIndexRef = useRef(0) + + const emitSwipeMetric = useMemo( + () => + debounce((fromIndex: number, toIndex: number) => { + ax.metric('post:gallery:swipe', { + fromImage: fromIndex + 1, // convert to 1-based index for easier analysis + toImage: toIndex + 1, // convert to 1-based index for easier analysis + totalImages: images.length, + }) + }, 200), + [ax, images.length], + ) + + const setCurrentIndex = (index: number) => { + const prev = currentIndexRef.current + if (prev !== index) { + currentIndexRef.current = index + emitSwipeMetric(prev, index) + } + } + + const scrollTo = (offset: number) => { + flatListRef.current?.scrollToOffset({offset, animated: false}) + } + + const onSettle = (index: number) => { + setCurrentIndex(index) + if (!IS_WEB) return + // Update tabIndex: only the active image is tab-focusable + itemRefsRef.current.forEach((node, i) => { + const el = node as unknown as HTMLElement + el.tabIndex = i === index ? 0 : -1 + }) + const el = itemRefsRef.current.get(index) as unknown as HTMLElement | null + el?.focus({preventScroll: true}) + } + + useKeyboardHandlers({ + flatListRef, + itemWidthsRef, + currentIndexRef, + scrollTo, + onSettle, + imageCount: images.length, + }) + + usePointerHandlers({ + flatListRef, + itemWidthsRef, + currentIndexRef, + scrollTo, + onSettle, + imageCount: images.length, + }) + + if (screenReaderEnabled) { + return ( + + {images.map((image, index) => ( + + onPress?.(index, [containerRef], [dims]) + } + onPressIn={() => onPressIn?.(index)} + hideBadge={ + viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia + } + /> + ))} + + ) + } + + return ( + + + item.thumb + index} + renderItem={({item, index}) => { + return ( + { + itemWidthsRef.current.set(i, w) + }} + itemRef={node => { + if (node) { + itemRefsRef.current.set(index, node) + } else { + itemRefsRef.current.delete(index) + } + }} + onContainerRef={(i, ref) => { + containerRefsRef.current.set(i, ref) + }} + onThumbDims={(i, dims) => { + thumbDimsRef.current.set(i, dims) + }} + onPress={ + onPress + ? () => { + ax.metric('post:gallery:openLightbox', { + fromImage: index + 1, // convert to 1-based index for easier analysis + totalImages: images.length, + }) + const refs: AnimatedRef[] = [] + const dims: (Dimensions | null)[] = [] + for (let i = 0; i < images.length; i++) { + refs.push(containerRefsRef.current.get(i)!) + dims.push(thumbDimsRef.current.get(i) ?? null) + } + onPress(index, refs, dims) + } + : undefined + } + onPressIn={onPressIn ? () => onPressIn(index) : undefined} + /> + ) + }} + onScroll={e => { + // web handles via onSettle in the web hooks + if (IS_WEB) return + const offsetX = e.nativeEvent.contentOffset.x + let accumulated = 0 + for (let i = 0; i < images.length; i++) { + const w = (itemWidthsRef.current.get(i) ?? 0) + ITEM_GAP + if (offsetX < accumulated + w / 2) { + setCurrentIndex(i) + break + } + accumulated += w + if (i === images.length - 1) { + setCurrentIndex(i) + } + } + }} + style={[ + { + height: contentHeight, + marginLeft: -insetLeft, + width, + }, + ]} + contentContainerStyle={{ + gap: ITEM_GAP, + paddingLeft: insetLeft, + paddingRight: insetRight, + }} + /> + + + ) +} + +function computeDims({ + height, + aspectRatio, +}: { + height: number + aspectRatio?: number +}) { + /* + * Old images, or images from other clients can sometimes not have + * aspectRatio populated. In these cases, default to square and we'll + * resize once the image loads. + * + * Clamp between MIN_ASPECT_RATIO (portrait) and MAX_ASPECT_RATIO + * (landscape) so items stay a reasonable size in the carousel. + */ + const raw = aspectRatio ?? 1 + const clamped = Math.max(MIN_ASPECT_RATIO, Math.min(raw, MAX_ASPECT_RATIO)) + const width = Math.floor(height * clamped) + return {width, height, aspectRatio: clamped, isCropped: raw !== clamped} +} + +function GalleryImage({ + contentHeight: height, + image, + index, + imageCount, + onWidthChange, + itemRef, + hideBadges, + largeAltBadge, + onContainerRef, + onThumbDims, + onPress, + onPressIn, +}: { + contentHeight: number + image: AppBskyEmbedImages.ViewImage + index: number + imageCount: number + onWidthChange: (index: number, width: number) => void + itemRef: (node: View | null) => void + hideBadges?: boolean + largeAltBadge?: boolean + onContainerRef: (index: number, ref: AnimatedRef) => void + onThumbDims: (index: number, dims: Dimensions) => void + onPress?: () => void + onPressIn?: () => void +}) { + const t = useTheme() + const {t: l} = useLingui() + const [focused, setFocused] = useState(false) + const containerRef = useAnimatedRef() + const [aspectRatio, setAspectRatio] = useState(() => + getAspectRatio(image.aspectRatio), + ) + const {isCropped, ...dims} = computeDims({height, aspectRatio}) + const hasAlt = !!image.alt + + useEffect(() => { + onWidthChange(index, dims.width) + }, [index, dims.width, onWidthChange]) + + useEffect(() => { + onContainerRef(index, containerRef) + }, [index, containerRef, onContainerRef]) + + return ( + + setFocused(true)} + onBlur={() => setFocused(false)} + accessibilityRole="button" + accessibilityLabel={image.alt || l`Image ${index + 1}`} + accessibilityHint={l`Opens full image`} + android_ripple={{ + color: utils.alpha(t.atoms.bg.backgroundColor, 0.2), + foreground: true, + }} + style={[ + a.rounded_md, + a.overflow_hidden, + t.atoms.bg_contrast_25, + web({ + cursor: 'inherit', + outline: 0, + border: 0, + }), + ]}> + { + const ar = getAspectRatio(e.source) + if (ar && ar !== aspectRatio) { + setAspectRatio(ar) + } + onThumbDims(index, { + width: e.source.width, + height: e.source.height, + }) + }} + /> + + {(hasAlt || isCropped) && !hideBadges ? ( + + {isCropped && ( + + + + )} + {hasAlt && ( + + + ALT + + + )} + + ) : null} + + + + + ) +} diff --git a/src/components/images/Gallery/maybeApplyGalleryOffsetStyles.ts b/src/components/images/Gallery/maybeApplyGalleryOffsetStyles.ts new file mode 100644 index 0000000000..5081b5a2d5 --- /dev/null +++ b/src/components/images/Gallery/maybeApplyGalleryOffsetStyles.ts @@ -0,0 +1,102 @@ +import { + AppBskyEmbedImages, + AppBskyEmbedRecordWithMedia, + type AppBskyFeedDefs, + AppBskyFeedPost, + type ModerationCause, + type ModerationUI, +} from '@atproto/api' + +import {unique} from '#/lib/moderation' +import {type AppModerationCause} from '#/components/Pills' +import {Features, features} from '#/analytics/features' +import * as bsky from '#/types/bsky' + +export const POST_META_NO_CONTENT_OFFSET = {paddingTop: 10} +export const POST_EMBED_NO_CONTENT_OFFSET = {paddingTop: 6} + +export function maybeApplyGalleryOffsetStyles( + placement: 'meta' | 'embed', + { + post, + modui, + additionalCauses, + }: { + post: AppBskyFeedDefs.PostView + modui: ModerationUI + additionalCauses?: ModerationCause[] | AppModerationCause[] + }, +) { + // don't ever check gates like this, except this one time + if (!features.isOn(Features.PostGalleryEmbedEnable)) return + + if ( + !bsky.dangerousIsType( + post.record, + AppBskyFeedPost.isRecord, + ) + ) { + return + } + + /* + * First check if we even have images + */ + const embed = post.record.embed + const isImageEmbed = + embed && + bsky.dangerousIsType( + embed, + AppBskyEmbedImages.isMain, + ) + const isRecordWithMedia = + embed && + bsky.dangerousIsType( + embed, + AppBskyEmbedRecordWithMedia.isMain, + ) + let hasImages = false + if (isImageEmbed) { + // one image, not a gallery + if (embed.images.length === 1) return + hasImages = true + } + if (isRecordWithMedia) { + if ( + bsky.dangerousIsType( + embed.media, + AppBskyEmbedImages.isMain, + ) + ) { + // one image, not a gallery + if (embed.media.images.length === 1) return + } + hasImages = true + } + if (!hasImages) return + + /* + * Then check if we have any text + */ + let hasLabels = false + if (modui.alert) { + hasLabels = modui.alerts.filter(unique).length > 0 + } + if (modui.inform) { + hasLabels = hasLabels || modui.informs.filter(unique).length > 0 + } + if (additionalCauses?.length) { + hasLabels = true + } + + /* + * If no text or labels, then we need a lil bump + */ + const shouldApplyOffset = !post.record.text && !hasLabels + + return shouldApplyOffset + ? placement === 'meta' + ? POST_META_NO_CONTENT_OFFSET + : POST_EMBED_NO_CONTENT_OFFSET + : {} +} diff --git a/src/components/images/Gallery/tween.ts b/src/components/images/Gallery/tween.ts new file mode 100644 index 0000000000..4d0042e475 --- /dev/null +++ b/src/components/images/Gallery/tween.ts @@ -0,0 +1,40 @@ +function ease(t: number, b: number, c: number, d: number) { + return t === d ? b + c : c * (-Math.pow(2, (-10 * t) / d) + 1) + b +} + +/** + * Tween from `start` to `end` over `duration` ms using an exponential ease-out. + * Returns a function that starts the tween. That function returns a stop handle. + * + * Adapted from tinkerbell. + */ +export function tween(start: number, end: number, duration: number) { + return function run(cb: (v: number) => void, done?: () => void) { + let ts: number | undefined + let frame: number + + frame = (function tick(last: number) { + return requestAnimationFrame(t => { + if (!ts) ts = t + const te = t - ts + const next = Math.round(ease(te, start, end - start, duration)) + if ( + (end > start + ? next < end && last <= end + : next > end && last >= end) && + te <= duration + ) { + frame = tick(next) + cb(next) + } else { + cb(end) + done?.() + } + }) + })(start) + + return function stop() { + cancelAnimationFrame(frame) + } + } +} diff --git a/src/components/images/Gallery/useKeyboardHandlers.ts b/src/components/images/Gallery/useKeyboardHandlers.ts new file mode 100644 index 0000000000..324ea28d2f --- /dev/null +++ b/src/components/images/Gallery/useKeyboardHandlers.ts @@ -0,0 +1,8 @@ +export function useKeyboardHandlers(_args: { + flatListRef: any + itemWidthsRef: any + currentIndexRef: any + scrollTo: any + onSettle: any + imageCount: any +}) {} diff --git a/src/components/images/Gallery/useKeyboardHandlers.web.ts b/src/components/images/Gallery/useKeyboardHandlers.web.ts new file mode 100644 index 0000000000..62cf79c287 --- /dev/null +++ b/src/components/images/Gallery/useKeyboardHandlers.web.ts @@ -0,0 +1,91 @@ +import {useEffect} from 'react' +import {type FlatList} from 'react-native' + +import {tween} from '#/components/images/Gallery/tween' +import {getOffsetForIndex} from '#/components/images/Gallery/utils' + +const SETTLE_DURATION = 700 + +export function useKeyboardHandlers({ + flatListRef, + itemWidthsRef, + currentIndexRef, + scrollTo, + onSettle, + imageCount, +}: { + flatListRef: React.RefObject + itemWidthsRef: React.RefObject> + currentIndexRef: React.RefObject + scrollTo: (offset: number) => void + onSettle: (index: number) => void + imageCount: number +}) { + useEffect(() => { + if (imageCount <= 1) return + + let stopTween: (() => void) | null = null + let pendingIndex: number | null = null + + const onKeyDown = (e: KeyboardEvent) => { + const el = + flatListRef.current?.getScrollableNode() as unknown as HTMLElement | null + if (!el || !el.contains(document.activeElement)) return + + const current = pendingIndex ?? currentIndexRef.current + let targetIndex: number | undefined + + if (e.key === 'ArrowRight') { + if (current < imageCount - 1) { + targetIndex = current + 1 + } + } else if (e.key === 'ArrowLeft') { + if (current > 0) { + targetIndex = current - 1 + } + } + + if (targetIndex != null) { + e.preventDefault() + + if (stopTween) { + stopTween() + stopTween = null + } + + pendingIndex = targetIndex + const from = el.scrollLeft + const to = getOffsetForIndex(itemWidthsRef.current, targetIndex) + const idx = targetIndex + + stopTween = tween( + from, + to, + SETTLE_DURATION, + )( + v => { + scrollTo(v) + }, + () => { + stopTween = null + pendingIndex = null + onSettle(idx) + }, + ) + } + } + + window.addEventListener('keydown', onKeyDown) + return () => { + window.removeEventListener('keydown', onKeyDown) + if (stopTween) stopTween() + } + }, [ + flatListRef, + itemWidthsRef, + currentIndexRef, + scrollTo, + onSettle, + imageCount, + ]) +} diff --git a/src/components/images/Gallery/usePointerHandlers.ts b/src/components/images/Gallery/usePointerHandlers.ts new file mode 100644 index 0000000000..661c20b511 --- /dev/null +++ b/src/components/images/Gallery/usePointerHandlers.ts @@ -0,0 +1,8 @@ +export function usePointerHandlers(_args: { + flatListRef: any + itemWidthsRef: any + currentIndexRef: any + scrollTo: any + onSettle: any + imageCount: any +}) {} diff --git a/src/components/images/Gallery/usePointerHandlers.web.ts b/src/components/images/Gallery/usePointerHandlers.web.ts new file mode 100644 index 0000000000..25bebfccbd --- /dev/null +++ b/src/components/images/Gallery/usePointerHandlers.web.ts @@ -0,0 +1,270 @@ +import {useEffect} from 'react' +import {type FlatList} from 'react-native' + +import {ITEM_GAP} from '#/components/images/Gallery/const' +import {tween} from '#/components/images/Gallery/tween' +import {getOffsetForIndex} from '#/components/images/Gallery/utils' + +const DRAG_THRESHOLD = 3 +const FLICK_DECAY = 0.85 +const FLICK_MIN_VELOCITY = 0.1 +const ADVANCE_THRESHOLD = 0.15 +const FRAME_MS = 1000 / 60 +const SETTLE_DURATION = 700 +const OVERSCROLL_RESISTANCE = 0.4 +const BOUNCE_DURATION = 700 + +function whichByDistance( + itemWidths: Map, + currentIndex: number, + distance: number, + direction: -1 | 1, + imageCount: number, +): number { + let remaining = distance + let i = currentIndex + + while (remaining > 0 && i >= 0 && i < imageCount) { + const w = (itemWidths.get(i) ?? 0) + ITEM_GAP + if (remaining > w) { + remaining -= w + i -= direction + } else if (remaining > w * ADVANCE_THRESHOLD) { + i -= direction + break + } else { + break + } + } + + return Math.max(0, Math.min(i, imageCount - 1)) +} + +export function usePointerHandlers({ + flatListRef, + itemWidthsRef, + currentIndexRef, + scrollTo, + onSettle, + imageCount, +}: { + flatListRef: React.RefObject + itemWidthsRef: React.RefObject> + currentIndexRef: React.RefObject + scrollTo: (offset: number) => void + onSettle: (index: number) => void + imageCount: number +}) { + useEffect(() => { + if (imageCount <= 1) return + + const el = + flatListRef.current?.getScrollableNode() as unknown as HTMLElement | null + if (!el) return + + let isDragging = false + let isMouseDown = false + let startX = 0 + let dragScrollLeft = 0 + let delta = 0 + let prevDelta = 0 + let velo = 0 + let t = 0 + let stopTween: (() => void) | null = null + let localIndex = currentIndexRef.current + let overscrollX = 0 + + el.style.cursor = 'grab' + + const clearOverscroll = () => { + overscrollX = 0 + el.style.transform = '' + } + + const onMouseDown = (e: MouseEvent) => { + e.preventDefault() // prevent native image drag + + // Cancel any in-progress tween + if (stopTween) { + stopTween() + stopTween = null + } + clearOverscroll() + + isMouseDown = true + isDragging = false + localIndex = currentIndexRef.current + startX = e.pageX + dragScrollLeft = el.scrollLeft + delta = 0 + prevDelta = 0 + velo = 0 + t = e.timeStamp + } + + const onMouseMove = (e: MouseEvent) => { + if (!isMouseDown) return + + const x = e.pageX - startX + + // Require minimum movement before starting drag + if (!isDragging && Math.abs(x) < DRAG_THRESHOLD) return + + if (!isDragging) { + isDragging = true + el.style.cursor = 'grabbing' + el.style.userSelect = 'none' + + // Blur focused element within the gallery + if (el.contains(document.activeElement)) { + ;(document.activeElement as HTMLElement)?.blur?.() + } + } + + e.preventDefault() + + // Track velocity + const elapsed = e.timeStamp - t || 1 + prevDelta = delta + delta = x + velo = (delta - prevDelta) / (elapsed * FRAME_MS) + t = e.timeStamp + + const desiredScroll = dragScrollLeft - delta + const maxScroll = el.scrollWidth - el.clientWidth + + if (desiredScroll < 0) { + // Overscroll at start — rubber band + scrollTo(0) + overscrollX = desiredScroll * OVERSCROLL_RESISTANCE + el.style.transform = `translateX(${-overscrollX}px)` + } else if (desiredScroll > maxScroll) { + // Overscroll at end — rubber band + scrollTo(maxScroll) + overscrollX = (desiredScroll - maxScroll) * OVERSCROLL_RESISTANCE + el.style.transform = `translateX(${-overscrollX}px)` + } else { + // Normal scroll range + scrollTo(desiredScroll) + if (overscrollX !== 0) clearOverscroll() + } + + // Update local index from scroll position (only in normal range) + if (overscrollX === 0) { + const offsetX = desiredScroll + let accumulated = 0 + for (let i = 0; i < imageCount; i++) { + const w = (itemWidthsRef.current.get(i) ?? 0) + ITEM_GAP + if (offsetX < accumulated + w / 2) { + localIndex = i + break + } + accumulated += w + if (i === imageCount - 1) localIndex = i + } + } + } + + const onMouseUp = () => { + if (!isMouseDown) return + + const wasDragging = isDragging + isMouseDown = false + isDragging = false + + el.style.cursor = 'grab' + el.style.userSelect = '' + + if (wasDragging) { + // Suppress the click that follows mouseup after a drag + el.addEventListener('click', e => e.stopPropagation(), { + once: true, + capture: true, + }) + + if (overscrollX !== 0) { + // Bounce back from overscroll + const targetIndex = overscrollX > 0 ? imageCount - 1 : 0 + const fromOverscroll = overscrollX + + stopTween = tween( + fromOverscroll, + 0, + BOUNCE_DURATION, + )( + v => { + el.style.transform = `translateX(${-v}px)` + }, + () => { + stopTween = null + clearOverscroll() + onSettle(targetIndex) + }, + ) + } else { + // Normal flick settle + let v = Math.abs(velo) + let restingDistance = 0 + while (v > FLICK_MIN_VELOCITY) { + v *= FLICK_DECAY + restingDistance += v + } + + const direction: -1 | 1 = delta < 0 ? -1 : 1 + const totalDistance = Math.abs(delta) + restingDistance + + const targetIndex = whichByDistance( + itemWidthsRef.current, + localIndex, + totalDistance, + direction, + imageCount, + ) + + const from = el.scrollLeft + const to = getOffsetForIndex(itemWidthsRef.current, targetIndex) + + if (from === to) { + onSettle(targetIndex) + return + } + + stopTween = tween( + from, + to, + SETTLE_DURATION, + )( + v => { + scrollTo(v) + }, + () => { + stopTween = null + onSettle(targetIndex) + }, + ) + } + } + } + + el.addEventListener('mousedown', onMouseDown) + window.addEventListener('mousemove', onMouseMove) + window.addEventListener('mouseup', onMouseUp) + + return () => { + el.removeEventListener('mousedown', onMouseDown) + window.removeEventListener('mousemove', onMouseMove) + window.removeEventListener('mouseup', onMouseUp) + if (stopTween) stopTween() + clearOverscroll() + el.style.cursor = '' + el.style.userSelect = '' + } + }, [ + flatListRef, + itemWidthsRef, + currentIndexRef, + scrollTo, + onSettle, + imageCount, + ]) +} diff --git a/src/components/images/Gallery/utils.ts b/src/components/images/Gallery/utils.ts new file mode 100644 index 0000000000..8f5fe481aa --- /dev/null +++ b/src/components/images/Gallery/utils.ts @@ -0,0 +1,22 @@ +import {ITEM_GAP} from '#/components/images/Gallery/const' + +export function getOffsetForIndex( + itemWidths: Map, + index: number, +): number { + let offset = 0 + for (let i = 0; i < index; i++) { + offset += (itemWidths.get(i) ?? 0) + ITEM_GAP + } + return offset +} + +export function getAspectRatio({ + width, + height, +}: {width?: number; height?: number} = {}) { + if (width && width > 0 && height && height > 0) { + return width / height + } + return undefined +} diff --git a/src/components/images/ImageLayoutGrid.tsx b/src/components/images/ImageLayoutGrid.tsx index 54ee1e0121..320dba70a5 100644 --- a/src/components/images/ImageLayoutGrid.tsx +++ b/src/components/images/ImageLayoutGrid.tsx @@ -6,7 +6,7 @@ import {type AppBskyEmbedImages} from '@atproto/api' import {type Dimensions} from '#/view/com/lightbox/ImageViewing/@types' import {atoms as a, useBreakpoints} from '#/alf' import {PostEmbedViewContext} from '#/components/Post/Embed/types' -import {GalleryItem} from './Gallery' +import {GalleryItem} from './ImageLayoutGridItem' interface ImageLayoutGridProps { images: AppBskyEmbedImages.ViewImage[] diff --git a/src/components/images/Gallery.tsx b/src/components/images/ImageLayoutGridItem.tsx similarity index 100% rename from src/components/images/Gallery.tsx rename to src/components/images/ImageLayoutGridItem.tsx diff --git a/src/components/verification/VerificationCreatePrompt.tsx b/src/components/verification/VerificationCreatePrompt.tsx index b0a87f476e..ea374128ce 100644 --- a/src/components/verification/VerificationCreatePrompt.tsx +++ b/src/components/verification/VerificationCreatePrompt.tsx @@ -10,8 +10,8 @@ import {useVerificationCreateMutation} from '#/state/queries/verification/useVer import {atoms as a, useBreakpoints} from '#/alf' import {Admonition} from '#/components/Admonition' import {Button, ButtonIcon, ButtonText} from '#/components/Button' -import {type DialogControlProps} from '#/components/Dialog' import * as Dialog from '#/components/Dialog' +import {type DialogControlProps} from '#/components/Dialog' import {VerifiedCheck} from '#/components/icons/VerifiedCheck' import {Loader} from '#/components/Loader' import * as ProfileCard from '#/components/ProfileCard' diff --git a/src/features/liveEvents/preferences.ts b/src/features/liveEvents/preferences.ts index f3a05bbb5e..6fb2ac6d11 100644 --- a/src/features/liveEvents/preferences.ts +++ b/src/features/liveEvents/preferences.ts @@ -8,8 +8,8 @@ import { } from '#/state/queries/preferences' import {useAgent} from '#/state/session' import {useAnalytics} from '#/analytics' -import {IS_WEB} from '#/env' import * as env from '#/env' +import {IS_WEB} from '#/env' import { type LiveEventFeed, type LiveEventFeedMetricContext, diff --git a/src/lib/api/resolve.ts b/src/lib/api/resolve.ts index 5ce0d70971..49b00fdfc0 100644 --- a/src/lib/api/resolve.ts +++ b/src/lib/api/resolve.ts @@ -1,10 +1,10 @@ import { type AppBskyFeedDefs, type AppBskyGraphDefs, + type BskyAgent, type ComAtprotoRepoStrongRef, } from '@atproto/api' import {AtUri} from '@atproto/api' -import {type BskyAgent} from '@atproto/api' import {POST_IMG_MAX} from '#/lib/constants' import {getLinkMeta} from '#/lib/link-meta/link-meta' @@ -15,18 +15,19 @@ import { parseStarterPackUri, } from '#/lib/strings/starter-pack' import { + convertBskyAppUrlIfNeeded, isBskyCustomFeedUrl, isBskyListUrl, isBskyPostUrl, isBskyStarterPackUrl, isBskyStartUrl, isShortLink, + makeRecordUri, } from '#/lib/strings/url-helpers' import {type ComposerImage} from '#/state/gallery' import {createComposerImage} from '#/state/gallery' import {type Gif} from '#/state/queries/tenor' import {createGIFDescription} from '../gif-alt-text' -import {convertBskyAppUrlIfNeeded, makeRecordUri} from '../strings/url-helpers' type ResolvedExternalLink = { type: 'external' @@ -190,7 +191,26 @@ export async function resolveGif( agent: BskyAgent, gif: Gif, ): Promise { - const uri = `${gif.media_formats.gif.url}?hh=${gif.media_formats.gif.dims[1]}&ww=${gif.media_formats.gif.dims[0]}` + const gifUrl = gif.media_formats.gif.url + const params = new URLSearchParams() + params.set('hh', String(gif.media_formats.gif.dims[1])) + params.set('ww', String(gif.media_formats.gif.dims[0])) + + // For Klipy GIFs, embed video format slugs so parseKlipyGif can + // swap to the right format per platform at render time. Klipy uses + // different filename slugs per format (unlike Tenor where format is + // encoded in the URL ID), so this info must travel with the URL. + try { + const url = new URL(gifUrl) + if (url.hostname === 'static.klipy.com') { + const mp4Slug = getFileSlug(gif.media_formats.mp4?.url) + const webmSlug = getFileSlug(gif.media_formats.webm?.url) + if (mp4Slug) params.set('mp4', mp4Slug) + if (webmSlug) params.set('webm', webmSlug) + } + } catch {} + + const uri = `${gifUrl}?${params.toString()}` const altText = gif.content_description || gif.title return { type: 'external', @@ -201,6 +221,14 @@ export async function resolveGif( } } +function getFileSlug(url: string | undefined): string | undefined { + if (!url) return undefined + const filename = url.split('/').pop() + if (!filename) return undefined + const dotIndex = filename.lastIndexOf('.') + return dotIndex > 0 ? filename.slice(0, dotIndex) : undefined +} + async function resolveExternal( agent: BskyAgent, uri: string, diff --git a/src/lib/hooks/useAnimatedValue.ts b/src/lib/hooks/useAnimatedValue.ts index 9ae14dab62..eb567c02b1 100644 --- a/src/lib/hooks/useAnimatedValue.ts +++ b/src/lib/hooks/useAnimatedValue.ts @@ -1,12 +1,12 @@ -import * as React from 'react' +import {useRef} from 'react' import {Animated} from 'react-native' export function useAnimatedValue(initialValue: number) { - const lazyRef = React.useRef(undefined) + const lazyRef = useRef(undefined) if (lazyRef.current === undefined) { lazyRef.current = new Animated.Value(initialValue) } - return lazyRef.current as Animated.Value + return lazyRef.current } diff --git a/src/lib/hooks/useTimer.ts b/src/lib/hooks/useTimer.ts index b14a9f24fd..8793ac5475 100644 --- a/src/lib/hooks/useTimer.ts +++ b/src/lib/hooks/useTimer.ts @@ -1,13 +1,13 @@ -import * as React from 'react' +import {useCallback, useEffect, useRef} from 'react' /** * Helper hook to run persistent timers on views */ export function useTimer(time: number, handler: () => void) { - const timer = React.useRef(undefined) + const timer = useRef(undefined) // function to restart the timer - const reset = React.useCallback(() => { + const reset = useCallback(() => { if (timer.current) { clearTimeout(timer.current) } @@ -15,7 +15,7 @@ export function useTimer(time: number, handler: () => void) { }, [time, timer, handler]) // function to cancel the timer - const cancel = React.useCallback(() => { + const cancel = useCallback(() => { if (timer.current) { clearTimeout(timer.current) timer.current = undefined @@ -23,7 +23,7 @@ export function useTimer(time: number, handler: () => void) { }, [timer]) // start the timer immediately - React.useEffect(() => { + useEffect(() => { reset() // eslint-disable-next-line react-hooks/exhaustive-deps }, []) diff --git a/src/lib/media/video/upload.shared.ts b/src/lib/media/video/upload.shared.ts index 1ab8439e6f..fd46e27868 100644 --- a/src/lib/media/video/upload.shared.ts +++ b/src/lib/media/video/upload.shared.ts @@ -30,7 +30,7 @@ export async function getServiceAuthToken({ return serviceAuth.token } -export async function getVideoUploadLimits(agent: BskyAgent, _: I18n['_']) { +export async function getVideoUploadLimits(agent: BskyAgent, i18n: I18n) { const token = await getServiceAuthToken({ agent, lxm: 'app.bsky.video.getUploadLimits', @@ -52,7 +52,7 @@ export async function getVideoUploadLimits(agent: BskyAgent, _: I18n['_']) { throw new UploadLimitError(limits.message) } else { throw new UploadLimitError( - _( + i18n._( msg`You have temporarily reached the limit for video uploads. Please try again later.`, ), ) diff --git a/src/lib/media/video/upload.ts b/src/lib/media/video/upload.ts index b7df3be52f..503577a76a 100644 --- a/src/lib/media/video/upload.ts +++ b/src/lib/media/video/upload.ts @@ -16,19 +16,19 @@ export async function uploadVideo({ did, setProgress, signal, - _, + i18n, }: { video: CompressedVideo agent: BskyAgent did: string setProgress: (progress: number) => void signal: AbortSignal - _: I18n['_'] + i18n: I18n }) { if (signal.aborted) { throw new AbortError() } - await getVideoUploadLimits(agent, _) + await getVideoUploadLimits(agent, i18n) const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', { did, @@ -69,7 +69,9 @@ export async function uploadVideo({ const responseBody = JSON.parse(res.body) as AppBskyVideoDefs.JobStatus if (!responseBody.jobId) { - throw new ServerError(responseBody.error || _(msg`Failed to upload video`)) + throw new ServerError( + responseBody.error || i18n._(msg`Failed to upload video`), + ) } if (signal.aborted) { diff --git a/src/lib/media/video/upload.web.ts b/src/lib/media/video/upload.web.ts index 2e78ec6d38..98d329a709 100644 --- a/src/lib/media/video/upload.web.ts +++ b/src/lib/media/video/upload.web.ts @@ -15,19 +15,19 @@ export async function uploadVideo({ did, setProgress, signal, - _, + i18n, }: { video: CompressedVideo agent: BskyAgent did: string setProgress: (progress: number) => void signal: AbortSignal - _: I18n['_'] + i18n: I18n }) { if (signal.aborted) { throw new AbortError() } - await getVideoUploadLimits(agent, _) + await getVideoUploadLimits(agent, i18n) const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', { did, @@ -70,11 +70,11 @@ export async function uploadVideo({ ) as AppBskyVideoDefs.JobStatus resolve(uploadRes) } else { - reject(new ServerError(_(msg`Failed to upload video`))) + reject(new ServerError(i18n._(msg`Failed to upload video`))) } } xhr.onerror = () => { - reject(new ServerError(_(msg`Failed to upload video`))) + reject(new ServerError(i18n._(msg`Failed to upload video`))) } xhr.open('POST', uri) xhr.setRequestHeader('Content-Type', video.mimeType) @@ -84,7 +84,7 @@ export async function uploadVideo({ ) if (!res.jobId) { - throw new ServerError(res.error || _(msg`Failed to upload video`)) + throw new ServerError(res.error || i18n._(msg`Failed to upload video`)) } if (signal.aborted) { diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts index 5bb7265709..e87aad55c3 100644 --- a/src/lib/routes/types.ts +++ b/src/lib/routes/types.ts @@ -73,6 +73,7 @@ export type CommonNavigatorParams = { Hashtag: {tag: string; author?: string} Topic: {topic: string} MessagesConversation: {conversation: string; embed?: string; accept?: true} + MessagesConversationSettings: {conversation: string} MessagesSettings: undefined MessagesInbox: undefined NotificationsActivityList: {posts: string} diff --git a/src/lib/strings/embed-player.ts b/src/lib/strings/embed-player.ts index d8a5999797..446affd87e 100644 --- a/src/lib/strings/embed-player.ts +++ b/src/lib/strings/embed-player.ts @@ -683,14 +683,35 @@ export function parseKlipyGif(urlp: URL): return {success: false} } - // Use the base URL without dimension params as the player URI, - // routed through the bsky KLIPY proxy (k.gifs.bsky.app). Mirrors - // Tenor's t.gifs.bsky.app rewrite, but on a separate hostname so - // the two upstreams can be routed independently. const playerUrl = new URL(urlp.href) playerUrl.hostname = 'k.gifs.bsky.app' + + // On web, swap the gif filename for a video format so the @@ -509,7 +615,7 @@ function ChatListItemReady({ {showMenu && ( 0} @@ -529,6 +635,7 @@ function ChatListItemReady({ latestReportableMessage={latestReportableMessage} /> )} + ({ - isOpen: false, - pos: {top: 0, left: 0, right: 0, bottom: 0, nextFocusRef: null}, - }) const composerInternalApiRef = useComposerInternalApiRef() const [text, setText] = useState(getDraft) @@ -85,10 +76,6 @@ export function MessageComposer({ const submitDisabled = !editable || (!hasEmbed && text.trim().length === 0) - const openEmojiPicker = (pos: any) => { - setEmojiPickerState({isOpen: true, pos}) - } - const onSubmit = () => { if (!editable) return if (!hasEmbed && text.trim() === '') return @@ -112,16 +99,6 @@ export function MessageComposer({ } } - useEffect(() => { - function onEmojiInserted(emoji: Emoji) { - composerInternalApiRef.current?.insert(emoji.native) - } - textInputWebEmitter.addListener('emoji-inserted', onEmojiInserted) - return () => { - textInputWebEmitter.removeListener('emoji-inserted', onEmojiInserted) - } - }, [composerInternalApiRef]) - return ( {children} @@ -142,54 +119,47 @@ export function MessageComposer({ tintColor={t.palette.contrast_50} fallbackStyle={[t.atoms.bg_contrast_50]}> {IS_WEB && ( - { - e.currentTarget.measure( - (_fx, _fy, _width, _height, px, py) => { - // TODO: rip this horrible system out - openEmojiPicker?.({ - top: py, - left: px - 400, - right: px - 400, - bottom: py, - nextFocusRef: { - current: - composerInternalApiRef.current?.input?.element, + + composerInternalApiRef.current?.insert(emoji.native) + } + nextFocusRef={() => + composerInternalApiRef.current?.input?.element + }> + + {({props, state, control}) => ( + - {state => ( - - )} - + ]}> + + + )} + + + )} - - {IS_WEB && ( - setEmojiPickerState(prev => ({...prev, isOpen: false}))} - /> - )} ) } diff --git a/src/screens/Messages/components/MessageInput.tsx b/src/screens/Messages/components/MessageInput.tsx index 643c0c86a6..d545e6d7b5 100644 --- a/src/screens/Messages/components/MessageInput.tsx +++ b/src/screens/Messages/components/MessageInput.tsx @@ -15,8 +15,7 @@ import Animated, { } from 'react-native-reanimated' import {useSafeAreaInsets} from 'react-native-safe-area-context' import {GlassContainer} from 'expo-glass-effect' -import {msg} from '@lingui/core/macro' -import {useLingui} from '@lingui/react' +import {useLingui} from '@lingui/react/macro' import {countGraphemes} from 'unicode-segmenter/grapheme' import {HITSLOP_10, MAX_DM_GRAPHEME_LENGTH} from '#/lib/constants' @@ -26,7 +25,6 @@ import { useMessageDraft, useSaveMessageDraft, } from '#/state/messages/message-drafts' -import {type EmojiPickerPosition} from '#/view/com/composer/text-input/web/EmojiPicker' import {atoms as a, platform, tokens, useTheme} from '#/alf' import {GlassView} from '#/components/GlassView' import {PaperPlaneVertical_Filled_Stroke2_Corner1_Rounded as PaperPlaneIcon} from '#/components/icons/PaperPlane' @@ -47,13 +45,12 @@ export function MessageInput({ children, }: { textInputId?: string - onSendMessage: (message: string) => void + onSendMessage: (message: string) => Promise | void hasEmbed: boolean setEmbed: (embedUrl: string | undefined) => void children?: React.ReactNode - openEmojiPicker?: (pos: EmojiPickerPosition) => void }) { - const {_} = useLingui() + const {t: l} = useLingui() const t = useTheme() const playHaptic = useHaptics() const {getDraft, clearDraft} = useMessageDraft() @@ -82,13 +79,13 @@ export function MessageInput({ return } if (countGraphemes(message) > MAX_DM_GRAPHEME_LENGTH) { - Toast.show(_(msg`Message is too long`), { + Toast.show(l`Message is too long`, { type: 'error', }) return } clearDraft() - onSendMessage(message) + void onSendMessage(message) playHaptic() setEmbed(undefined) setMessage('') @@ -111,7 +108,7 @@ export function MessageInput({ playHaptic, setEmbed, inputRef, - _, + l, ]) useFocusedInputHandler( @@ -169,9 +166,9 @@ export function MessageInput({ fallbackStyle={[t.atoms.bg_contrast_50]}> { @@ -225,7 +222,7 @@ export function MessageInput({ }}> void hasEmbed: boolean setEmbed: (embedUrl: string | undefined) => void children?: React.ReactNode - openEmojiPicker?: (pos: EmojiPickerPosition) => void }) { const {isMobile} = useWebMediaQueries() - const {_} = useLingui() + const {t: l} = useLingui() const t = useTheme() const {getDraft, clearDraft} = useMessageDraft() const [message, setMessage] = useState(getDraft) @@ -57,7 +50,7 @@ export function MessageInput({ return } if (countGraphemes(message) > MAX_DM_GRAPHEME_LENGTH) { - Toast.show(_(msg`Message is too long`), { + Toast.show(l`Message is too long`, { type: 'error', }) return @@ -66,7 +59,7 @@ export function MessageInput({ onSendMessage(message) setMessage('') setEmbed(undefined) - }, [message, onSendMessage, _, clearDraft, hasEmbed, setEmbed]) + }, [message, onSendMessage, l, clearDraft, hasEmbed, setEmbed]) const onKeyDown = useCallback( (e: React.KeyboardEvent) => { @@ -105,12 +98,11 @@ export function MessageInput({ }, []) const onEmojiInserted = useCallback( - (emoji: Emoji) => { + (emoji: EmojiPicker.Emoji) => { if (!textAreaRef.current) { return } const position = textAreaRef.current.selectionStart ?? 0 - textAreaRef.current.focus() flushSync(() => { setMessage( message => @@ -122,12 +114,6 @@ export function MessageInput({ }, [setMessage], ) - useEffect(() => { - textInputWebEmitter.addListener('emoji-inserted', onEmojiInserted) - return () => { - textInputWebEmitter.removeListener('emoji-inserted', onEmojiInserted) - } - }, [onEmojiInserted]) useSaveMessageDraft(message) useExtractEmbedFromFacets(message, setEmbed) @@ -153,49 +139,45 @@ export function MessageInput({ // @ts-expect-error web only onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)}> - + + + {({props, state}) => ( + + )} + + + { return { [ConvoItemError.FirehoseFailed]: { - description: _(msg`This chat was disconnected`), - help: _(msg`Press to attempt reconnection`), - cta: _(msg`Reconnect`), + description: l`This chat was disconnected`, + help: l`Press to attempt reconnection`, + cta: l`Reconnect`, }, [ConvoItemError.HistoryFailed]: { - description: _(msg`Failed to load past messages`), - help: _(msg`Press to retry`), - cta: _(msg`Retry`), + description: l`Failed to load past messages`, + help: l`Press to retry`, + cta: l`Retry`, }, }[item.code] - }, [_, item.code]) + }, [l, item.code]) return ( - + - {description} ·{' '} + {description} {item.retry && ( - { - e.preventDefault() - item.retry?.() - return false - }}> - {cta} - + <> + ·{' '} + { + item.retry?.() + })}> + {cta} + + )} diff --git a/src/screens/Messages/components/MessagesList.tsx b/src/screens/Messages/components/MessagesList.tsx index eda3c593d9..bdff2412c1 100644 --- a/src/screens/Messages/components/MessagesList.tsx +++ b/src/screens/Messages/components/MessagesList.tsx @@ -1,11 +1,18 @@ -import {useCallback, useEffect, useId, useRef, useState} from 'react' +import { + useCallback, + useEffect, + useId, + useLayoutEffect, + useRef, + useState, +} from 'react' import {type LayoutChangeEvent, type ScrollViewProps, View} from 'react-native' import { KeyboardChatScrollView, type KeyboardChatScrollViewProps, KeyboardGestureArea, } from 'react-native-keyboard-controller' -import Animated, { +import { runOnJS, type ScrollEvent, type SharedValue, @@ -42,10 +49,6 @@ import { } from '#/state/messages/convo/types' import {useGetPost} from '#/state/queries/post' import {useAgent} from '#/state/session' -import { - EmojiPicker, - type EmojiPickerState, -} from '#/view/com/composer/text-input/web/EmojiPicker' import {List, type ListMethods} from '#/view/com/util/List' import {ChatDisabled} from '#/screens/Messages/components/ChatDisabled' import {MessageComposer} from '#/screens/Messages/components/MessageComposer' @@ -53,6 +56,7 @@ import {MessageInput} from '#/screens/Messages/components/MessageInput' import {MessageListError} from '#/screens/Messages/components/MessageListError' import {atoms as a, platform, tokens, useTheme, web} from '#/alf' import {ChatEmptyPill} from '#/components/dms/ChatEmptyPill' +import {DateDividerToggleProvider} from '#/components/dms/DateDividerToggle' import {MessageItem} from '#/components/dms/MessageItem' import {NewMessagesPill} from '#/components/dms/NewMessagesPill' import {Loader} from '#/components/Loader' @@ -61,6 +65,7 @@ import {useAnalytics} from '#/analytics' import {IS_ANDROID, IS_NATIVE, IS_WEB} from '#/env' import {ChatStatusInfo} from './ChatStatusInfo' import {MessageInputEmbed, useMessageEmbed} from './MessageInputEmbed' +import {MessagesListInfoPanel} from './MessagesListInfoPanel' import {KeyboardStickyView} from './vendor/KeyboardStickyView' function MaybeLoader({isLoading}: {isLoading: boolean}) { @@ -77,18 +82,6 @@ function MaybeLoader({isLoading}: {isLoading: boolean}) { ) } -function renderItem({item}: {item: ConvoItem}) { - if (item.type === 'message' || item.type === 'pending-message') { - return - } else if (item.type === 'deleted-message') { - return Deleted message - } else if (item.type === 'error') { - return - } - - return null -} - function keyExtractor(item: ConvoItem) { return item.key } @@ -103,12 +96,14 @@ export function MessagesList({ blocked, footer, hasAcceptOverride, + transparentHeaderHeight, }: { hasScrolled: boolean setHasScrolled: React.Dispatch> blocked?: boolean footer?: React.ReactNode hasAcceptOverride?: boolean + transparentHeaderHeight?: number }) { const ax = useAnalytics() const convoState = useConvoActive() @@ -125,11 +120,6 @@ export function MessagesList({ startContentOffset: 0, }) - const [emojiPickerState, setEmojiPickerState] = useState({ - isOpen: false, - pos: {top: 0, left: 0, right: 0, bottom: 0, nextFocusRef: null}, - }) - const inputHeightUI = useSharedValue(0) const [inputHeightJS, setInputHeightJS] = useState(0) @@ -155,6 +145,18 @@ export function MessagesList({ const prevContentHeight = useRef(0) const prevItemCount = useRef(0) + // Tracks whether the initial scroll-to-bottom has been triggered. Separated from isAtBottom so that contentInset + // (which causes an early onScroll with negative offset) can't prevent the first scroll. + // Reset when hasScrolled goes back to false (e.g. convo re-initialization after backgrounding). + const hasInitiallyScrolled = useRef(false) + const prevHasScrolled = useRef(hasScrolled) + useLayoutEffect(() => { + if (prevHasScrolled.current && !hasScrolled) { + hasInitiallyScrolled.current = false + } + prevHasScrolled.current = hasScrolled + }, [hasScrolled]) + // -- Keep track of background state and positioning for new pill const layoutHeight = useSharedValue(0) const didBackground = useRef(false) @@ -187,8 +189,25 @@ export function MessagesList({ }) } - // This number _must_ be the height of the MaybeLoader component - if (height > 50 && isAtBottom.get()) { + // Initial scroll to bottom — unconditional, not gated on isAtBottom. This is separated because contentInset + // can cause an early onScroll with a negative offset that sets isAtBottom to false before we get here. + if (!hasInitiallyScrolled.current && convoState.items.length > 0) { + hasInitiallyScrolled.current = true + flatListRef.current?.scrollToOffset({offset: height, animated: false}) + // If history is already done loading, mark ready after a frame for the scroll to settle. + // Otherwise, the footer sentinel's onLayout will handle it when history finishes. + if (!convoState.isFetchingHistory) { + requestAnimationFrame(() => { + setHasScrolled(true) + }) + } + prevContentHeight.current = height + prevItemCount.current = convoState.items.length + return + } + + // Subsequent: auto-scroll only if user is at the bottom + if (isAtBottom.get()) { // If the size of the content is changing by more than the height of the screen, then we don't // want to scroll further than the start of all the new content. Since we are storing the previous offset, // we can just scroll the user to that offset and add a little bit of padding. We'll also show the pill @@ -212,17 +231,6 @@ export function MessagesList({ offset: height, animated: hasScrolled && height > prevContentHeight.current, }) - - // HACK Unfortunately, we need to call `setHasScrolled` after a brief delay, - // because otherwise there is too much of a delay between the time the content - // scrolls and the time the screen appears, causing a flicker. - // We cannot actually use a synchronous scroll here, because `onContentSizeChange` - // is actually async itself - all the info has to come across the bridge first. - if (!hasScrolled && !convoState.isFetchingHistory) { - setTimeout(() => { - setHasScrolled(true) - }, 100) - } } } @@ -365,9 +373,39 @@ export function MessagesList({ }) }, [flatListRef]) - const onOpenEmojiPicker = useCallback((pos: any) => { - setEmojiPickerState({isOpen: true, pos}) - }, []) + const renderItem = ({item}: {item: ConvoItem}) => { + if (item.type === 'message' || item.type === 'pending-message') { + return ( + member.did === item.message.sender.did, + )} + isGroupChat={convoState.isGroup()} + /> + ) + } else if (item.type === 'deleted-message') { + return Deleted message + } else if (item.type === 'error') { + return + } + + return null + } + + // Footer sentinel: when history is still loading during the initial scroll, the footer's onLayout fires each time + // new items are prepended (shifting its position). Once history finishes, this triggers setHasScrolled. + const onFooterLayout = useCallback(() => { + if ( + hasInitiallyScrolled.current && + !hasScrolled && + !convoState.isFetchingHistory + ) { + requestAnimationFrame(() => { + setHasScrolled(true) + }) + } + }, [hasScrolled, setHasScrolled, convoState.isFetchingHistory]) const renderScrollComponent = useCallback( (props: ScrollViewProps) => ( @@ -377,12 +415,13 @@ export function MessagesList({ ) return ( - <> + {/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */} @@ -407,19 +446,36 @@ export function MessagesList({ showsVerticalScrollIndicator={!IS_ANDROID} scrollEventThrottle={100} ListHeaderComponent={ - + <> + + {convoState.isGroup() && convoState.hasAllHistory ? ( + + ) : null} + } // native only (prop is not supported on web) renderScrollComponent={renderScrollComponent} - // pushes up the content under the input on web (renderScrollComponent handles it on native) - ListFooterComponent={web( - , - )} + contentContainerStyle={{ + paddingBottom: platform({ + // ios is slightly larger as the input has no top padding + ios: tokens.space.lg, + android: tokens.space.md, + web: 0, // web uses ListFooterComponent instead for scroll reasons + }), + }} + ListFooterComponent={ + + } style={web({ scrollbarWidth: 'thin', scrollbarColor: `${t.palette.contrast_100} transparent`, scrollbarGutter: 'stable both-edges', })} + contentInset={{top: transparentHeaderHeight}} + scrollIndicatorInsets={{top: transparentHeaderHeight}} /> + void onSendMessage(message) + } hasEmbed={!!embedUri} setEmbed={setEmbed}> @@ -454,8 +512,7 @@ export function MessagesList({ textInputId={textInputId} onSendMessage={onSendMessage} hasEmbed={!!embedUri} - setEmbed={setEmbed} - openEmojiPicker={onOpenEmojiPicker}> + setEmbed={setEmbed}> )} @@ -464,16 +521,8 @@ export function MessagesList({ - {IS_WEB && ( - setEmojiPickerState(prev => ({...prev, isOpen: false}))} - /> - )} - {newMessagesPill.show && } - + ) } @@ -518,12 +567,6 @@ function ChatScrollComponent({ ) } -function WebInputSpacer({inputHeight}: {inputHeight: number}) { - if (!IS_WEB) return null - - return -} - type FooterState = 'loading' | 'new-chat' | 'request' | 'standard' function getFooterState( diff --git a/src/screens/Messages/components/MessagesListInfoPanel.tsx b/src/screens/Messages/components/MessagesListInfoPanel.tsx new file mode 100644 index 0000000000..9f1a82dbc4 --- /dev/null +++ b/src/screens/Messages/components/MessagesListInfoPanel.tsx @@ -0,0 +1,135 @@ +import {View} from 'react-native' +import {Plural, Trans, useLingui} from '@lingui/react/macro' + +import {type ConvoState} from '#/state/messages/convo/types' +import {useSession} from '#/state/session' +import {atoms as a, useTheme} from '#/alf' +import {AvatarBubbles} from '#/components/AvatarBubbles' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import {AddMembersFlow} from '#/components/dms/AddMembersFlow' +import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink' +import {PersonPlus_Stroke2_Corner0_Rounded as PersonPlusIcon} from '#/components/icons/Person' +import {Text} from '#/components/Typography' + +export function MessagesListInfoPanel({convoState}: {convoState: ConvoState}) { + const t = useTheme() + const {t: l} = useLingui() + + const addMembersControl = Dialog.useDialogControl() + + const {currentAccount} = useSession() + + const isOwner = + currentAccount?.did == null + ? false + : convoState.getPrimaryMember?.()?.did === currentAccount.did + // TODO Get this from @api/atproto - dsb + const isLinkEnabled = false + + const groupName = convoState.getGroupInfo?.()?.name + + const members = (convoState?.convo?.members ?? []).filter( + profile => profile.did !== currentAccount?.did, + ) + + let names: React.ReactNode | null = null + if (members.length === 1) { + names = New chat with {members[0].displayName} + } + if (members.length === 2) { + names = ( + + New chat with {members[0].displayName} and {members[1].displayName} + + ) + } + if (members.length > 2) { + names = ( + + New chat with {members[0].displayName}, {members[1].displayName}, and{' '} + + . + + ) + } + + const showButtons = isOwner || isLinkEnabled + + return ( + <> + + + {groupName ? ( + + {groupName} + + ) : null} + {names ? ( + + {names} + + ) : null} + {showButtons ? ( + + {isOwner ? ( + + ) : null} + {isOwner || isLinkEnabled ? ( + + ) : null} + + ) : null} + + + + { + // TODO Add members here + addMembersControl.close() + }} + /> + + + ) +} diff --git a/src/screens/Messages/components/RequestListItem.tsx b/src/screens/Messages/components/RequestListItem.tsx index fc68686571..a885847d9d 100644 --- a/src/screens/Messages/components/RequestListItem.tsx +++ b/src/screens/Messages/components/RequestListItem.tsx @@ -5,31 +5,34 @@ import {Trans} from '@lingui/react/macro' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useSession} from '#/state/session' import {atoms as a, tokens} from '#/alf' +import {parseConvoView} from '#/components/dms/util' import {KnownFollowers} from '#/components/KnownFollowers' import {Text} from '#/components/Typography' import {ChatListItem, ChatListItemPortal} from './ChatListItem' import {AcceptChatButton, DeleteChatButton, RejectMenu} from './RequestButtons' -export function RequestListItem({convo}: {convo: ChatBskyConvoDefs.ConvoView}) { +export function RequestListItem({ + convo: convoView, +}: { + convo: ChatBskyConvoDefs.ConvoView +}) { const {currentAccount} = useSession() const moderationOpts = useModerationOpts() - const otherUser = convo.members.find( - member => member.did !== currentAccount?.did, - ) + const convo = parseConvoView(convoView, currentAccount?.did) - if (!otherUser || !moderationOpts) { + if (!convo || !moderationOpts) { return null } - const isDeletedAccount = otherUser.handle === 'missing.invalid' + const isDeletedAccount = convo.primaryMember.handle === 'missing.invalid' return ( - + {!isDeletedAccount ? ( <> - + ) : ( <> - + )} diff --git a/src/screens/PostThread/components/ThreadItemAnchor.tsx b/src/screens/PostThread/components/ThreadItemAnchor.tsx index 42574c7408..17c8e54be8 100644 --- a/src/screens/PostThread/components/ThreadItemAnchor.tsx +++ b/src/screens/PostThread/components/ThreadItemAnchor.tsx @@ -39,6 +39,7 @@ import {Button} from '#/components/Button' import {DebugFieldDisplay} from '#/components/DebugFieldDisplay' import {CalendarClock_Stroke2_Corner0_Rounded as CalendarClockIcon} from '#/components/icons/CalendarClock' import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' +import {GalleryBleed} from '#/components/images/Gallery' import {Link} from '#/components/Link' import {ContentHider} from '#/components/moderation/ContentHider' import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe' @@ -308,234 +309,243 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({ return ( <> - - - - - - - - - + + + + + + + + + + + + {sanitizeDisplayName( + post.author.displayName || + sanitizeHandle(post.author.handle), + moderation.ui('displayName'), + )} + + + + + + - {sanitizeDisplayName( - post.author.displayName || - sanitizeHandle(post.author.handle), - moderation.ui('displayName'), - )} + {sanitizeHandle(post.author.handle, '@')} - - - - - - - {sanitizeHandle(post.author.handle, '@')} - - - - - - - - - - - - - {richText?.text ? ( - - ) : undefined} - - {post.embed && ( - - + - )} - - - {post.repostCount !== 0 || - post.likeCount !== 0 || - post.quoteCount !== 0 || - post.bookmarkCount !== 0 ? ( - // Show this section unless we're *sure* it has no engagement. + + + + + + + + + + {richText?.text ? ( + + ) : undefined} + + {post.embed && ( + + + + )} + + + {post.repostCount !== 0 || + post.likeCount !== 0 || + post.quoteCount !== 0 || + post.bookmarkCount !== 0 ? ( + // Show this section unless we're *sure* it has no engagement. + + {post.repostCount != null && post.repostCount !== 0 ? ( + + + + + {formatPostStatCount(post.repostCount)} + {' '} + + + + + ) : null} + {post.quoteCount != null && + post.quoteCount !== 0 && + !post.viewer?.embeddingDisabled ? ( + + + + + {formatPostStatCount(post.quoteCount)} + {' '} + + + + + ) : null} + {post.likeCount != null && post.likeCount !== 0 ? ( + + + + + {formatPostStatCount(post.likeCount)} + {' '} + + + + + ) : null} + {post.bookmarkCount != null && post.bookmarkCount !== 0 ? ( + + + + {formatPostStatCount(post.bookmarkCount)} + {' '} + + + + ) : null} + + ) : null} - {post.repostCount != null && post.repostCount !== 0 ? ( - - - - - {formatPostStatCount(post.repostCount)} - {' '} - - - - - ) : null} - {post.quoteCount != null && - post.quoteCount !== 0 && - !post.viewer?.embeddingDisabled ? ( - - - - - {formatPostStatCount(post.quoteCount)} - {' '} - - - - - ) : null} - {post.likeCount != null && post.likeCount !== 0 ? ( - - - - - {formatPostStatCount(post.likeCount)} - {' '} - - - - - ) : null} - {post.bookmarkCount != null && post.bookmarkCount !== 0 ? ( - - - - {formatPostStatCount(post.bookmarkCount)} - {' '} - - - - ) : null} + + + - ) : null} - - - - + - - + ) }) diff --git a/src/screens/PostThread/components/ThreadItemPost.tsx b/src/screens/PostThread/components/ThreadItemPost.tsx index 87aa551330..841c2af745 100644 --- a/src/screens/PostThread/components/ThreadItemPost.tsx +++ b/src/screens/PostThread/components/ThreadItemPost.tsx @@ -32,6 +32,10 @@ import {atoms as a, useTheme} from '#/alf' import {DebugFieldDisplay} from '#/components/DebugFieldDisplay' import {useInteractionState} from '#/components/hooks/useInteractionState' import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' +import { + GalleryBleed, + maybeApplyGalleryOffsetStyles, +} from '#/components/images/Gallery' import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe' import {PostAlerts} from '#/components/moderation/PostAlerts' import {PostHider} from '#/components/moderation/PostHider' @@ -131,18 +135,20 @@ const ThreadItemPostOuterWrapper = memo(function ThreadItemPostOuterWrapper({ !item.ui.showParentReplyLine && overrides?.topBorder !== true return ( - - {children} - + + + {children} + + ) }) @@ -295,7 +301,14 @@ const ThreadItemPostInner = memo(function ThreadItemPostInner({ moderation={moderation} timestamp={post.indexedAt} postHref={postHref} - style={[a.pb_xs]} + style={[ + a.pb_xs, + maybeApplyGalleryOffsetStyles('meta', { + post, + modui: moderation.ui('contentList'), + additionalCauses: additionalPostAlerts, + }), + ]} /> {post.embed && ( - + - {Array.from(Array(indents)).map((_, n: number) => { - const isSkipped = item.ui.skippedIndentIndices.has(n) - return ( - + - ) - })} - {children} - + ], + ]}> + {Array.from(Array(indents)).map((_, n: number) => { + const isSkipped = item.ui.skippedIndentIndices.has(n) + return ( + + ) + })} + {children} + + ) }, ) diff --git a/src/screens/Profile/Header/Shell.tsx b/src/screens/Profile/Header/Shell.tsx index b050ea9ef8..1d33539e25 100644 --- a/src/screens/Profile/Header/Shell.tsx +++ b/src/screens/Profile/Header/Shell.tsx @@ -1,10 +1,7 @@ import {memo, useCallback, useEffect, useMemo} from 'react' import {Pressable, View} from 'react-native' import Animated, { - measure, - type MeasuredDimensions, - runOnJS, - runOnUI, + type AnimatedRef, useAnimatedRef, } from 'react-native-reanimated' import {useSafeAreaInsets} from 'react-native-safe-area-context' @@ -76,7 +73,7 @@ let ProfileHeaderShell = ({ const _openLightbox = useCallback( ( uri: string, - thumbRect: MeasuredDimensions | null, + thumbRef: AnimatedRef, type: 'circle-avi' | 'rect-avi' | 'image' = 'circle-avi', ) => { openLightbox({ @@ -84,7 +81,8 @@ let ProfileHeaderShell = ({ { uri, thumbUri: uri, - thumbRect, + thumbRect: null, + thumbRef, dimensions: type === 'circle-avi' || type === 'rect-avi' ? { @@ -130,11 +128,7 @@ let ProfileHeaderShell = ({ const avatar = profile.avatar const type = profile.associated?.labeler ? 'rect-avi' : 'circle-avi' if (avatar && !(modui.blur && modui.noOverride)) { - runOnUI(() => { - 'worklet' - const rect = measure(aviRef) - runOnJS(_openLightbox)(avatar, rect, type) - })() + _openLightbox(avatar, aviRef, type) } } }, [ @@ -152,11 +146,7 @@ let ProfileHeaderShell = ({ const modui = moderation.ui('banner') const banner = profile.banner if (banner && !(modui.blur && modui.noOverride)) { - runOnUI(() => { - 'worklet' - const rect = measure(bannerRef) - runOnJS(_openLightbox)(banner, rect, 'image') - })() + _openLightbox(banner, bannerRef, 'image') } }, [profile.banner, moderation, _openLightbox, bannerRef]) diff --git a/src/screens/Search/Shell.tsx b/src/screens/Search/Shell.tsx index ac0ad75483..9f6dc67d27 100644 --- a/src/screens/Search/Shell.tsx +++ b/src/screens/Search/Shell.tsx @@ -96,7 +96,12 @@ export function SearchScreenShell({ const [activeTab, setActiveTab] = useState(() => getTabIndex(tabParam)) // Query terms - const [searchText, setSearchText] = useState(queryParam) + const [searchText, _setSearchText] = useState(queryParam) + const searchTextRef = useRef(searchText) + const setSearchText = (text: string) => { + searchTextRef.current = text + _setSearchText(text) + } const {data: autocompleteData, isFetching: isAutocompleteFetching} = useActorAutocompleteQuery(searchText, true) @@ -227,15 +232,12 @@ export function SearchScreenShell({ } }, [setShowAutocomplete, setSearchText, navigation, route.params, route.name]) - const onSubmit = useCallback( - (source: 'typed' | 'autocomplete') => () => { - ax.metric('search:query', { - source, - }) - navigateToItem(searchText) - }, - [ax, navigateToItem, searchText], - ) + const onSubmit = (source: 'typed' | 'autocomplete') => () => { + ax.metric('search:query', { + source, + }) + navigateToItem(searchTextRef.current) + } const onAutocompleteResultPress = useCallback(() => { if (IS_WEB) { diff --git a/src/screens/Settings/AboutSettings.tsx b/src/screens/Settings/AboutSettings.tsx index 5acb50c4f5..c605a48f0b 100644 --- a/src/screens/Settings/AboutSettings.tsx +++ b/src/screens/Settings/AboutSettings.tsx @@ -21,8 +21,8 @@ import * as Layout from '#/components/Layout' import {Loader} from '#/components/Loader' import * as Toast from '#/components/Toast' import {getDeviceId} from '#/analytics/identifiers' -import {IS_ANDROID, IS_IOS, IS_NATIVE} from '#/env' import * as env from '#/env' +import {IS_ANDROID, IS_IOS, IS_NATIVE} from '#/env' import {useDemoMode} from '#/storage/hooks/demo-mode' import {useDevMode} from '#/storage/hooks/dev-mode' import {OTAInfo} from './components/OTAInfo' diff --git a/src/state/lightbox.tsx b/src/state/lightbox.tsx index 1e22cc98a4..7d688dab20 100644 --- a/src/state/lightbox.tsx +++ b/src/state/lightbox.tsx @@ -1,4 +1,10 @@ import {createContext, useContext, useEffect, useMemo, useState} from 'react' +import { + measure, + type MeasuredDimensions, + runOnJS, + runOnUI, +} from 'react-native-reanimated' import {nanoid} from 'nanoid/non-secure' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' @@ -39,17 +45,42 @@ export function Provider({children}: React.PropsWithChildren<{}>) { } }, [activeLightbox, disableScope, enableScope]) + const doOpen = useNonReactiveCallback((lightbox: Omit) => { + setActiveLightbox(prevLightbox => { + if (prevLightbox) { + // Ignore duplicate open requests. If it's already open, + // the user has to explicitly close the previous one first. + return prevLightbox + } else { + return {...lightbox, id: nanoid()} + } + }) + }) + const openLightbox = useNonReactiveCallback( (lightbox: Omit) => { - setActiveLightbox(prevLightbox => { - if (prevLightbox) { - // Ignore duplicate open requests. If it's already open, - // the user has to explicitly close the previous one first. - return prevLightbox - } else { - return {...lightbox, id: nanoid()} + const thumbRef = lightbox.images[lightbox.index]?.thumbRef + if (thumbRef) { + // Measure the tapped image on the UI thread, then open with + // the rect baked in so it's available from the first render. + // Only the rect (plain data) goes through runOnJS — AnimatedRef + // objects can't survive serialization across threads. + const openWithRect = (rect: MeasuredDimensions | null) => { + doOpen({ + ...lightbox, + images: lightbox.images.map((img, i) => + i === lightbox.index ? {...img, thumbRect: rect} : img, + ), + }) } - }) + runOnUI(() => { + 'worklet' + const rect = measure(thumbRef) + runOnJS(openWithRect)(rect) + })() + } else { + doOpen(lightbox) + } }, ) diff --git a/src/state/messages/convo/agent.ts b/src/state/messages/convo/agent.ts index b6c8ee2f16..9f0693c4a3 100644 --- a/src/state/messages/convo/agent.ts +++ b/src/state/messages/convo/agent.ts @@ -1,6 +1,6 @@ import { type AtpAgent, - type ChatBskyActorDefs, + ChatBskyActorDefs, ChatBskyConvoDefs, type ChatBskyConvoGetLog, type ChatBskyConvoSendMessage, @@ -37,6 +37,7 @@ import { import {type MessagesEventBus} from '#/state/messages/events/agent' import {type MessagesEventBusError} from '#/state/messages/events/types' import {IS_NATIVE} from '#/env' +import * as bsky from '#/types/bsky' const logger = Logger.create(Logger.Context.ConversationAgent) @@ -112,6 +113,9 @@ export class Convo { this.markConvoAccepted = this.markConvoAccepted.bind(this) this.addReaction = this.addReaction.bind(this) this.removeReaction = this.removeReaction.bind(this) + this.isGroup = this.isGroup.bind(this) + this.getGroupInfo = this.getGroupInfo.bind(this) + this.getPrimaryMember = this.getPrimaryMember.bind(this) } private commit() { @@ -149,12 +153,17 @@ export class Convo { sender: this.sender, recipients: this.recipients, isFetchingHistory: this.isFetchingHistory, + // Explicit null check since the value is initially undefined. + hasAllHistory: this.oldestRev === null, deleteMessage: undefined, sendMessage: undefined, fetchMessageHistory: undefined, markConvoAccepted: undefined, addReaction: undefined, removeReaction: undefined, + isGroup: this.isGroup, + getGroupInfo: this.getGroupInfo, + getPrimaryMember: this.getPrimaryMember, } } case ConvoStatus.Disabled: @@ -169,12 +178,17 @@ export class Convo { sender: this.sender!, recipients: this.recipients!, isFetchingHistory: this.isFetchingHistory, + // Explicit null check since the value is initially undefined. + hasAllHistory: this.oldestRev === null, deleteMessage: this.deleteMessage, sendMessage: this.sendMessage, fetchMessageHistory: this.fetchMessageHistory, markConvoAccepted: this.markConvoAccepted, addReaction: this.addReaction, removeReaction: this.removeReaction, + isGroup: this.isGroup, + getGroupInfo: this.getGroupInfo, + getPrimaryMember: this.getPrimaryMember, } } case ConvoStatus.Error: { @@ -186,12 +200,16 @@ export class Convo { sender: undefined, recipients: undefined, isFetchingHistory: false, + hasAllHistory: false, deleteMessage: undefined, sendMessage: undefined, fetchMessageHistory: undefined, markConvoAccepted: undefined, addReaction: undefined, removeReaction: undefined, + isGroup: undefined, + getGroupInfo: undefined, + getPrimaryMember: undefined, } } default: { @@ -203,12 +221,17 @@ export class Convo { sender: this.sender, recipients: this.recipients, isFetchingHistory: false, + // Explicit null check since the value is initially undefined. + hasAllHistory: this.oldestRev === null, deleteMessage: undefined, sendMessage: undefined, fetchMessageHistory: undefined, markConvoAccepted: undefined, addReaction: undefined, removeReaction: undefined, + isGroup: this.isGroup, + getGroupInfo: this.getGroupInfo, + getPrimaryMember: this.getPrimaryMember, } } } @@ -222,7 +245,7 @@ export class Convo { switch (action.event) { case ConvoDispatchEvent.Init: { this.status = ConvoStatus.Initializing - this.setup() + void this.setup() this.setupFirehose() this.requestPollInterval(ACTIVE_POLL_INTERVAL) break @@ -234,12 +257,12 @@ export class Convo { switch (action.event) { case ConvoDispatchEvent.Ready: { this.status = ConvoStatus.Ready - this.fetchMessageHistory() + void this.fetchMessageHistory() break } case ConvoDispatchEvent.Background: { this.status = ConvoStatus.Backgrounded - this.fetchMessageHistory() + void this.fetchMessageHistory() this.requestPollInterval(BACKGROUND_POLL_INTERVAL) break } @@ -258,7 +281,7 @@ export class Convo { } case ConvoDispatchEvent.Disable: { this.status = ConvoStatus.Disabled - this.fetchMessageHistory() // finish init + void this.fetchMessageHistory() // finish init this.cleanupFirehoseConnection?.() this.withdrawRequestedPollInterval() break @@ -269,7 +292,7 @@ export class Convo { case ConvoStatus.Ready: { switch (action.event) { case ConvoDispatchEvent.Resume: { - this.refreshConvo() + void this.refreshConvo() this.requestPollInterval(ACTIVE_POLL_INTERVAL) break } @@ -308,11 +331,11 @@ export class Convo { } else { if (this.convo) { this.status = ConvoStatus.Ready - this.refreshConvo() + void this.refreshConvo() this.maybeRecoverFromNetworkError() } else { this.status = ConvoStatus.Initializing - this.setup() + void this.setup() } this.requestPollInterval(ACTIVE_POLL_INTERVAL) } @@ -435,7 +458,7 @@ export class Convo { this.firehoseError = undefined this.commit() } else { - this.batchRetryPendingMessages() + void this.batchRetryPendingMessages() } if (this.fetchMessageHistoryError) { @@ -487,7 +510,8 @@ export class Convo { } else { this.dispatch({event: ConvoDispatchEvent.Ready}) } - } catch (e: any) { + } catch (err) { + const e = err as Error if (!isNetworkError(e) && !isErrorMaybeAppPasswordPermissions(e)) { logger.error('setup failed', { safeMessage: e.message, @@ -557,11 +581,7 @@ export class Convo { async fetchConvo() { if (this.pendingFetchConvo) return this.pendingFetchConvo - this.pendingFetchConvo = new Promise<{ - convo: ChatBskyConvoDefs.ConvoView - sender: ChatBskyActorDefs.ProfileViewBasic | undefined - recipients: ChatBskyActorDefs.ProfileViewBasic[] - }>(async (resolve, reject) => { + this.pendingFetchConvo = (async () => { try { const response = await networkRetry(2, () => { return this.agent.api.chat.bsky.convo.getConvo( @@ -574,17 +594,15 @@ export class Convo { const convo = response.data.convo - resolve({ + return { convo, sender: convo.members.find(m => m.did === this.senderUserDid), recipients: convo.members.filter(m => m.did !== this.senderUserDid), - }) - } catch (e) { - reject(e) + } } finally { this.pendingFetchConvo = undefined } - }) + })() return this.pendingFetchConvo } @@ -596,7 +614,8 @@ export class Convo { this.convo = convo || this.convo this.sender = sender || this.sender this.recipients = recipients || this.recipients - } catch (e: any) { + } catch (err) { + const e = err as Error if (!isNetworkError(e) && !isErrorMaybeAppPasswordPermissions(e)) { logger.error(`failed to refresh convo`, { safeMessage: e.message, @@ -615,6 +634,7 @@ export class Convo { /* * If oldestRev is null, we've fetched all history. + * Needs to explicitly check for `null` since this is initially `undefined`. */ if (this.oldestRev === null) return @@ -648,6 +668,14 @@ export class Convo { this.oldestRev = cursor ?? null + /* + * If the response contained fewer messages than the limit, we know + * there are no more pages, regardless of whether a cursor was returned. + */ + if (messages.length < (IS_NATIVE ? 30 : 60)) { + this.oldestRev = null + } + for (const message of messages) { if ( ChatBskyConvoDefs.isMessageView(message) || @@ -664,7 +692,8 @@ export class Convo { this.pastMessages.set(message.id, message) } } - } catch (e: any) { + } catch (err) { + const e = err as Error if (!isNetworkError(e) && !isErrorMaybeAppPasswordPermissions(e)) { logger.error('failed to fetch message history', { safeMessage: e.message, @@ -673,7 +702,7 @@ export class Convo { this.fetchMessageHistoryError = { retry: () => { - this.fetchMessageHistory() + void this.fetchMessageHistory() }, } } finally { @@ -716,7 +745,7 @@ export class Convo { onFirehoseConnect() { this.firehoseError = undefined - this.batchRetryPendingMessages() + void this.batchRetryPendingMessages() this.commit() } @@ -761,8 +790,8 @@ export class Convo { /** * If this message is already in new messages, it was added by our * sending logic, and is based on client-ordering. When we receive - * the "commited" event from the log, we should replace this - * reference and re-insert in order to respect the order we receied + * the "committed" event from the log, we should replace this + * reference and re-insert in order to respect the order we received * from the log. */ if (this.newMessages.has(ev.message.id)) { @@ -836,7 +865,7 @@ export class Convo { this.commit() if (!this.isProcessingPendingMessages && !this.pendingMessageFailure) { - this.processPendingMessages() + void this.processPendingMessages() } } @@ -912,7 +941,7 @@ export class Convo { } } - private handleSendMessageFailure(e: any) { + private handleSendMessageFailure(e: Error | XRPCError) { if (e instanceof XRPCError) { if (NETWORK_FAILURE_STATUSES.includes(e.status)) { this.pendingMessageFailure = 'recoverable' @@ -1026,7 +1055,8 @@ export class Convo { {encoding: 'application/json', headers: DM_SERVICE_HEADERS}, ) }) - } catch (e: any) { + } catch (err) { + const e = err as Error if (!isNetworkError(e) && !isErrorMaybeAppPasswordPermissions(e)) { logger.error(`failed to delete message`, { safeMessage: e.message, @@ -1334,4 +1364,46 @@ export class Convo { throw error } } + + // Group utilities + + isGroup(): boolean | undefined { + if (!this.convo) return undefined + const info = this.getGroupInfo() + return !!info + } + + getGroupInfo(): ChatBskyConvoDefs.GroupConvo | undefined { + if ( + this.convo && + bsky.dangerousIsType( + this.convo.kind, + ChatBskyConvoDefs.isGroupConvo, + ) + ) { + return this.convo.kind + } + return undefined + } + + getPrimaryMember(): ChatBskyActorDefs.ProfileViewBasic | undefined { + if (this.isGroup()) { + return this.recipients?.find(r => { + if ( + bsky.dangerousIsType( + r.kind, + ChatBskyActorDefs.isGroupConvoMember, + ) + ) { + return r.kind.role === 'owner' + } else { + throw new Error( + 'Expected a GroupConvoMember, got an unknown kind of member', + ) + } + }) + } else { + return this.recipients?.find(r => r.did !== this.senderUserDid) + } + } } diff --git a/src/state/messages/convo/types.ts b/src/state/messages/convo/types.ts index 7053877935..f27610053a 100644 --- a/src/state/messages/convo/types.ts +++ b/src/state/messages/convo/types.ts @@ -144,6 +144,9 @@ type FetchMessageHistory = () => Promise type MarkConvoAccepted = () => void type AddReaction = (messageId: string, reaction: string) => Promise type RemoveReaction = (messageId: string, reaction: string) => Promise +type IsGroup = () => boolean | undefined +type GetGroupInfo = () => ChatBskyConvoDefs.GroupConvo | undefined +type GetPrimaryMember = () => ChatBskyActorDefs.ProfileViewBasic | undefined export type ConvoStateUninitialized = { status: ConvoStatus.Uninitialized @@ -153,12 +156,16 @@ export type ConvoStateUninitialized = { sender: ChatBskyActorDefs.ProfileViewBasic | undefined recipients: ChatBskyActorDefs.ProfileViewBasic[] | undefined isFetchingHistory: false + hasAllHistory: boolean deleteMessage: undefined sendMessage: undefined fetchMessageHistory: undefined markConvoAccepted: undefined addReaction: undefined removeReaction: undefined + isGroup: IsGroup + getGroupInfo: GetGroupInfo + getPrimaryMember: GetPrimaryMember } export type ConvoStateInitializing = { status: ConvoStatus.Initializing @@ -168,12 +175,16 @@ export type ConvoStateInitializing = { sender: ChatBskyActorDefs.ProfileViewBasic | undefined recipients: ChatBskyActorDefs.ProfileViewBasic[] | undefined isFetchingHistory: boolean + hasAllHistory: boolean deleteMessage: undefined sendMessage: undefined fetchMessageHistory: undefined markConvoAccepted: undefined addReaction: undefined removeReaction: undefined + isGroup: IsGroup + getGroupInfo: GetGroupInfo + getPrimaryMember: GetPrimaryMember } export type ConvoStateReady = { status: ConvoStatus.Ready @@ -183,12 +194,16 @@ export type ConvoStateReady = { sender: ChatBskyActorDefs.ProfileViewBasic recipients: ChatBskyActorDefs.ProfileViewBasic[] isFetchingHistory: boolean + hasAllHistory: boolean deleteMessage: DeleteMessage sendMessage: SendMessage fetchMessageHistory: FetchMessageHistory markConvoAccepted: MarkConvoAccepted addReaction: AddReaction removeReaction: RemoveReaction + isGroup: IsGroup + getGroupInfo: GetGroupInfo + getPrimaryMember: GetPrimaryMember } export type ConvoStateBackgrounded = { status: ConvoStatus.Backgrounded @@ -198,12 +213,16 @@ export type ConvoStateBackgrounded = { sender: ChatBskyActorDefs.ProfileViewBasic recipients: ChatBskyActorDefs.ProfileViewBasic[] isFetchingHistory: boolean + hasAllHistory: boolean deleteMessage: DeleteMessage sendMessage: SendMessage fetchMessageHistory: FetchMessageHistory markConvoAccepted: MarkConvoAccepted addReaction: AddReaction removeReaction: RemoveReaction + isGroup: IsGroup + getGroupInfo: GetGroupInfo + getPrimaryMember: GetPrimaryMember } export type ConvoStateSuspended = { status: ConvoStatus.Suspended @@ -213,12 +232,16 @@ export type ConvoStateSuspended = { sender: ChatBskyActorDefs.ProfileViewBasic recipients: ChatBskyActorDefs.ProfileViewBasic[] isFetchingHistory: boolean + hasAllHistory: boolean deleteMessage: DeleteMessage sendMessage: SendMessage fetchMessageHistory: FetchMessageHistory markConvoAccepted: MarkConvoAccepted addReaction: AddReaction removeReaction: RemoveReaction + isGroup: IsGroup + getGroupInfo: GetGroupInfo + getPrimaryMember: GetPrimaryMember } export type ConvoStateError = { status: ConvoStatus.Error @@ -228,12 +251,16 @@ export type ConvoStateError = { sender: undefined recipients: undefined isFetchingHistory: false + hasAllHistory: false deleteMessage: undefined sendMessage: undefined fetchMessageHistory: undefined markConvoAccepted: undefined addReaction: undefined removeReaction: undefined + isGroup: undefined + getGroupInfo: undefined + getPrimaryMember: undefined } export type ConvoStateDisabled = { status: ConvoStatus.Disabled @@ -243,12 +270,16 @@ export type ConvoStateDisabled = { sender: ChatBskyActorDefs.ProfileViewBasic recipients: ChatBskyActorDefs.ProfileViewBasic[] isFetchingHistory: boolean + hasAllHistory: boolean deleteMessage: DeleteMessage sendMessage: SendMessage fetchMessageHistory: FetchMessageHistory markConvoAccepted: MarkConvoAccepted addReaction: AddReaction removeReaction: RemoveReaction + isGroup: IsGroup + getGroupInfo: GetGroupInfo + getPrimaryMember: GetPrimaryMember } export type ConvoState = | ConvoStateUninitialized diff --git a/src/state/queries/messages/create-group-chat.ts b/src/state/queries/messages/create-group-chat.ts new file mode 100644 index 0000000000..9f8aadc7d0 --- /dev/null +++ b/src/state/queries/messages/create-group-chat.ts @@ -0,0 +1,37 @@ +import {type ChatBskyGroupCreateGroup} from '@atproto/api' +import {useMutation, useQueryClient} from '@tanstack/react-query' + +import {DM_SERVICE_HEADERS} from '#/lib/constants' +import {logger} from '#/logger' +import {useAgent} from '#/state/session' +import {precacheConvoQuery} from './conversation' + +export function useCreateGroupChat({ + onSuccess, + onError, +}: { + onSuccess?: (data: ChatBskyGroupCreateGroup.OutputSchema) => void + onError?: (error: Error) => void +}) { + const queryClient = useQueryClient() + const agent = useAgent() + + return useMutation({ + mutationFn: async ({name, members}: {name: string; members: string[]}) => { + const {data} = await agent.chat.bsky.group.createGroup( + {name, members}, + {headers: DM_SERVICE_HEADERS}, + ) + + return data + }, + onSuccess: data => { + precacheConvoQuery(queryClient, data.convo) + onSuccess?.(data) + }, + onError: error => { + logger.error(error) + onError?.(error) + }, + }) +} diff --git a/src/state/queries/messages/list-conversations.tsx b/src/state/queries/messages/list-conversations.tsx index c5457d1cb8..4c21bbbfeb 100644 --- a/src/state/queries/messages/list-conversations.tsx +++ b/src/state/queries/messages/list-conversations.tsx @@ -24,17 +24,20 @@ export const RQKEY_ROOT = 'convo-list' export const RQKEY = ( status: 'accepted' | 'request' | 'all', readState: 'all' | 'unread' = 'all', -) => [RQKEY_ROOT, status, readState] + kind: 'all' | 'group' | 'direct' = 'all', +) => [RQKEY_ROOT, status, readState, kind] type RQPageParam = string | undefined export function useListConvosQuery({ enabled, status, readState = 'all', + kind = 'all', }: { enabled?: boolean status?: 'request' | 'accepted' readState?: 'all' | 'unread' + kind?: 'all' | 'group' | 'direct' } = {}) { const agent = useAgent() @@ -47,6 +50,7 @@ export function useListConvosQuery({ limit: 20, cursor: pageParam, readState: readState === 'unread' ? 'unread' : undefined, + kind: kind === 'all' ? undefined : kind, status, }, {headers: DM_SERVICE_HEADERS}, diff --git a/src/state/queries/messages/mute-conversation.ts b/src/state/queries/messages/mute-conversation.ts index 08878d7fb5..d90ebb1b55 100644 --- a/src/state/queries/messages/mute-conversation.ts +++ b/src/state/queries/messages/mute-conversation.ts @@ -31,13 +31,13 @@ export function useMuteConvo( mutationFn: async ({mute}: {mute: boolean}) => { if (!convoId) throw new Error('No convoId provided') if (mute) { - const {data} = await agent.api.chat.bsky.convo.muteConvo( + const {data} = await agent.chat.bsky.convo.muteConvo( {convoId}, {headers: DM_SERVICE_HEADERS, encoding: 'application/json'}, ) return data } else { - const {data} = await agent.api.chat.bsky.convo.unmuteConvo( + const {data} = await agent.chat.bsky.convo.unmuteConvo( {convoId}, {headers: DM_SERVICE_HEADERS, encoding: 'application/json'}, ) diff --git a/src/state/queries/tenor.ts b/src/state/queries/tenor.ts index 97ce426e72..835f21f066 100644 --- a/src/state/queries/tenor.ts +++ b/src/state/queries/tenor.ts @@ -143,7 +143,8 @@ export type Gif = { /** * A dictionary with a content format as the key and a Media Object as the value. */ - media_formats: Record + media_formats: Record & + Partial> /** * An array of tags for the post */ @@ -198,16 +199,20 @@ type MediaObject = { size: number } -type ContentFormats = +type BaseContentFormats = | 'preview' | 'gif' // | 'mediumgif' | 'tinygif' // | 'nanogif' -// | 'mp4' -// | 'loopedmp4' -// | 'tinymp4' -// | 'nanomp4' -// | 'webm' + +type VideoContentFormats = + | 'mp4' + // | 'loopedmp4' + // | 'tinymp4' + // | 'nanomp4' + | 'webm' // | 'tinywebm' // | 'nanowebm' + +type ContentFormats = BaseContentFormats | VideoContentFormats diff --git a/src/state/queries/trending/useGetSuggestedUsersForDiscoverQuery.ts b/src/state/queries/trending/useGetSuggestedUsersForDiscoverQuery.ts index efd02a1dce..74c8886693 100644 --- a/src/state/queries/trending/useGetSuggestedUsersForDiscoverQuery.ts +++ b/src/state/queries/trending/useGetSuggestedUsersForDiscoverQuery.ts @@ -19,9 +19,9 @@ export type QueryProps = { export const getSuggestedUsersForDiscoverQueryKeyRoot = 'unspecced-suggested-users-for-explore' -export const createGetSuggestedUsersForDiscoverQueryKey = ( - props: QueryProps, -) => [getSuggestedUsersForDiscoverQueryKeyRoot, props.limit] +export const createGetSuggestedUsersForDiscoverQueryKey = (props: { + limit?: number +}) => [getSuggestedUsersForDiscoverQueryKeyRoot, props.limit] export function useGetSuggestedUsersForDiscoverQuery(props: QueryProps = {}) { const agent = useAgent() @@ -29,7 +29,7 @@ export function useGetSuggestedUsersForDiscoverQuery(props: QueryProps = {}) { return useQuery({ staleTime: STALE.MINUTES.THREE, - queryKey: createGetSuggestedUsersForDiscoverQueryKey(props), + queryKey: createGetSuggestedUsersForDiscoverQueryKey({limit: props.limit}), queryFn: async () => { const contentLangs = getContentLanguages().join(',') const userInterests = aggregateUserInterests(preferences) diff --git a/src/state/queries/trending/useGetSuggestedUsersForSeeMoreQuery.ts b/src/state/queries/trending/useGetSuggestedUsersForSeeMoreQuery.ts index eec392d4c9..e816f0cedb 100644 --- a/src/state/queries/trending/useGetSuggestedUsersForSeeMoreQuery.ts +++ b/src/state/queries/trending/useGetSuggestedUsersForSeeMoreQuery.ts @@ -16,21 +16,27 @@ import {useAgent} from '#/state/session' export type QueryProps = { category?: string | null limit?: number + enabled?: boolean } export const getSuggestedUsersForSeeMoreQueryKeyRoot = 'unspecced-suggested-users-for-explore' -export const createGetSuggestedUsersForSeeMoreQueryKey = ( - props: QueryProps, -) => [getSuggestedUsersForSeeMoreQueryKeyRoot, props.category, props.limit] +export const createGetSuggestedUsersForSeeMoreQueryKey = (props: { + category?: string | null + limit?: number +}) => [getSuggestedUsersForSeeMoreQueryKeyRoot, props.category, props.limit] export function useGetSuggestedUsersForSeeMoreQuery(props: QueryProps = {}) { const agent = useAgent() const {data: preferences} = usePreferencesQuery() return useQuery({ + enabled: props.enabled ?? true, staleTime: STALE.MINUTES.THREE, - queryKey: createGetSuggestedUsersForSeeMoreQueryKey(props), + queryKey: createGetSuggestedUsersForSeeMoreQueryKey({ + category: props.category, + limit: props.limit, + }), queryFn: async () => { const contentLangs = getContentLanguages().join(',') const userInterests = aggregateUserInterests(preferences) diff --git a/src/state/shell/composer/index.tsx b/src/state/shell/composer/index.tsx index eda5235523..c29fb41872 100644 --- a/src/state/shell/composer/index.tsx +++ b/src/state/shell/composer/index.tsx @@ -17,7 +17,6 @@ import { RQKEY_GIF_ROOT, RQKEY_LINK_ROOT, } from '#/state/queries/resolve-link' -import {type EmojiPickerPosition} from '#/view/com/composer/text-input/web/EmojiPicker' import * as Toast from '#/components/Toast' export interface ComposerOptsPostRef { @@ -51,7 +50,6 @@ export interface ComposerOpts { onPostSuccess?: (data: OnPostSuccessData) => void quote?: AppBskyFeedDefs.PostView mention?: string // handle of user to mention - openEmojiPicker?: (pos: EmojiPickerPosition | undefined) => void text?: string imageUris?: {uri: string; width: number; height: number; altText?: string}[] videoUri?: {uri: string; width: number; height: number} diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index ceaa2fc395..d12b3a0bea 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -54,9 +54,8 @@ import { type BskyAgent, type RichText, } from '@atproto/api' -import {msg, plural} from '@lingui/core/macro' -import {useLingui} from '@lingui/react' -import {Trans} from '@lingui/react/macro' +import {plural} from '@lingui/core/macro' +import {Trans, useLingui} from '@lingui/react/macro' import {useNavigation} from '@react-navigation/native' import {useQueryClient} from '@tanstack/react-query' @@ -73,7 +72,6 @@ import { } from '#/lib/constants' import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' -import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {mimeToExt} from '#/lib/media/video/util' import {useCallOnce} from '#/lib/once' import {type NavigationProp} from '#/lib/routes/types' @@ -123,9 +121,10 @@ import {SubtitleDialogBtn} from '#/view/com/composer/videos/SubtitleDialog' import {VideoPreview} from '#/view/com/composer/videos/VideoPreview' import {VideoTranscodeProgress} from '#/view/com/composer/videos/VideoTranscodeProgress' import {UserAvatar} from '#/view/com/util/UserAvatar' -import {atoms as a, native, useTheme, web} from '#/alf' +import {atoms as a, native, useBreakpoints, useTheme, web} from '#/alf' import {Admonition} from '#/components/Admonition' import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import * as EmojiPicker from '#/components/EmojiPicker' import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfoIcon} from '#/components/icons/CircleInfo' import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmileIcon} from '#/components/icons/Emoji' import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus' @@ -186,7 +185,6 @@ export const ComposePost = ({ onPostSuccess, quote: initQuote, mention: initMention, - openEmojiPicker, text: initText, imageUris: initImageUris, videoUri: initVideoUri, @@ -197,16 +195,17 @@ export const ComposePost = ({ cancelRef?: React.RefObject }) => { const {currentAccount} = useSession() + const t = useTheme() const ax = useAnalytics() const agent = useAgent() const queryClient = useQueryClient() const currentDid = currentAccount!.did const {closeComposer} = useComposerControls() - const {_} = useLingui() + const {t: l, i18n} = useLingui() const requireAltTextEnabled = useRequireAltTextEnabled() const langPrefs = useLanguagePrefs() const setLangPrefs = useLanguagePrefsApi() - const textInput = useRef(null) + const textInputRef = useRef(null) const discardPromptControl = Prompt.usePromptControl() const {mutateAsync: saveDraft, isPending: _isSavingDraft} = useSaveDraftMutation() @@ -313,7 +312,7 @@ export const ComposePost = ({ abortController, }, }) - processVideo( + void processVideo( asset, videoAction => { composerDispatch({ @@ -328,10 +327,10 @@ export const ComposePost = ({ agent, currentDid, abortController.signal, - _, + i18n, ) }, - [_, agent, currentDid, composerDispatch], + [i18n, agent, currentDid, composerDispatch], ) const onInitVideo = useNonReactiveCallback(() => { @@ -460,7 +459,7 @@ export const ComposePost = ({ } // Start video compression and upload - processVideo( + void processVideo( asset, videoAction => { composerDispatch({ @@ -475,7 +474,7 @@ export const ComposePost = ({ agent, currentDid, abortController.signal, - _, + i18n, ) } catch (e) { logger.error('Failed to restore video from draft', { @@ -484,7 +483,7 @@ export const ComposePost = ({ }) } }, - [_, agent, currentDid, composerDispatch], + [i18n, agent, currentDid, composerDispatch], ) const handleSelectDraft = useCallback( @@ -558,11 +557,11 @@ export const ComposePost = ({ const getDraftSaveError = useCallback( (e: unknown): string => { if (e instanceof AppBskyDraftCreateDraft.DraftLimitReachedError) { - return _(msg`You've reached the maximum number of drafts`) + return l`You've reached the maximum number of drafts` } - return _(msg`Failed to save draft`) + return l`Failed to save draft` }, - [_], + [l], ) const validateDraftTextOrError = useCallback((): boolean => { @@ -571,14 +570,12 @@ export const ComposePost = ({ ) if (tooLong) { setError( - _( - msg`One or more posts are too long to save as a draft. ${plural(MAX_DRAFT_GRAPHEME_LENGTH, {one: 'The maximum number of characters is # character.', other: 'The maximum number of characters is # characters.'})}`, - ), + l`One or more posts are too long to save as a draft. ${plural(MAX_DRAFT_GRAPHEME_LENGTH, {one: 'The maximum number of characters is # character.', other: 'The maximum number of characters is # characters.'})}`, ) return false } return true - }, [composerState.thread.posts, _]) + }, [composerState.thread.posts, l]) const handleSaveDraft = useCallback(async () => { setError('') @@ -710,7 +707,7 @@ export const ComposePost = ({ ) const onPressCancel = useCallback(() => { - if (textInput.current?.maybeClosePopup()) { + if (textInputRef.current?.maybeClosePopup()) { return } @@ -768,21 +765,21 @@ export const ComposePost = ({ const media = thread.posts[i].embed.media if (media) { if (media.type === 'images' && media.images.some(img => !img.alt)) { - return _(msg`One or more images is missing alt text.`) + return l`One or more images is missing alt text.` } if (media.type === 'gif' && !media.alt) { - return _(msg`One or more GIFs is missing alt text.`) + return l`One or more GIFs is missing alt text.` } if ( media.type === 'video' && media.video.status !== 'error' && !media.video.altText ) { - return _(msg`One or more videos is missing alt text.`) + return l`One or more videos is missing alt text.` } } } - }, [thread, requireAltTextEnabled, _]) + }, [thread, requireAltTextEnabled, l]) const canPost = !missingAltError && @@ -895,11 +892,9 @@ export const ComposePost = ({ let err = cleanError(e.message) if (err.includes('not locate record')) { - err = _( - msg`We're sorry! The post you are replying to has been deleted.`, - ) + err = l`We're sorry! The post you are replying to has been deleted.` } else if (e instanceof EmbeddingDisabledError) { - err = _(msg`This post's author has disabled quote posts.`) + err = l`This post's author has disabled quote posts.` } setError(err) setIsPublishing(false) @@ -979,14 +974,14 @@ export const ComposePost = ({ {thread.posts.length > 1 - ? _(msg`Your posts were sent`) + ? l`Your posts were sent` : replyTo - ? _(msg`Your reply was sent`) - : _(msg`Your post was sent`)} + ? l`Your reply was sent` + : l`Your post was sent`} {postUri && ( { const {host: name, rkey} = new AtUri(postUri) navigation.navigate('PostThread', {name, rkey}) @@ -1001,7 +996,7 @@ export const ComposePost = ({ ) }, 500) }, [ - _, + l, ax, agent, thread, @@ -1026,7 +1021,7 @@ export const ComposePost = ({ // Preserves the referential identity passed to each post item. // Avoids re-rendering all posts on each keystroke. const onComposerPostPublish = useNonReactiveCallback(() => { - onPressPublish() + void onPressPublish() }) useEffect(() => { @@ -1047,7 +1042,7 @@ export const ComposePost = ({ setPublishOnUpload(false) } else if (uploadingVideos === 0) { setPublishOnUpload(false) - onPressPublish() + void onPressPublish() } } }, [thread.posts, onPressPublish, publishOnUpload]) @@ -1068,17 +1063,6 @@ export const ComposePost = ({ } } - const onEmojiButtonPress = useCallback(() => { - const rect = textInput.current?.getCursorPosition() - if (rect) { - openEmojiPicker?.({ - ...rect, - nextFocusRef: - textInput as unknown as React.MutableRefObject, - }) - } - }, [openEmojiPicker]) - const scrollViewRef = useAnimatedRef() useEffect(() => { if (composerState.mutableNeedsFocusActive) { @@ -1086,7 +1070,7 @@ export const ComposePost = ({ // On Android, this risks getting the cursor stuck behind the keyboard. // Not worth it. if (!IS_ANDROID) { - textInput.current?.focus() + textInputRef.current?.focus() } } }, [composerState]) @@ -1127,7 +1111,6 @@ export const ComposePost = ({ !isEmptyPost(activePost) && (!nextPost || !isEmptyPost(nextPost)) } onError={setError} - onEmojiButtonPress={onEmojiButtonPress} onSelectVideo={selectVideo} onAddPost={() => { composerDispatch({ @@ -1137,6 +1120,7 @@ export const ComposePost = ({ currentLanguages={currentLanguages} onSelectLanguage={onSelectLanguage} openGallery={openGallery} + textInputRef={textInputRef} /> ) @@ -1189,7 +1173,13 @@ export const ComposePost = ({ layout={native(LinearTransition)} onScroll={scrollHandler} contentContainerStyle={a.flex_grow} - style={a.flex_1} + style={[ + a.flex_1, + web({ + scrollbarGutter: 'stable', + scrollbarColor: `${t.palette.contrast_200} transparent`, + }), + ]} keyboardShouldPersistTaps="always" onContentSizeChange={onScrollViewContentSizeChange} onLayout={onScrollViewLayout}> @@ -1199,7 +1189,7 @@ export const ComposePost = ({ 1} @@ -1224,9 +1214,9 @@ export const ComposePost = ({ {replyTo ? ( @@ -1264,21 +1254,17 @@ export const ComposePost = ({ {allPostsWithinLimit && ( )} - + )} @@ -1290,7 +1276,7 @@ export const ComposePost = ({ let ComposerPost = memo(function ComposerPost({ post, dispatch, - textInput, + textInputRef, isActive, isReply, isFirstPost, @@ -1305,7 +1291,7 @@ let ComposerPost = memo(function ComposerPost({ }: { post: PostDraft dispatch: (action: ComposerAction) => void - textInput: React.Ref + textInputRef: React.RefObject | null isActive: boolean isReply: boolean isFirstPost: boolean @@ -1320,16 +1306,16 @@ let ComposerPost = memo(function ComposerPost({ }) { const {currentAccount} = useSession() const currentDid = currentAccount!.did - const {_} = useLingui() + const {t: l} = useLingui() const {data: currentProfile} = useProfileQuery({did: currentDid}) const richtext = post.richtext const isTextOnly = !post.embed.link && !post.embed.quote && !post.embed.media const forceMinHeight = IS_WEB && isTextOnly && isActive const selectTextInputPlaceholder = isReply ? isFirstPost - ? _(msg`Write your reply`) - : _(msg`Add another post`) - : _(msg`What's up?`) + ? l`Write your reply` + : l`Add another post` + : l`What's up?` const discardPromptControl = Prompt.usePromptControl() const dispatchPost = useCallback( @@ -1369,7 +1355,7 @@ let ComposerPost = memo(function ComposerPost({ if (IS_NATIVE) return // web only const [mimeType] = uri.slice('data:'.length).split(';') if (!SUPPORTED_MIME_TYPES.includes(mimeType as SupportedMimeTypes)) { - Toast.show(_(msg`Unsupported video type: ${mimeType}`), { + Toast.show(l`Unsupported video type: ${mimeType}`, { type: 'error', }) return @@ -1384,7 +1370,7 @@ let ComposerPost = memo(function ComposerPost({ onImageAdd([res]) } }, - [post.id, onSelectVideo, onImageAdd, _], + [post.id, onSelectVideo, onImageAdd, l], ) useHideKeyboardOnBackground() @@ -1406,7 +1392,7 @@ let ComposerPost = memo(function ComposerPost({ style={[a.mt_xs]} /> {canRemovePost && isActive && ( <> + {IS_WEB && gtPhone ? ( + + + {({props}) => ( + + )} + + + ) : null} )} @@ -1994,7 +1978,7 @@ function ComposerFooter({ {showAddButton && (