Files
bsky-social-app/src/components/dms/ReactionsDialog.tsx
T
2026-06-03 09:28:11 -07:00

417 lines
11 KiB
TypeScript

import {useMemo, useRef, useState} from 'react'
import {
LayoutAnimation,
Pressable,
type ScrollView,
useWindowDimensions,
View,
} from 'react-native'
import {type ChatBskyActorDefs, 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 {type SessionAccount} from '#/state/session/types'
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
}
type Tab = Omit<Reaction, 'senders'>
export function ReactionsDialog({
control,
relatedProfiles,
message,
onClose,
}: {
control: Dialog.DialogControlProps
relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic>
message: ChatBskyConvoDefs.MessageView
onClose?: () => void
}) {
const {t: l} = useLingui()
const {height: screenHeight} = useWindowDimensions()
const {currentAccount} = useSession()
const convo = useConvoActive()
const [selected, setSelected] = useState('all')
const reactions = message.reactions
const groupedReactions = useMemo(() => groupReactions(reactions), [reactions])
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={setSelected}
/>
<Dialog.Close />
</>
)
return (
<Dialog.Outer
control={control}
onClose={() => {
setSelected('all')
onClose?.()
}}
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 = relatedProfiles.get(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?: SessionAccount
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: Tab[] = [
{key: 'all', value: l`All`, count: totalReactions},
...(groupedReactions ?? []),
]
return (
<View accessibilityRole="tablist" 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
}}>
<View
style={[
a.flex_row,
a.flex_grow,
a.gap_sm,
a.align_center,
a.justify_start,
]}>
{tabs.map((tab, index) => (
<ReactionTab
key={tab.key}
index={index}
tab={tab}
selected={selected}
total={tabs.length}
onPress={handlePress}
onTabLayout={handleTabLayout}
/>
))}
</View>
</DraggableScrollView>
</View>
)
}
function ReactionTab({
index,
tab,
selected,
total,
onPress,
onTabLayout,
}: {
index: number
tab: Tab
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="tab"
accessibilityState={{selected: selected === tab.key}}
accessibilityHint={
tab.key === 'all'
? l`Tap to show all reactions`
: l`Tap to show ${tab.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 === tab.key
? t.atoms.border_contrast_low
: {borderColor: t.palette.contrast_50},
selected === tab.key ? t.atoms.bg_contrast_50 : t.atoms.bg,
index === 0 ? a.ml_2xl : index === total - 1 ? a.mr_2xl : null,
]}
onLayout={e => {
onTabLayout(tab.key, {
x: e.nativeEvent.layout.x,
width: e.nativeEvent.layout.width,
})
}}
onPress={() => onPress(tab.key)}>
<Text emoji style={[a.text_sm]}>
{tab.key === 'all'
? l({
message: `All ${tab.count}`,
comment:
'Tab label showing the total count of reactions on a chat message.',
})
: `${tab.value} ${tab.count}`}
</Text>
</Pressable>
)
}
export function groupReactions(
reactions: ChatBskyConvoDefs.ReactionView[] | undefined,
): Reaction[] {
const grouped = new Map<string, Reaction>()
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())
}