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}`} ) }