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/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/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/dms/MessageItem.tsx b/src/components/dms/MessageItem.tsx index 7a000c8dc5..d8ffa2debf 100644 --- a/src/components/dms/MessageItem.tsx +++ b/src/components/dms/MessageItem.tsx @@ -1,4 +1,4 @@ -import {memo, useCallback, useEffect, useMemo, useState} from 'react' +import {memo, useCallback, useEffect, useMemo} from 'react' import { type GestureResponderEvent, LayoutAnimation, @@ -6,6 +6,7 @@ import { type StyleProp, type TextStyle, View, + type ViewStyle, } from 'react-native' import Animated, { FadeIn, @@ -25,22 +26,21 @@ import { } from '@atproto/api' import {plural} from '@lingui/core/macro' import {Trans, useLingui} from '@lingui/react/macro' +import {useQueryClient} from '@tanstack/react-query' -import {HITSLOP_10} from '#/lib/constants' +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 {DraggableScrollView} from '#/view/com/pager/DraggableScrollView' -import {UserAvatar} from '#/view/com/util/UserAvatar' -import {atoms as a, native, useTheme, web} from '#/alf' +import {atoms as a, native, platform, useTheme} from '#/alf' import {isOnlyEmoji} from '#/alf/typography' -import * as Dialog from '#/components/Dialog' 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' @@ -48,6 +48,7 @@ import type * as bsky from '#/types/bsky' import {DateDivider} from './DateDivider' import {useDateDividerToggle} from './DateDividerToggle' import {MessageItemEmbed} from './MessageItemEmbed' +import {ReactionsDialog} from './ReactionsDialog' const AVATAR_SIZE = 28 const CLUSTERED_MESSAGE_GAP = 2 @@ -55,19 +56,9 @@ const BORDER_RADIUS = 18 const SQUARED_BORDER_RADIUS = 4 const DISPLAY_NAME_INSET = 22 -// 42px avatar + 2 * 8px my_sm margins -const ROW_HEIGHT = 58 - const CLUSTERED_MESSAGE_THRESHOLD_MS = 5 * 60 * 1000 const MESSAGE_GAP_THRESHOLD_MS = 60 * 60 * 1000 -type Reaction = { - key: string - value: string - senders: ChatBskyConvoDefs.ReactionViewSender[] - count: number -} - function isWithinCluster({ isPending, adjacentMessage, @@ -112,6 +103,7 @@ let MessageItem = ({ const {t: l} = useLingui() const {convo} = useConvoActive() const moderationOpts = useModerationOpts() + const queryClient = useQueryClient() const reactionsControl = useDialogControl() @@ -203,11 +195,7 @@ let MessageItem = ({ const topRadiusSV = useSharedValue(targetTopRadius) const showDisplayName = - isGroupChat && - !isFromSelf && - effectiveFirstInCluster && - !isDateDividerToggled && - !isOnlyEmoji(message.text) + isGroupChat && !isFromSelf && isFirstInCluster && !isOnlyEmoji(message.text) const showAvatar = isGroupChat && !isFromSelf && isLastInCluster useEffect(() => { @@ -231,12 +219,23 @@ let MessageItem = ({ ) const avatar = profile ? ( - + unstableCacheProfileView(queryClient, profile)}> + + ) : ( ) @@ -299,78 +298,88 @@ let MessageItem = ({ const appliedReactions = ( {hasReactions ? ( - <> - + - - isGroupChat ? reactionsControl.open() : undefined - }> - {groupedReactions.map(group => ( - 1 && native(ZoomOut.delay(200)) - } - layout={native(LinearTransition.delay(300))} - key={group.value} - style={[a.p_2xs]}> - - {group.value} - - - ))} - {groupedReactions.length !== reactions.length && - reactions.length > 1 ? ( - - - {reactions.length} - - - ) : null} - - - - + a.flex_row, + a.gap_2xs, + a.px_xs, + isFromSelf ? a.justify_end : a.justify_start, + a.flex_wrap, + a.rounded_lg, + a.border, + t.atoms.border_contrast_low, + t.atoms.bg_contrast_25, + t.atoms.shadow_sm, + { + paddingTop: platform({android: 2, default: 3}), + paddingBottom: platform({android: 2, default: 3}), + transform: [{translateY: -8}], + }, + ]} + onPress={() => (isGroupChat ? reactionsControl.open() : undefined)}> + {groupedReactions.map(group => ( + 1 && native(ZoomOut.delay(200)) + } + 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 ( <> {(showDateDivider || isDateDividerToggled) && ( @@ -379,25 +388,25 @@ let MessageItem = ({ )} + style={[messageInset, isFirstInCluster && !showDateDivider && a.mt_sm]}> {showAvatar ? ( - + {avatar} ) : null} {showDisplayName ? ( { - setSelected(value) - } - - const filteredMembers = - selected === 'all' - ? members - : members.filter(m => - reactions?.some(r => r.sender.did === m.did && r.value === selected), - ) - - const minHeight = members.length * ROW_HEIGHT - - return ( - setSelected('all')} - nativeOptions={{preventExpansion: true, minHeight}}> - - - - Reactions - - - - - {filteredMembers.map(profile => { - const displayName = sanitizeDisplayName( - profile?.displayName || sanitizeHandle(profile?.handle ?? ''), - ) - const handle = sanitizeHandle(profile?.handle ?? '', '@') - const reaction = reactions?.find( - ({sender}) => sender.did === profile.did, - ) - const rt = reaction - ? new RichTextAPI({text: reaction.value}) - : undefined - - return rt ? ( - - - - - - {displayName} - - - {handle} - - - - - - - - ) : null - })} - - - ) -} - -function ReactionTabs({ - groupedReactions, - selected, - totalReactions, - onFilter, -}: { - groupedReactions?: Reaction[] - selected: string - totalReactions: number - onFilter: (value: string) => void -}) { - const t = useTheme() - const {t: l} = useLingui() - - const contentSize = useSharedValue(0) - const scrollX = useSharedValue(0) - - const handlePress = (value: string) => { - onFilter(value) - } - - const tabs = [ - { - key: 'all', - value: l`All`, - senders: [], - count: totalReactions, - } as Reaction, - ...(groupedReactions ?? []), - ] - - return ( - - { - scrollX.set(Math.round(e.nativeEvent.contentOffset.x)) - }}> - { - contentSize.set(e.nativeEvent.layout.width) - }}> - {tabs?.map((reaction, index) => ( - - ))} - - - - ) -} - -function ReactionTab({ - index, - reaction, - selected, - total, - onPress, -}: { - index: number - reaction: Reaction - selected: string - total: number - onPress: (value: string) => void -}) { - const t = useTheme() - const {t: l} = useLingui() - - return ( - onPress(reaction.key)}> - - {l`${reaction.value} ${reaction.count}`} - - - ) -} 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}`} + + + ) +}