Remove own reaction from reactions dialog on tap (#10231)

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
This commit is contained in:
DS Boyce
2026-04-16 11:16:43 -07:00
committed by GitHub
parent 36c95d7dc6
commit ac68cfe98c
8 changed files with 532 additions and 336 deletions
@@ -1,4 +1,4 @@
import * as React from 'react' import {Component, createRef} from 'react'
import { import {
Dimensions, Dimensions,
type LayoutChangeEvent, type LayoutChangeEvent,
@@ -39,14 +39,14 @@ const IS_IOS15 =
const IS_NON_E2E_ANDROID = const IS_NON_E2E_ANDROID =
Platform.OS === 'android' && Number(Platform.Version) < 35 Platform.OS === 'android' && Number(Platform.Version) < 35
export class BottomSheetNativeComponent extends React.Component< export class BottomSheetNativeComponent extends Component<
BottomSheetViewProps, BottomSheetViewProps,
{ {
open: boolean open: boolean
viewHeight?: number viewHeight?: number
} }
> { > {
ref = React.createRef<any>() ref = createRef<any>()
static contextType = PortalContext static contextType = PortalContext
@@ -129,6 +129,7 @@ export class BottomSheetNativeComponent extends React.Component<
function BottomSheetNativeComponentInner({ function BottomSheetNativeComponentInner({
children, children,
backgroundColor, backgroundColor,
maxHeight,
onLayout, onLayout,
onStateChange, onStateChange,
nativeViewRef, nativeViewRef,
@@ -156,6 +157,7 @@ function BottomSheetNativeComponentInner({
return ( return (
<NativeView <NativeView
{...rest} {...rest}
maxHeight={maxHeight}
onStateChange={onStateChange} onStateChange={onStateChange}
ref={nativeViewRef} ref={nativeViewRef}
style={{ style={{
@@ -170,6 +172,7 @@ function BottomSheetNativeComponentInner({
flex: 1, flex: 1,
backgroundColor, backgroundColor,
}, },
maxHeight != null && {maxHeight},
Platform.OS === 'android' && { Platform.OS === 'android' && {
borderTopLeftRadius: cornerRadius, borderTopLeftRadius: cornerRadius,
borderTopRightRadius: cornerRadius, borderTopRightRadius: cornerRadius,
@@ -177,7 +180,9 @@ function BottomSheetNativeComponentInner({
}, },
extraStyles, extraStyles,
]}> ]}>
<View onLayout={onLayout}> <View
onLayout={onLayout}
style={maxHeight == null ? undefined : {flex: 1}}>
<BottomSheetPortalProvider>{children}</BottomSheetPortalProvider> <BottomSheetPortalProvider>{children}</BottomSheetPortalProvider>
</View> </View>
</View> </View>
+1
View File
@@ -23,6 +23,7 @@ export const Context = createContext<DialogContextProps>({
disableDrag: false, disableDrag: false,
setDisableDrag: () => {}, setDisableDrag: () => {},
isWithinDialog: false, isWithinDialog: false,
isHeightConstrained: false,
}) })
Context.displayName = 'DialogContext' Context.displayName = 'DialogContext'
+11 -4
View File
@@ -157,6 +157,8 @@ export function Outer({
[open, close], [open, close],
) )
const isHeightConstrained = nativeOptions?.maxHeight != null
const context = useMemo( const context = useMemo(
() => ({ () => ({
close, close,
@@ -165,8 +167,9 @@ export function Outer({
disableDrag, disableDrag,
setDisableDrag, setDisableDrag,
isWithinDialog: true, isWithinDialog: true,
isHeightConstrained,
}), }),
[close, snapPoint, disableDrag, setDisableDrag], [close, snapPoint, disableDrag, setDisableDrag, isHeightConstrained],
) )
return ( return (
@@ -180,7 +183,9 @@ export function Outer({
onStateChange={onStateChange} onStateChange={onStateChange}
disableDrag={disableDrag}> disableDrag={disableDrag}>
<Context.Provider value={context}> <Context.Provider value={context}>
<View testID={testID} style={[a.relative]}> <View
testID={testID}
style={[a.relative, isHeightConstrained && a.flex_1]}>
{children} {children}
</View> </View>
</Context.Provider> </Context.Provider>
@@ -213,10 +218,11 @@ export function Inner({children, style, header}: DialogInnerProps) {
export const ScrollableInner = forwardRef<ScrollView, DialogInnerProps>( export const ScrollableInner = forwardRef<ScrollView, DialogInnerProps>(
function ScrollableInner( function ScrollableInner(
{children, contentContainerStyle, header, ...props}, {children, contentContainerStyle, header, style, ...props},
ref, ref,
) { ) {
const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext() const {nativeSnapPoint, disableDrag, setDisableDrag, isHeightConstrained} =
useDialogContext()
const isAtMaxSnapPoint = nativeSnapPoint === BottomSheetSnapPoint.Full const isAtMaxSnapPoint = nativeSnapPoint === BottomSheetSnapPoint.Full
const insets = useSafeAreaInsets() const insets = useSafeAreaInsets()
const [keyboardHeight, setKeyboardHeight] = useState(() => const [keyboardHeight, setKeyboardHeight] = useState(() =>
@@ -243,6 +249,7 @@ export const ScrollableInner = forwardRef<ScrollView, DialogInnerProps>(
return ( return (
<ScrollView <ScrollView
style={[isHeightConstrained && a.flex_1, style]}
contentContainerStyle={[ contentContainerStyle={[
a.pt_2xl, a.pt_2xl,
IS_LIQUID_GLASS ? a.px_2xl : a.px_xl, IS_LIQUID_GLASS ? a.px_2xl : a.px_xl,
+2
View File
@@ -111,6 +111,7 @@ export function Outer({
disableDrag: false, disableDrag: false,
setDisableDrag: () => {}, setDisableDrag: () => {},
isWithinDialog: true, isWithinDialog: true,
isHeightConstrained: false,
}), }),
[close], [close],
) )
@@ -196,6 +197,7 @@ export function Inner({
a.border, a.border,
t.atoms.bg, t.atoms.bg,
{ {
cursor: 'default', // The overlay applies `cursor: 'pointer'` to all children.
maxWidth: 600, maxWidth: 600,
borderColor: t.palette.contrast_200, borderColor: t.palette.contrast_200,
shadowColor: t.palette.black, shadowColor: t.palette.black,
+1
View File
@@ -45,6 +45,7 @@ export type DialogContextProps = {
setDisableDrag: React.Dispatch<React.SetStateAction<boolean>> setDisableDrag: React.Dispatch<React.SetStateAction<boolean>>
// in the event that the hook is used outside of a dialog // in the event that the hook is used outside of a dialog
isWithinDialog: boolean isWithinDialog: boolean
isHeightConstrained: boolean
} }
export type DialogControlOpenOptions = { export type DialogControlOpenOptions = {
+2 -4
View File
@@ -60,8 +60,7 @@ export function Error({
color="primary" color="primary"
label={_(msg`Press to retry`)} label={_(msg`Press to retry`)}
onPress={onRetry} onPress={onRetry}
size="large" size="large">
style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]}>
<ButtonText> <ButtonText>
<Trans>Retry</Trans> <Trans>Retry</Trans>
</ButtonText> </ButtonText>
@@ -73,8 +72,7 @@ export function Error({
color={onRetry ? 'secondary' : 'primary'} color={onRetry ? 'secondary' : 'primary'}
label={_(msg`Return to previous page`)} label={_(msg`Return to previous page`)}
onPress={goBack} onPress={goBack}
size="large" size="large">
style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]}>
<ButtonText> <ButtonText>
<Trans>Go Back</Trans> <Trans>Go Back</Trans>
</ButtonText> </ButtonText>
+115 -324
View File
@@ -1,4 +1,4 @@
import {memo, useCallback, useEffect, useMemo, useState} from 'react' import {memo, useCallback, useEffect, useMemo} from 'react'
import { import {
type GestureResponderEvent, type GestureResponderEvent,
LayoutAnimation, LayoutAnimation,
@@ -6,6 +6,7 @@ import {
type StyleProp, type StyleProp,
type TextStyle, type TextStyle,
View, View,
type ViewStyle,
} from 'react-native' } from 'react-native'
import Animated, { import Animated, {
FadeIn, FadeIn,
@@ -25,22 +26,21 @@ import {
} from '@atproto/api' } from '@atproto/api'
import {plural} from '@lingui/core/macro' import {plural} from '@lingui/core/macro'
import {Trans, useLingui} from '@lingui/react/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 {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles' import {sanitizeHandle} from '#/lib/strings/handles'
import {useConvoActive} from '#/state/messages/convo' import {useConvoActive} from '#/state/messages/convo'
import {type ConvoItem} from '#/state/messages/convo/types' import {type ConvoItem} from '#/state/messages/convo/types'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {DraggableScrollView} from '#/view/com/pager/DraggableScrollView' import {atoms as a, native, platform, useTheme} from '#/alf'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, native, useTheme, web} from '#/alf'
import {isOnlyEmoji} from '#/alf/typography' import {isOnlyEmoji} from '#/alf/typography'
import * as Dialog from '#/components/Dialog'
import {useDialogControl} from '#/components/Dialog' import {useDialogControl} from '#/components/Dialog'
import {ActionsWrapper} from '#/components/dms/ActionsWrapper' import {ActionsWrapper} from '#/components/dms/ActionsWrapper'
import {InlineLinkText} from '#/components/Link' import {InlineLinkText, Link} from '#/components/Link'
import * as ProfileCard from '#/components/ProfileCard' import * as ProfileCard from '#/components/ProfileCard'
import {RichText} from '#/components/RichText' import {RichText} from '#/components/RichText'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
@@ -48,6 +48,7 @@ import type * as bsky from '#/types/bsky'
import {DateDivider} from './DateDivider' import {DateDivider} from './DateDivider'
import {useDateDividerToggle} from './DateDividerToggle' import {useDateDividerToggle} from './DateDividerToggle'
import {MessageItemEmbed} from './MessageItemEmbed' import {MessageItemEmbed} from './MessageItemEmbed'
import {ReactionsDialog} from './ReactionsDialog'
const AVATAR_SIZE = 28 const AVATAR_SIZE = 28
const CLUSTERED_MESSAGE_GAP = 2 const CLUSTERED_MESSAGE_GAP = 2
@@ -55,19 +56,9 @@ const BORDER_RADIUS = 18
const SQUARED_BORDER_RADIUS = 4 const SQUARED_BORDER_RADIUS = 4
const DISPLAY_NAME_INSET = 22 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 CLUSTERED_MESSAGE_THRESHOLD_MS = 5 * 60 * 1000
const MESSAGE_GAP_THRESHOLD_MS = 60 * 60 * 1000 const MESSAGE_GAP_THRESHOLD_MS = 60 * 60 * 1000
type Reaction = {
key: string
value: string
senders: ChatBskyConvoDefs.ReactionViewSender[]
count: number
}
function isWithinCluster({ function isWithinCluster({
isPending, isPending,
adjacentMessage, adjacentMessage,
@@ -112,6 +103,7 @@ let MessageItem = ({
const {t: l} = useLingui() const {t: l} = useLingui()
const {convo} = useConvoActive() const {convo} = useConvoActive()
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
const queryClient = useQueryClient()
const reactionsControl = useDialogControl() const reactionsControl = useDialogControl()
@@ -203,11 +195,7 @@ let MessageItem = ({
const topRadiusSV = useSharedValue(targetTopRadius) const topRadiusSV = useSharedValue(targetTopRadius)
const showDisplayName = const showDisplayName =
isGroupChat && isGroupChat && !isFromSelf && isFirstInCluster && !isOnlyEmoji(message.text)
!isFromSelf &&
effectiveFirstInCluster &&
!isDateDividerToggled &&
!isOnlyEmoji(message.text)
const showAvatar = isGroupChat && !isFromSelf && isLastInCluster const showAvatar = isGroupChat && !isFromSelf && isLastInCluster
useEffect(() => { useEffect(() => {
@@ -231,12 +219,23 @@ let MessageItem = ({
) )
const avatar = profile ? ( const avatar = profile ? (
<ProfileCard.Avatar <Link
profile={profile} label={l`${sanitizeDisplayName(
size={AVATAR_SIZE} profile.displayName || sanitizeHandle(profile.handle),
moderationOpts={moderationOpts!} )}s avatar`}
disabledPreview accessibilityHint={l`Opens this profile`}
/> to={makeProfileLink({
did: profile.did,
handle: profile.handle,
})}
onPress={() => unstableCacheProfileView(queryClient, profile)}>
<ProfileCard.Avatar
profile={profile}
size={AVATAR_SIZE}
moderationOpts={moderationOpts!}
disabledPreview
/>
</Link>
) : ( ) : (
<ProfileCard.AvatarPlaceholder size={AVATAR_SIZE} /> <ProfileCard.AvatarPlaceholder size={AVATAR_SIZE} />
) )
@@ -299,78 +298,88 @@ let MessageItem = ({
const appliedReactions = ( const appliedReactions = (
<LayoutAnimationConfig skipEntering skipExiting> <LayoutAnimationConfig skipEntering skipExiting>
{hasReactions ? ( {hasReactions ? (
<> <View
<View style={[
a.relative,
a.bottom_0,
isFromSelf ? [a.align_end] : [a.ml_sm, a.align_start],
a.px_sm,
]}>
<Pressable
accessible={true}
accessibilityLabel={reactionsLabel}
accessibilityHint={
isGroupChat ? l`Tap to view reactions` : undefined
}
style={[ style={[
isFromSelf ? a.align_end : a.align_start, a.flex_row,
a.px_sm, a.gap_2xs,
a.pb_2xs, a.px_xs,
]}> isFromSelf ? a.justify_end : a.justify_start,
<Pressable a.flex_wrap,
accessible={true} a.rounded_lg,
accessibilityLabel={reactionsLabel} a.border,
accessibilityHint={ t.atoms.border_contrast_low,
isGroupChat ? l`Tap to view reactions` : undefined t.atoms.bg_contrast_25,
} t.atoms.shadow_sm,
style={[ {
a.flex_row, paddingTop: platform({android: 2, default: 3}),
a.gap_2xs, paddingBottom: platform({android: 2, default: 3}),
a.py_xs, transform: [{translateY: -8}],
a.px_xs, },
isFromSelf ? a.justify_end : a.justify_start, ]}
a.flex_wrap, onPress={() => (isGroupChat ? reactionsControl.open() : undefined)}>
a.rounded_lg, {groupedReactions.map(group => (
a.border, <Animated.View
t.atoms.border_contrast_low, entering={native(ZoomIn.springify(200).delay(400))}
t.atoms.bg_contrast_25, exiting={
t.atoms.shadow_sm, groupedReactions.length > 1 && native(ZoomOut.delay(200))
{ }
transform: [{translateY: -8}], layout={native(LinearTransition.delay(300))}
}, key={group.value}
]} style={[a.py_2xs]}>
onPress={() => <Text
isGroupChat ? reactionsControl.open() : undefined emoji
}> style={[
{groupedReactions.map(group => ( a.text_xs,
<Animated.View {textAlignVertical: 'center', includeFontPadding: false},
entering={native(ZoomIn.springify(200).delay(400))} ]}>
exiting={ {group.value}
groupedReactions.length > 1 && native(ZoomOut.delay(200)) </Text>
} </Animated.View>
layout={native(LinearTransition.delay(300))} ))}
key={group.value} {groupedReactions.length !== reactions.length &&
style={[a.p_2xs]}> reactions.length > 1 ? (
<Text emoji style={[a.text_sm]}> <View style={[a.p_2xs, a.pl_0, a.justify_center]}>
{group.value} <Text
</Text> style={[
</Animated.View> a.text_xs,
))} t.atoms.text_contrast_medium,
{groupedReactions.length !== reactions.length && {textAlignVertical: 'center', includeFontPadding: false},
reactions.length > 1 ? ( ]}>
<View style={[a.p_2xs, a.justify_center]}> {reactions.length}
<Text </Text>
style={[ </View>
a.text_xs, ) : null}
t.atoms.text_contrast_medium, </Pressable>
{includeFontPadding: false}, </View>
]}>
{reactions.length}
</Text>
</View>
) : null}
</Pressable>
</View>
<ReactionsDialog
control={reactionsControl}
members={convo.members}
reactions={message.reactions}
groupedReactions={groupedReactions}
/>
</>
) : null} ) : null}
<ReactionsDialog
control={reactionsControl}
members={convo.members}
message={message}
reactions={message.reactions}
groupedReactions={groupedReactions}
/>
</LayoutAnimationConfig> </LayoutAnimationConfig>
) )
const messageInset = platform<ViewStyle | undefined>({
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 ( return (
<> <>
{(showDateDivider || isDateDividerToggled) && ( {(showDateDivider || isDateDividerToggled) && (
@@ -379,25 +388,25 @@ let MessageItem = ({
</Animated.View> </Animated.View>
)} )}
<View <View
style={[ style={[messageInset, isFirstInCluster && !showDateDivider && a.mt_sm]}>
isFromSelf ? a.mr_sm : a.ml_sm,
effectiveFirstInCluster &&
!(showDateDivider || isDateDividerToggled) &&
a.mt_sm,
]}>
<View style={[a.relative]}> <View style={[a.relative]}>
{showAvatar ? ( {showAvatar ? (
<View style={[a.absolute, {bottom: hasReactions ? 10 : 0}]}> <View
style={[
a.absolute,
a.bottom_0,
a.z_50,
{
transform: [{translateY: hasReactions ? -24 : 0}],
},
]}>
{avatar} {avatar}
</View> </View>
) : null} ) : null}
<View <View
style={[ style={[
a.flex_grow, a.flex_grow,
!isFromSelf && !isFromSelf && isGroupChat && {paddingLeft: AVATAR_SIZE},
isGroupChat && {
paddingLeft: AVATAR_SIZE,
},
]}> ]}>
{showDisplayName ? ( {showDisplayName ? (
<Text <Text
@@ -537,221 +546,3 @@ let MessageItemMetadata = ({
} }
MessageItemMetadata = memo(MessageItemMetadata) MessageItemMetadata = memo(MessageItemMetadata)
export {MessageItemMetadata} export {MessageItemMetadata}
function ReactionsDialog({
control,
members,
reactions,
groupedReactions,
}: {
control: Dialog.DialogControlProps
members: bsky.profile.AnyProfileView[]
reactions?: ChatBskyConvoDefs.ReactionView[]
groupedReactions?: Reaction[]
}) {
const t = useTheme()
const {t: l} = useLingui()
const [selected, setSelected] = useState('all')
const handleFilter = (value: string) => {
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 (
<Dialog.Outer
control={control}
onClose={() => setSelected('all')}
nativeOptions={{preventExpansion: true, minHeight}}>
<Dialog.Handle />
<View style={[a.px_2xl, a.pt_3xl, t.atoms.bg]}>
<Text style={[a.font_bold, a.text_2xl, a.mb_sm]}>
<Trans>Reactions</Trans>
</Text>
</View>
<ReactionTabs
groupedReactions={groupedReactions}
selected={selected}
totalReactions={reactions?.length ?? 0}
onFilter={handleFilter}
/>
<Dialog.ScrollableInner
label={l`Reactions`}
contentContainerStyle={[a.pt_0]}
style={[web({maxWidth: 400})]}>
{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 ? (
<View
key={profile.did}
style={[
a.flex_row,
a.gap_sm,
a.align_center,
a.justify_between,
a.my_sm,
]}>
<View style={[a.flex_row, a.gap_sm]}>
<UserAvatar
avatar={profile.avatar}
size={42}
type="user"
hideLiveBadge
/>
<View>
<Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
{displayName}
</Text>
<Text style={[a.text_xs, t.atoms.text_contrast_medium]}>
{handle}
</Text>
</View>
</View>
<View>
<RichText
value={rt}
style={[a.text_md]}
interactiveStyle={a.underline}
enableTags
emojiMultiplier={2}
shouldProxyLinks={true}
/>
</View>
</View>
) : null
})}
</Dialog.ScrollableInner>
</Dialog.Outer>
)
}
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 (
<View accessibilityRole="list" style={[t.atoms.bg]}>
<DraggableScrollView
horizontal={true}
showsHorizontalScrollIndicator={false}
onScroll={e => {
scrollX.set(Math.round(e.nativeEvent.contentOffset.x))
}}>
<Animated.View
style={[
a.flex_row,
a.flex_grow,
a.gap_sm,
a.align_center,
a.justify_start,
]}
onLayout={e => {
contentSize.set(e.nativeEvent.layout.width)
}}>
{tabs?.map((reaction, index) => (
<ReactionTab
key={reaction.value}
index={index}
reaction={reaction}
selected={selected}
total={tabs.length}
onPress={handlePress}
/>
))}
</Animated.View>
</DraggableScrollView>
</View>
)
}
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 (
<Pressable
accessibilityRole="button"
accessibilityHint={
reaction.key === 'all'
? l`Tap to show all reactions `
: l`Tap to show ${reaction.value} reactions`
}
hitSlop={HITSLOP_10}
style={[
a.flex_row,
a.align_center,
a.border,
a.justify_center,
a.rounded_lg,
a.px_md,
a.py_sm,
a.mb_sm,
t.atoms.border_contrast_low,
selected === reaction.key ? t.atoms.bg_contrast_50 : t.atoms.bg,
index === 0 ? a.ml_2xl : index === total - 1 ? a.mr_2xl : null,
]}
onPress={() => onPress(reaction.key)}>
<Text emoji style={[a.text_sm]}>
{l`${reaction.value} ${reaction.count}`}
</Text>
</Pressable>
)
}
+391
View File
@@ -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 = (
<>
<View style={[a.px_2xl, IS_WEB ? [a.pt_xl, a.pb_md] : a.pt_3xl]}>
<Text style={[a.font_bold, a.text_2xl, a.mb_sm]}>
<Trans>Reactions</Trans>
</Text>
</View>
<ReactionTabs
groupedReactions={groupedReactions}
selected={selected}
totalReactions={reactions?.length ?? 0}
onFilter={handleFilter}
/>
<Dialog.Close />
</>
)
return (
<Dialog.Outer
control={control}
onClose={() => setSelected('all')}
nativeOptions={{
preventExpansion: true,
minHeight: screenHeight / 2,
maxHeight: screenHeight / 2,
}}>
<Dialog.Handle />
{IS_NATIVE ? header : null}
<Dialog.ScrollableInner
label={l`Reactions`}
contentContainerStyle={[a.pt_0]}
header={IS_WEB ? header : null}
style={[web({maxWidth: 400})]}>
{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 (
<ReactionRow
key={reaction.sender.did + '-' + reaction.value}
control={control}
convo={convo}
currentAccount={currentAccount}
message={message}
profile={sender}
reaction={reaction}
allReactions={reactions ?? []}
selected={selected}
setSelected={setSelected}
/>
)
})}
</Dialog.ScrollableInner>
</Dialog.Outer>
)
}
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<React.SetStateAction<string>>
}) {
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 = (
<>
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
<UserAvatar
avatar={profile.avatar}
size={42}
type="user"
hideLiveBadge
/>
<View>
<Text
numberOfLines={1}
style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
{displayName}
</Text>
<Text
numberOfLines={1}
style={[a.text_xs, t.atoms.text_contrast_medium, web([a.mt_xs])]}>
{isFromSelf ? l`Tap to remove` : handle}
</Text>
</View>
</View>
<View>
<Text style={[a.text_5xl, {includeFontPadding: false}]} emoji>
{reaction.value}
</Text>
</View>
</>
)
if (isFromSelf) {
return (
<Pressable
accessibilityRole="button"
accessibilityHint={l`Tap to remove your ${reaction.value} reaction`}
style={[
a.flex_row,
a.align_center,
a.gap_sm,
a.justify_between,
a.my_sm,
]}
onPress={handleOnPress}>
{inner}
</Pressable>
)
}
return (
<View
style={[
a.flex_row,
a.align_center,
a.gap_sm,
a.justify_between,
a.my_sm,
]}>
{inner}
</View>
)
}
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<ScrollView>(null)
const scrollState = useRef({x: 0, width: 0})
const tabLayouts = useRef<Map<string, {x: number; width: number}>>(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 (
<View accessibilityRole="list" style={[t.atoms.bg]}>
<DraggableScrollView
ref={scrollViewRef}
horizontal={true}
scrollEventThrottle={16}
showsHorizontalScrollIndicator={false}
onScroll={e => {
scrollState.current = {
x: e.nativeEvent.contentOffset.x,
width: e.nativeEvent.layoutMeasurement.width,
}
}}
onLayout={e => {
scrollState.current.width = e.nativeEvent.layout.width
}}>
<Animated.View
style={[
a.flex_row,
a.flex_grow,
a.gap_sm,
a.align_center,
a.justify_start,
]}>
{tabs?.map((reaction, index) => (
<ReactionTab
key={reaction.value}
index={index}
reaction={reaction}
selected={selected}
total={tabs.length}
onPress={handlePress}
onTabLayout={handleTabLayout}
/>
))}
</Animated.View>
</DraggableScrollView>
</View>
)
}
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 (
<Pressable
accessibilityRole="button"
accessibilityHint={
reaction.key === 'all'
? l`Tap to show all reactions `
: l`Tap to show ${reaction.value} reactions`
}
hitSlop={HITSLOP_10}
style={[
a.flex_row,
a.align_center,
a.border,
a.justify_center,
a.rounded_lg,
a.px_md,
a.py_sm,
a.mb_sm,
selected === reaction.key
? t.atoms.border_contrast_low
: {borderColor: t.palette.contrast_50},
selected === reaction.key ? t.atoms.bg_contrast_50 : t.atoms.bg,
index === 0 ? a.ml_2xl : index === total - 1 ? a.mr_2xl : null,
]}
onLayout={e => {
onTabLayout(reaction.key, {
x: e.nativeEvent.layout.x,
width: e.nativeEvent.layout.width,
})
}}
onPress={() => onPress(reaction.key)}>
<Text emoji style={[a.text_sm]}>
{l`${reaction.value} ${reaction.count}`}
</Text>
</Pressable>
)
}