Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 391 lines 10 kB view raw
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}