1b4c72f516
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
417 lines
11 KiB
TypeScript
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())
|
|
}
|