forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import {useRef, useState} from 'react'
2import {
3 LayoutAnimation,
4 Pressable,
5 type ScrollView,
6 useWindowDimensions,
7 View,
8} from 'react-native'
9import Animated from 'react-native-reanimated'
10import {type ChatBskyConvoDefs} from '@atproto/api'
11import {Trans, useLingui} from '@lingui/react/macro'
12
13import {HITSLOP_10} from '#/lib/constants'
14import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
15import {sanitizeHandle} from '#/lib/strings/handles'
16import {type ActiveConvoStates, useConvoActive} from '#/state/messages/convo'
17import {useSession} from '#/state/session'
18import {DraggableScrollView} from '#/view/com/pager/DraggableScrollView'
19import {UserAvatar} from '#/view/com/util/UserAvatar'
20import {atoms as a, useTheme, web} from '#/alf'
21import * as Dialog from '#/components/Dialog'
22import * as Toast from '#/components/Toast'
23import {Text} from '#/components/Typography'
24import {IS_NATIVE, IS_WEB} from '#/env'
25import type * as bsky from '#/types/bsky'
26
27type Reaction = {
28 key: string
29 value: string
30 senders: ChatBskyConvoDefs.ReactionViewSender[]
31 count: number
32}
33
34export function ReactionsDialog({
35 control,
36 members,
37 message,
38 reactions,
39 groupedReactions,
40}: {
41 control: Dialog.DialogControlProps
42 members: bsky.profile.AnyProfileView[]
43 message: ChatBskyConvoDefs.MessageView
44 reactions?: ChatBskyConvoDefs.ReactionView[]
45 groupedReactions?: Reaction[]
46}) {
47 const {t: l} = useLingui()
48
49 const {height: screenHeight} = useWindowDimensions()
50 const {currentAccount} = useSession()
51 const convo = useConvoActive()
52
53 const [selected, setSelected] = useState('all')
54
55 const handleFilter = (value: string) => {
56 setSelected(value)
57 }
58
59 const filteredReactions = reactions?.filter(
60 r => selected === 'all' || r.value === selected,
61 )
62
63 const header = (
64 <>
65 <View style={[a.px_2xl, IS_WEB ? [a.pt_xl, a.pb_md] : a.pt_3xl]}>
66 <Text style={[a.font_bold, a.text_2xl, a.mb_sm]}>
67 <Trans>Reactions</Trans>
68 </Text>
69 </View>
70 <ReactionTabs
71 groupedReactions={groupedReactions}
72 selected={selected}
73 totalReactions={reactions?.length ?? 0}
74 onFilter={handleFilter}
75 />
76 <Dialog.Close />
77 </>
78 )
79
80 return (
81 <Dialog.Outer
82 control={control}
83 onClose={() => setSelected('all')}
84 nativeOptions={{
85 preventExpansion: true,
86 minHeight: screenHeight / 2,
87 maxHeight: screenHeight / 2,
88 }}>
89 <Dialog.Handle />
90 {IS_NATIVE ? header : null}
91 <Dialog.ScrollableInner
92 label={l`Reactions`}
93 contentContainerStyle={[a.pt_0]}
94 header={IS_WEB ? header : null}
95 style={[web({maxWidth: 400})]}>
96 {filteredReactions
97 ?.sort((a, b) => {
98 if (a.sender.did === currentAccount?.did) return -1
99 if (b.sender.did === currentAccount?.did) return 1
100 return 0
101 })
102 .map(reaction => {
103 const sender = members.find(m => m.did === reaction.sender.did)
104 if (!sender) return null
105 return (
106 <ReactionRow
107 key={reaction.sender.did + '-' + reaction.value}
108 control={control}
109 convo={convo}
110 currentAccount={currentAccount}
111 message={message}
112 profile={sender}
113 reaction={reaction}
114 allReactions={reactions ?? []}
115 selected={selected}
116 setSelected={setSelected}
117 />
118 )
119 })}
120 </Dialog.ScrollableInner>
121 </Dialog.Outer>
122 )
123}
124
125function ReactionRow({
126 control,
127 convo,
128 currentAccount,
129 message,
130 profile,
131 reaction,
132 allReactions,
133 selected,
134 setSelected,
135}: {
136 control: Dialog.DialogControlProps
137 convo: ActiveConvoStates
138 currentAccount?: bsky.profile.AnyProfileView
139 message: ChatBskyConvoDefs.MessageView
140 profile: bsky.profile.AnyProfileView
141 reaction: ChatBskyConvoDefs.ReactionView
142 allReactions: ChatBskyConvoDefs.ReactionView[]
143 selected: string
144 setSelected: React.Dispatch<React.SetStateAction<string>>
145}) {
146 const t = useTheme()
147 const {t: l} = useLingui()
148
149 const isFromSelf = currentAccount?.did === profile.did
150
151 const displayName = createSanitizedDisplayName(profile, true)
152 const handle = sanitizeHandle(profile?.handle ?? '', '@')
153
154 const handleOnPress = () => {
155 const remainingReactions =
156 allReactions?.filter(
157 r =>
158 !(r.value === reaction.value && r.sender.did === currentAccount?.did),
159 ) ?? []
160
161 if (remainingReactions.length === 0) {
162 control.close()
163 } else if (
164 selected !== 'all' &&
165 !remainingReactions.some(r => r.value === reaction.value)
166 ) {
167 // tab no longer exists
168 LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
169 setSelected('all')
170 }
171
172 convo
173 .removeReaction(message.id, reaction.value)
174 .catch(() => Toast.show(l`Failed to remove emoji reaction`))
175 }
176
177 const inner = (
178 <>
179 <View style={[a.flex_row, a.align_center, a.gap_sm]}>
180 <UserAvatar
181 avatar={profile.avatar}
182 size={42}
183 type="user"
184 hideLiveBadge
185 />
186 <View>
187 <Text
188 numberOfLines={1}
189 style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
190 {displayName}
191 </Text>
192 <Text
193 numberOfLines={1}
194 style={[a.text_xs, t.atoms.text_contrast_medium, web([a.mt_xs])]}>
195 {isFromSelf ? l`Tap to remove` : handle}
196 </Text>
197 </View>
198 </View>
199 <View>
200 <Text style={[a.text_5xl, {includeFontPadding: false}]} emoji>
201 {reaction.value}
202 </Text>
203 </View>
204 </>
205 )
206
207 if (isFromSelf) {
208 return (
209 <Pressable
210 accessibilityRole="button"
211 accessibilityHint={l`Tap to remove your ${reaction.value} reaction`}
212 style={[
213 a.flex_row,
214 a.align_center,
215 a.gap_sm,
216 a.justify_between,
217 a.my_sm,
218 ]}
219 onPress={handleOnPress}>
220 {inner}
221 </Pressable>
222 )
223 }
224
225 return (
226 <View
227 style={[
228 a.flex_row,
229 a.align_center,
230 a.gap_sm,
231 a.justify_between,
232 a.my_sm,
233 ]}>
234 {inner}
235 </View>
236 )
237}
238
239function ReactionTabs({
240 groupedReactions,
241 selected,
242 totalReactions,
243 onFilter,
244}: {
245 groupedReactions?: Reaction[]
246 selected: string
247 totalReactions: number
248 onFilter: (value: string) => void
249}) {
250 const t = useTheme()
251 const {t: l} = useLingui()
252
253 const scrollViewRef = useRef<ScrollView>(null)
254 const scrollState = useRef({x: 0, width: 0})
255 const tabLayouts = useRef<Map<string, {x: number; width: number}>>(new Map())
256
257 const handlePress = (value: string) => {
258 onFilter(value)
259
260 // Scroll a partially-visible tab fully into view.
261 const layout = tabLayouts.current.get(value)
262 if (layout && scrollViewRef.current && scrollState.current.width > 0) {
263 const tabLeft = layout.x
264 const tabRight = layout.x + layout.width
265 const viewLeft = scrollState.current.x
266 const viewRight = viewLeft + scrollState.current.width
267
268 if (tabLeft < viewLeft) {
269 scrollViewRef.current.scrollTo({
270 x: Math.max(0, tabLeft - 24),
271 animated: true,
272 })
273 } else if (tabRight > viewRight) {
274 scrollViewRef.current.scrollTo({
275 x: tabRight - scrollState.current.width + 24,
276 animated: true,
277 })
278 }
279 }
280 }
281
282 const handleTabLayout = (key: string, layout: {x: number; width: number}) => {
283 tabLayouts.current.set(key, layout)
284 }
285
286 const tabs = [
287 {
288 key: 'all',
289 value: l`All`,
290 senders: [],
291 count: totalReactions,
292 } as Reaction,
293 ...(groupedReactions ?? []),
294 ]
295
296 return (
297 <View accessibilityRole="list" style={[t.atoms.bg]}>
298 <DraggableScrollView
299 ref={scrollViewRef}
300 horizontal={true}
301 scrollEventThrottle={16}
302 showsHorizontalScrollIndicator={false}
303 onScroll={e => {
304 scrollState.current = {
305 x: e.nativeEvent.contentOffset.x,
306 width: e.nativeEvent.layoutMeasurement.width,
307 }
308 }}
309 onLayout={e => {
310 scrollState.current.width = e.nativeEvent.layout.width
311 }}>
312 <Animated.View
313 style={[
314 a.flex_row,
315 a.flex_grow,
316 a.gap_sm,
317 a.align_center,
318 a.justify_start,
319 ]}>
320 {tabs?.map((reaction, index) => (
321 <ReactionTab
322 key={reaction.value}
323 index={index}
324 reaction={reaction}
325 selected={selected}
326 total={tabs.length}
327 onPress={handlePress}
328 onTabLayout={handleTabLayout}
329 />
330 ))}
331 </Animated.View>
332 </DraggableScrollView>
333 </View>
334 )
335}
336
337function ReactionTab({
338 index,
339 reaction,
340 selected,
341 total,
342 onPress,
343 onTabLayout,
344}: {
345 index: number
346 reaction: Reaction
347 selected: string
348 total: number
349 onPress: (value: string) => void
350 onTabLayout: (key: string, layout: {x: number; width: number}) => void
351}) {
352 const t = useTheme()
353 const {t: l} = useLingui()
354
355 return (
356 <Pressable
357 accessibilityRole="button"
358 accessibilityHint={
359 reaction.key === 'all'
360 ? l`Tap to show all reactions`
361 : l`Tap to show ${reaction.value} reactions`
362 }
363 hitSlop={HITSLOP_10}
364 style={[
365 a.flex_row,
366 a.align_center,
367 a.border,
368 a.justify_center,
369 a.rounded_lg,
370 a.px_md,
371 a.py_sm,
372 a.mb_sm,
373 selected === reaction.key
374 ? t.atoms.border_contrast_low
375 : {borderColor: t.palette.contrast_50},
376 selected === reaction.key ? t.atoms.bg_contrast_50 : t.atoms.bg,
377 index === 0 ? a.ml_2xl : index === total - 1 ? a.mr_2xl : null,
378 ]}
379 onLayout={e => {
380 onTabLayout(reaction.key, {
381 x: e.nativeEvent.layout.x,
382 width: e.nativeEvent.layout.width,
383 })
384 }}
385 onPress={() => onPress(reaction.key)}>
386 <Text emoji style={[a.text_sm]}>
387 {l`${reaction.value} ${reaction.count}`}
388 </Text>
389 </Pressable>
390 )
391}