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 {
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<any>()
ref = createRef<any>()
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 (
<NativeView
{...rest}
maxHeight={maxHeight}
onStateChange={onStateChange}
ref={nativeViewRef}
style={{
@@ -170,6 +172,7 @@ function BottomSheetNativeComponentInner({
flex: 1,
backgroundColor,
},
maxHeight != null && {maxHeight},
Platform.OS === 'android' && {
borderTopLeftRadius: cornerRadius,
borderTopRightRadius: cornerRadius,
@@ -177,7 +180,9 @@ function BottomSheetNativeComponentInner({
},
extraStyles,
]}>
<View onLayout={onLayout}>
<View
onLayout={onLayout}
style={maxHeight == null ? undefined : {flex: 1}}>
<BottomSheetPortalProvider>{children}</BottomSheetPortalProvider>
</View>
</View>
+1
View File
@@ -23,6 +23,7 @@ export const Context = createContext<DialogContextProps>({
disableDrag: false,
setDisableDrag: () => {},
isWithinDialog: false,
isHeightConstrained: false,
})
Context.displayName = 'DialogContext'
+11 -4
View File
@@ -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}>
<Context.Provider value={context}>
<View testID={testID} style={[a.relative]}>
<View
testID={testID}
style={[a.relative, isHeightConstrained && a.flex_1]}>
{children}
</View>
</Context.Provider>
@@ -213,10 +218,11 @@ export function Inner({children, style, header}: DialogInnerProps) {
export const ScrollableInner = forwardRef<ScrollView, DialogInnerProps>(
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<ScrollView, DialogInnerProps>(
return (
<ScrollView
style={[isHeightConstrained && a.flex_1, style]}
contentContainerStyle={[
a.pt_2xl,
IS_LIQUID_GLASS ? a.px_2xl : a.px_xl,
+2
View File
@@ -111,6 +111,7 @@ export function Outer({
disableDrag: false,
setDisableDrag: () => {},
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,
+1
View File
@@ -45,6 +45,7 @@ export type DialogContextProps = {
setDisableDrag: React.Dispatch<React.SetStateAction<boolean>>
// in the event that the hook is used outside of a dialog
isWithinDialog: boolean
isHeightConstrained: boolean
}
export type DialogControlOpenOptions = {
+2 -4
View File
@@ -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">
<ButtonText>
<Trans>Retry</Trans>
</ButtonText>
@@ -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">
<ButtonText>
<Trans>Go Back</Trans>
</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 {
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 ? (
<ProfileCard.Avatar
profile={profile}
size={AVATAR_SIZE}
moderationOpts={moderationOpts!}
disabledPreview
/>
<Link
label={l`${sanitizeDisplayName(
profile.displayName || sanitizeHandle(profile.handle),
)}s avatar`}
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} />
)
@@ -299,78 +298,88 @@ let MessageItem = ({
const appliedReactions = (
<LayoutAnimationConfig skipEntering skipExiting>
{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={[
isFromSelf ? a.align_end : a.align_start,
a.px_sm,
a.pb_2xs,
]}>
<Pressable
accessible={true}
accessibilityLabel={reactionsLabel}
accessibilityHint={
isGroupChat ? l`Tap to view reactions` : undefined
}
style={[
a.flex_row,
a.gap_2xs,
a.py_xs,
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,
{
transform: [{translateY: -8}],
},
]}
onPress={() =>
isGroupChat ? reactionsControl.open() : undefined
}>
{groupedReactions.map(group => (
<Animated.View
entering={native(ZoomIn.springify(200).delay(400))}
exiting={
groupedReactions.length > 1 && native(ZoomOut.delay(200))
}
layout={native(LinearTransition.delay(300))}
key={group.value}
style={[a.p_2xs]}>
<Text emoji style={[a.text_sm]}>
{group.value}
</Text>
</Animated.View>
))}
{groupedReactions.length !== reactions.length &&
reactions.length > 1 ? (
<View style={[a.p_2xs, a.justify_center]}>
<Text
style={[
a.text_xs,
t.atoms.text_contrast_medium,
{includeFontPadding: false},
]}>
{reactions.length}
</Text>
</View>
) : null}
</Pressable>
</View>
<ReactionsDialog
control={reactionsControl}
members={convo.members}
reactions={message.reactions}
groupedReactions={groupedReactions}
/>
</>
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 => (
<Animated.View
entering={native(ZoomIn.springify(200).delay(400))}
exiting={
groupedReactions.length > 1 && native(ZoomOut.delay(200))
}
layout={native(LinearTransition.delay(300))}
key={group.value}
style={[a.py_2xs]}>
<Text
emoji
style={[
a.text_xs,
{textAlignVertical: 'center', includeFontPadding: false},
]}>
{group.value}
</Text>
</Animated.View>
))}
{groupedReactions.length !== reactions.length &&
reactions.length > 1 ? (
<View style={[a.p_2xs, a.pl_0, a.justify_center]}>
<Text
style={[
a.text_xs,
t.atoms.text_contrast_medium,
{textAlignVertical: 'center', includeFontPadding: false},
]}>
{reactions.length}
</Text>
</View>
) : null}
</Pressable>
</View>
) : null}
<ReactionsDialog
control={reactionsControl}
members={convo.members}
message={message}
reactions={message.reactions}
groupedReactions={groupedReactions}
/>
</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 (
<>
{(showDateDivider || isDateDividerToggled) && (
@@ -379,25 +388,25 @@ let MessageItem = ({
</Animated.View>
)}
<View
style={[
isFromSelf ? a.mr_sm : a.ml_sm,
effectiveFirstInCluster &&
!(showDateDivider || isDateDividerToggled) &&
a.mt_sm,
]}>
style={[messageInset, isFirstInCluster && !showDateDivider && a.mt_sm]}>
<View style={[a.relative]}>
{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}
</View>
) : null}
<View
style={[
a.flex_grow,
!isFromSelf &&
isGroupChat && {
paddingLeft: AVATAR_SIZE,
},
!isFromSelf && isGroupChat && {paddingLeft: AVATAR_SIZE},
]}>
{showDisplayName ? (
<Text
@@ -537,221 +546,3 @@ let MessageItemMetadata = ({
}
MessageItemMetadata = memo(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>
)
}