Add dialog for viewing clip clop reactions (#10228)

This commit is contained in:
DS Boyce
2026-04-10 12:50:20 -07:00
committed by Samuel Newman
parent 921ecf927b
commit c05a12cf89
5 changed files with 336 additions and 68 deletions
+6 -1
View File
@@ -482,7 +482,11 @@ function TriggerClone({
) )
} }
export function AuxiliaryView({children, align = 'left'}: AuxiliaryViewProps) { export function AuxiliaryView({
children,
align = 'left',
style,
}: AuxiliaryViewProps) {
const context = useContextMenuContext() const context = useContextMenuContext()
const {width: screenWidth} = useWindowDimensions() const {width: screenWidth} = useWindowDimensions()
const {top: topInset} = useSafeAreaInsets() const {top: topInset} = useSafeAreaInsets()
@@ -556,6 +560,7 @@ export function AuxiliaryView({children, align = 'left'}: AuxiliaryViewProps) {
: {right: screenWidth - measurement.x - measurement.width}, : {right: screenWidth - measurement.x - measurement.width},
animatedStyle, animatedStyle,
a.z_20, a.z_20,
style,
]}> ]}>
{children} {children}
</Animated.View> </Animated.View>
+1
View File
@@ -21,6 +21,7 @@ export type {
export type AuxiliaryViewProps = { export type AuxiliaryViewProps = {
children?: React.ReactNode children?: React.ReactNode
align?: 'left' | 'right' align?: 'left' | 'right'
style?: StyleProp<ViewStyle>
} }
export type ItemProps = Omit<MenuItemProps, 'onPress' | 'children'> & { export type ItemProps = Omit<MenuItemProps, 'onPress' | 'children'> & {
+8 -2
View File
@@ -11,6 +11,7 @@ import {useConvoActive} from '#/state/messages/convo'
import {useLanguagePrefs} from '#/state/preferences' import {useLanguagePrefs} from '#/state/preferences'
import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache' import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {atoms as a} from '#/alf'
import * as ContextMenu from '#/components/ContextMenu' import * as ContextMenu from '#/components/ContextMenu'
import {type TriggerProps} from '#/components/ContextMenu/types' import {type TriggerProps} from '#/components/ContextMenu/types'
import {AfterReportDialog} from '#/components/dms/AfterReportDialog' import {AfterReportDialog} from '#/components/dms/AfterReportDialog'
@@ -46,6 +47,7 @@ export let MessageContextMenu = ({
const translate = useGoogleTranslate() const translate = useGoogleTranslate()
const isFromSelf = message.sender?.did === currentAccount?.did const isFromSelf = message.sender?.did === currentAccount?.did
const isGroupChatEnabled = ax.features.enabled(ax.features.GroupChatsEnable)
const onCopyMessage = useCallback(() => { const onCopyMessage = useCallback(() => {
const str = richTextToString( const str = richTextToString(
@@ -114,7 +116,9 @@ export let MessageContextMenu = ({
<> <>
<ContextMenu.Root> <ContextMenu.Root>
{IS_NATIVE && ( {IS_NATIVE && (
<ContextMenu.AuxiliaryView align={isFromSelf ? 'right' : 'left'}> <ContextMenu.AuxiliaryView
align={isFromSelf ? 'right' : 'left'}
style={[isFromSelf && isGroupChatEnabled ? null : a.ml_sm]}>
<EmojiReactionPicker <EmojiReactionPicker
message={message} message={message}
onEmojiSelect={onEmojiSelect} onEmojiSelect={onEmojiSelect}
@@ -130,7 +134,9 @@ export let MessageContextMenu = ({
{children} {children}
</ContextMenu.Trigger> </ContextMenu.Trigger>
<ContextMenu.Outer align={isFromSelf ? 'right' : 'left'}> <ContextMenu.Outer
align={isFromSelf ? 'right' : 'left'}
style={[isFromSelf && isGroupChatEnabled ? null : a.ml_sm]}>
{message.text.length > 0 && ( {message.text.length > 0 && (
<> <>
<ContextMenu.Item <ContextMenu.Item
+318 -64
View File
@@ -1,6 +1,7 @@
import {memo, useCallback, useMemo} from 'react' import {memo, useCallback, useMemo, useState} from 'react'
import { import {
type GestureResponderEvent, type GestureResponderEvent,
Pressable,
type StyleProp, type StyleProp,
type TextStyle, type TextStyle,
View, View,
@@ -10,6 +11,7 @@ import Animated, {
FadeOut, FadeOut,
LayoutAnimationConfig, LayoutAnimationConfig,
LinearTransition, LinearTransition,
useSharedValue,
ZoomIn, ZoomIn,
ZoomOut, ZoomOut,
} from 'react-native-reanimated' } from 'react-native-reanimated'
@@ -19,16 +21,21 @@ import {
RichText as RichTextAPI, RichText as RichTextAPI,
} from '@atproto/api' } from '@atproto/api'
import {plural} from '@lingui/core/macro' import {plural} from '@lingui/core/macro'
import {useLingui} from '@lingui/react/macro' import {Trans, useLingui} from '@lingui/react/macro'
import {HITSLOP_10} from '#/lib/constants'
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 {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {atoms as a, native, useTheme} from '#/alf' import {DraggableScrollView} from '#/view/com/pager/DraggableScrollView'
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 {ActionsWrapper} from '#/components/dms/ActionsWrapper' import {ActionsWrapper} from '#/components/dms/ActionsWrapper'
import {InlineLinkText} from '#/components/Link' import {InlineLinkText} from '#/components/Link'
import * as ProfileCard from '#/components/ProfileCard' import * as ProfileCard from '#/components/ProfileCard'
@@ -44,9 +51,19 @@ 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,
@@ -92,6 +109,8 @@ let MessageItem = ({
const {convo} = useConvoActive() const {convo} = useConvoActive()
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
const reactionsControl = useDialogControl()
const {message, nextMessage, prevMessage} = item const {message, nextMessage, prevMessage} = item
const isPending = item.type === 'pending-message' const isPending = item.type === 'pending-message'
@@ -176,21 +195,23 @@ let MessageItem = ({
const grouped = new Map< const grouped = new Map<
string, string,
{ {
key: string
value: string value: string
senders: ChatBskyConvoDefs.ReactionViewSender[] senders: ChatBskyConvoDefs.ReactionViewSender[]
count: number count: number
} }
>() >()
for (const react of reactions) { for (const reaction of reactions) {
if (!react) continue if (!reaction) continue
const existing = grouped.get(react.value) const existing = grouped.get(reaction.value)
if (existing) { if (existing) {
existing.senders.push(react.sender) existing.senders.push(reaction.sender)
existing.count++ existing.count++
} else { } else {
grouped.set(react.value, { grouped.set(reaction.value, {
value: react.value, key: reaction.value,
senders: [react.sender], value: reaction.value,
senders: [reaction.sender],
count: 1, count: 1,
}) })
} }
@@ -227,62 +248,74 @@ let MessageItem = ({
const appliedReactions = ( const appliedReactions = (
<LayoutAnimationConfig skipEntering skipExiting> <LayoutAnimationConfig skipEntering skipExiting>
{hasReactions ? ( {hasReactions ? (
<View <>
style={[
isFromSelf ? a.align_end : a.align_start,
a.px_sm,
a.pb_2xs,
!isFromSelf && isGroupChat && {paddingLeft: AVATAR_SIZE},
]}>
<View <View
accessible={true}
accessibilityLabel={reactionsLabel}
accessibilityHint={l`Double tap or long press the message to add a reaction`}
style={[ style={[
a.flex_row, isFromSelf ? a.align_end : a.align_start,
a.gap_2xs, a.px_sm,
a.py_xs, a.pb_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,
{
transform: [{translateY: -8}],
},
]}> ]}>
{groupedReactions.map(group => ( <Pressable
<Animated.View accessible={true}
entering={native(ZoomIn.springify(200).delay(400))} accessibilityLabel={reactionsLabel}
exiting={ accessibilityHint={
groupedReactions.length > 1 && native(ZoomOut.delay(200)) isGroupChat ? l`Tap to view reactions` : undefined
} }
layout={native(LinearTransition.delay(300))} style={[
key={group.value} a.flex_row,
style={[a.p_2xs]}> a.gap_2xs,
<Text emoji style={[a.text_sm]}> a.py_xs,
{group.value} a.px_xs,
</Text> isFromSelf ? a.justify_end : a.justify_start,
</Animated.View> a.flex_wrap,
))} a.rounded_lg,
{groupedReactions.length !== reactions.length && a.border,
reactions.length > 1 ? ( t.atoms.border_contrast_low,
<View style={[a.p_2xs, a.justify_center]}> t.atoms.bg_contrast_25,
<Text t.atoms.shadow_sm,
style={[ {
a.text_xs, transform: [{translateY: -8}],
t.atoms.text_contrast_medium, },
{includeFontPadding: false}, ]}
]}> onPress={() =>
{reactions.length} isGroupChat ? reactionsControl.open() : undefined
</Text> }>
</View> {groupedReactions.map(group => (
) : null} <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> </View>
</View> <ReactionsDialog
control={reactionsControl}
members={convo.members}
reactions={message.reactions}
groupedReactions={groupedReactions}
/>
</>
) : null} ) : null}
</LayoutAnimationConfig> </LayoutAnimationConfig>
) )
@@ -301,7 +334,9 @@ let MessageItem = ({
]}> ]}>
<View style={[a.relative]}> <View style={[a.relative]}>
{isGroupChat && !isFromSelf && isLastInCluster ? ( {isGroupChat && !isFromSelf && isLastInCluster ? (
<View style={[a.absolute, a.bottom_0]}>{avatar}</View> <View style={[a.absolute, {bottom: hasReactions ? 10 : 0}]}>
{avatar}
</View>
) : null} ) : null}
<View <View
style={[ style={[
@@ -331,6 +366,7 @@ let MessageItem = ({
<ActionsWrapper isFromSelf={isFromSelf} message={message}> <ActionsWrapper isFromSelf={isFromSelf} message={message}>
{rt.text.length > 0 && ( {rt.text.length > 0 && (
<View <View
accessibilityHint={l`Double tap or long press the message to add a reaction`}
style={[ style={[
!isFromSelf && a.ml_sm, !isFromSelf && a.ml_sm,
...(isOnlyEmoji(message.text) ...(isOnlyEmoji(message.text)
@@ -390,10 +426,10 @@ let MessageItem = ({
squaredTopCorner={squaredTopCorner || hasEmbedAndText} squaredTopCorner={squaredTopCorner || hasEmbedAndText}
/> />
)} )}
{appliedReactions}
</ActionsWrapper> </ActionsWrapper>
</View> </View>
</View> </View>
{appliedReactions}
{isLastInCluster && ( {isLastInCluster && (
<MessageItemMetadata <MessageItemMetadata
item={item} item={item}
@@ -485,3 +521,221 @@ 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>
)
}
@@ -471,7 +471,9 @@ export function MessagesList({
{ax.features.enabled(ax.features.DmsNewMessageComposerEnable) ? ( {ax.features.enabled(ax.features.DmsNewMessageComposerEnable) ? (
<MessageComposer <MessageComposer
textInputId={textInputId} textInputId={textInputId}
onSendMessage={onSendMessage} onSendMessage={(message: string) =>
void onSendMessage(message)
}
hasEmbed={!!embedUri} hasEmbed={!!embedUri}
setEmbed={setEmbed}> setEmbed={setEmbed}>
<MessageInputEmbed embedUri={embedUri} setEmbed={setEmbed} /> <MessageInputEmbed embedUri={embedUri} setEmbed={setEmbed} />