Bluesky app fork with some witchin' additions 💫 witchsky.app
bluesky fork client
117
fork

Configure Feed

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

Remove own reaction from reactions dialog on tap (#10231)

Co-authored-by: Samuel Newman <mozzius@protonmail.com>

authored by

DS Boyce
Samuel Newman
and committed by
GitHub
ac68cfe9 36c95d7d

+532 -336
+9 -4
modules/bottom-sheet/src/BottomSheetNativeComponent.tsx
··· 1 - import * as React from 'react' 1 + import {Component, createRef} from 'react' 2 2 import { 3 3 Dimensions, 4 4 type LayoutChangeEvent, ··· 39 39 const IS_NON_E2E_ANDROID = 40 40 Platform.OS === 'android' && Number(Platform.Version) < 35 41 41 42 - export class BottomSheetNativeComponent extends React.Component< 42 + export class BottomSheetNativeComponent extends Component< 43 43 BottomSheetViewProps, 44 44 { 45 45 open: boolean 46 46 viewHeight?: number 47 47 } 48 48 > { 49 - ref = React.createRef<any>() 49 + ref = createRef<any>() 50 50 51 51 static contextType = PortalContext 52 52 ··· 129 129 function BottomSheetNativeComponentInner({ 130 130 children, 131 131 backgroundColor, 132 + maxHeight, 132 133 onLayout, 133 134 onStateChange, 134 135 nativeViewRef, ··· 156 157 return ( 157 158 <NativeView 158 159 {...rest} 160 + maxHeight={maxHeight} 159 161 onStateChange={onStateChange} 160 162 ref={nativeViewRef} 161 163 style={{ ··· 170 172 flex: 1, 171 173 backgroundColor, 172 174 }, 175 + maxHeight != null && {maxHeight}, 173 176 Platform.OS === 'android' && { 174 177 borderTopLeftRadius: cornerRadius, 175 178 borderTopRightRadius: cornerRadius, ··· 177 180 }, 178 181 extraStyles, 179 182 ]}> 180 - <View onLayout={onLayout}> 183 + <View 184 + onLayout={onLayout} 185 + style={maxHeight == null ? undefined : {flex: 1}}> 181 186 <BottomSheetPortalProvider>{children}</BottomSheetPortalProvider> 182 187 </View> 183 188 </View>
+1
src/components/Dialog/context.ts
··· 23 23 disableDrag: false, 24 24 setDisableDrag: () => {}, 25 25 isWithinDialog: false, 26 + isHeightConstrained: false, 26 27 }) 27 28 Context.displayName = 'DialogContext' 28 29
+11 -4
src/components/Dialog/index.tsx
··· 157 157 [open, close], 158 158 ) 159 159 160 + const isHeightConstrained = nativeOptions?.maxHeight != null 161 + 160 162 const context = useMemo( 161 163 () => ({ 162 164 close, ··· 165 167 disableDrag, 166 168 setDisableDrag, 167 169 isWithinDialog: true, 170 + isHeightConstrained, 168 171 }), 169 - [close, snapPoint, disableDrag, setDisableDrag], 172 + [close, snapPoint, disableDrag, setDisableDrag, isHeightConstrained], 170 173 ) 171 174 172 175 return ( ··· 180 183 onStateChange={onStateChange} 181 184 disableDrag={disableDrag}> 182 185 <Context.Provider value={context}> 183 - <View testID={testID} style={[a.relative]}> 186 + <View 187 + testID={testID} 188 + style={[a.relative, isHeightConstrained && a.flex_1]}> 184 189 {children} 185 190 </View> 186 191 </Context.Provider> ··· 213 218 214 219 export const ScrollableInner = forwardRef<ScrollView, DialogInnerProps>( 215 220 function ScrollableInner( 216 - {children, contentContainerStyle, header, ...props}, 221 + {children, contentContainerStyle, header, style, ...props}, 217 222 ref, 218 223 ) { 219 - const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext() 224 + const {nativeSnapPoint, disableDrag, setDisableDrag, isHeightConstrained} = 225 + useDialogContext() 220 226 const isAtMaxSnapPoint = nativeSnapPoint === BottomSheetSnapPoint.Full 221 227 const insets = useSafeAreaInsets() 222 228 const [keyboardHeight, setKeyboardHeight] = useState(() => ··· 243 249 244 250 return ( 245 251 <ScrollView 252 + style={[isHeightConstrained && a.flex_1, style]} 246 253 contentContainerStyle={[ 247 254 a.pt_2xl, 248 255 IS_LIQUID_GLASS ? a.px_2xl : a.px_xl,
+2
src/components/Dialog/index.web.tsx
··· 111 111 disableDrag: false, 112 112 setDisableDrag: () => {}, 113 113 isWithinDialog: true, 114 + isHeightConstrained: false, 114 115 }), 115 116 [close], 116 117 ) ··· 196 197 a.border, 197 198 t.atoms.bg, 198 199 { 200 + cursor: 'default', // The overlay applies `cursor: 'pointer'` to all children. 199 201 maxWidth: 600, 200 202 borderColor: t.palette.contrast_200, 201 203 shadowColor: t.palette.black,
+1
src/components/Dialog/types.ts
··· 45 45 setDisableDrag: React.Dispatch<React.SetStateAction<boolean>> 46 46 // in the event that the hook is used outside of a dialog 47 47 isWithinDialog: boolean 48 + isHeightConstrained: boolean 48 49 } 49 50 50 51 export type DialogControlOpenOptions = {
+2 -4
src/components/Error.tsx
··· 60 60 color="primary" 61 61 label={_(msg`Press to retry`)} 62 62 onPress={onRetry} 63 - size="large" 64 - style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]}> 63 + size="large"> 65 64 <ButtonText> 66 65 <Trans>Retry</Trans> 67 66 </ButtonText> ··· 73 72 color={onRetry ? 'secondary' : 'primary'} 74 73 label={_(msg`Return to previous page`)} 75 74 onPress={goBack} 76 - size="large" 77 - style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]}> 75 + size="large"> 78 76 <ButtonText> 79 77 <Trans>Go Back</Trans> 80 78 </ButtonText>
+115 -324
src/components/dms/MessageItem.tsx
··· 1 - import {memo, useCallback, useEffect, useMemo, useState} from 'react' 1 + import {memo, useCallback, useEffect, useMemo} from 'react' 2 2 import { 3 3 type GestureResponderEvent, 4 4 LayoutAnimation, ··· 6 6 type StyleProp, 7 7 type TextStyle, 8 8 View, 9 + type ViewStyle, 9 10 } from 'react-native' 10 11 import Animated, { 11 12 FadeIn, ··· 25 26 } from '@atproto/api' 26 27 import {plural} from '@lingui/core/macro' 27 28 import {Trans, useLingui} from '@lingui/react/macro' 29 + import {useQueryClient} from '@tanstack/react-query' 28 30 29 - import {HITSLOP_10} from '#/lib/constants' 31 + import {makeProfileLink} from '#/lib/routes/links' 30 32 import {sanitizeDisplayName} from '#/lib/strings/display-names' 31 33 import {sanitizeHandle} from '#/lib/strings/handles' 32 34 import {useConvoActive} from '#/state/messages/convo' 33 35 import {type ConvoItem} from '#/state/messages/convo/types' 34 36 import {useModerationOpts} from '#/state/preferences/moderation-opts' 37 + import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache' 35 38 import {useSession} from '#/state/session' 36 - import {DraggableScrollView} from '#/view/com/pager/DraggableScrollView' 37 - import {UserAvatar} from '#/view/com/util/UserAvatar' 38 - import {atoms as a, native, useTheme, web} from '#/alf' 39 + import {atoms as a, native, platform, useTheme} from '#/alf' 39 40 import {isOnlyEmoji} from '#/alf/typography' 40 - import * as Dialog from '#/components/Dialog' 41 41 import {useDialogControl} from '#/components/Dialog' 42 42 import {ActionsWrapper} from '#/components/dms/ActionsWrapper' 43 - import {InlineLinkText} from '#/components/Link' 43 + import {InlineLinkText, Link} from '#/components/Link' 44 44 import * as ProfileCard from '#/components/ProfileCard' 45 45 import {RichText} from '#/components/RichText' 46 46 import {Text} from '#/components/Typography' ··· 48 48 import {DateDivider} from './DateDivider' 49 49 import {useDateDividerToggle} from './DateDividerToggle' 50 50 import {MessageItemEmbed} from './MessageItemEmbed' 51 + import {ReactionsDialog} from './ReactionsDialog' 51 52 52 53 const AVATAR_SIZE = 28 53 54 const CLUSTERED_MESSAGE_GAP = 2 ··· 55 56 const SQUARED_BORDER_RADIUS = 4 56 57 const DISPLAY_NAME_INSET = 22 57 58 58 - // 42px avatar + 2 * 8px my_sm margins 59 - const ROW_HEIGHT = 58 60 - 61 59 const CLUSTERED_MESSAGE_THRESHOLD_MS = 5 * 60 * 1000 62 60 const MESSAGE_GAP_THRESHOLD_MS = 60 * 60 * 1000 63 - 64 - type Reaction = { 65 - key: string 66 - value: string 67 - senders: ChatBskyConvoDefs.ReactionViewSender[] 68 - count: number 69 - } 70 61 71 62 function isWithinCluster({ 72 63 isPending, ··· 112 103 const {t: l} = useLingui() 113 104 const {convo} = useConvoActive() 114 105 const moderationOpts = useModerationOpts() 106 + const queryClient = useQueryClient() 115 107 116 108 const reactionsControl = useDialogControl() 117 109 ··· 203 195 const topRadiusSV = useSharedValue(targetTopRadius) 204 196 205 197 const showDisplayName = 206 - isGroupChat && 207 - !isFromSelf && 208 - effectiveFirstInCluster && 209 - !isDateDividerToggled && 210 - !isOnlyEmoji(message.text) 198 + isGroupChat && !isFromSelf && isFirstInCluster && !isOnlyEmoji(message.text) 211 199 const showAvatar = isGroupChat && !isFromSelf && isLastInCluster 212 200 213 201 useEffect(() => { ··· 231 219 ) 232 220 233 221 const avatar = profile ? ( 234 - <ProfileCard.Avatar 235 - profile={profile} 236 - size={AVATAR_SIZE} 237 - moderationOpts={moderationOpts!} 238 - disabledPreview 239 - /> 222 + <Link 223 + label={l`${sanitizeDisplayName( 224 + profile.displayName || sanitizeHandle(profile.handle), 225 + )}’s avatar`} 226 + accessibilityHint={l`Opens this profile`} 227 + to={makeProfileLink({ 228 + did: profile.did, 229 + handle: profile.handle, 230 + })} 231 + onPress={() => unstableCacheProfileView(queryClient, profile)}> 232 + <ProfileCard.Avatar 233 + profile={profile} 234 + size={AVATAR_SIZE} 235 + moderationOpts={moderationOpts!} 236 + disabledPreview 237 + /> 238 + </Link> 240 239 ) : ( 241 240 <ProfileCard.AvatarPlaceholder size={AVATAR_SIZE} /> 242 241 ) ··· 299 298 const appliedReactions = ( 300 299 <LayoutAnimationConfig skipEntering skipExiting> 301 300 {hasReactions ? ( 302 - <> 303 - <View 301 + <View 302 + style={[ 303 + a.relative, 304 + a.bottom_0, 305 + isFromSelf ? [a.align_end] : [a.ml_sm, a.align_start], 306 + a.px_sm, 307 + ]}> 308 + <Pressable 309 + accessible={true} 310 + accessibilityLabel={reactionsLabel} 311 + accessibilityHint={ 312 + isGroupChat ? l`Tap to view reactions` : undefined 313 + } 304 314 style={[ 305 - isFromSelf ? a.align_end : a.align_start, 306 - a.px_sm, 307 - a.pb_2xs, 308 - ]}> 309 - <Pressable 310 - accessible={true} 311 - accessibilityLabel={reactionsLabel} 312 - accessibilityHint={ 313 - isGroupChat ? l`Tap to view reactions` : undefined 314 - } 315 - style={[ 316 - a.flex_row, 317 - a.gap_2xs, 318 - a.py_xs, 319 - a.px_xs, 320 - isFromSelf ? a.justify_end : a.justify_start, 321 - a.flex_wrap, 322 - a.rounded_lg, 323 - a.border, 324 - t.atoms.border_contrast_low, 325 - t.atoms.bg_contrast_25, 326 - t.atoms.shadow_sm, 327 - { 328 - transform: [{translateY: -8}], 329 - }, 330 - ]} 331 - onPress={() => 332 - isGroupChat ? reactionsControl.open() : undefined 333 - }> 334 - {groupedReactions.map(group => ( 335 - <Animated.View 336 - entering={native(ZoomIn.springify(200).delay(400))} 337 - exiting={ 338 - groupedReactions.length > 1 && native(ZoomOut.delay(200)) 339 - } 340 - layout={native(LinearTransition.delay(300))} 341 - key={group.value} 342 - style={[a.p_2xs]}> 343 - <Text emoji style={[a.text_sm]}> 344 - {group.value} 345 - </Text> 346 - </Animated.View> 347 - ))} 348 - {groupedReactions.length !== reactions.length && 349 - reactions.length > 1 ? ( 350 - <View style={[a.p_2xs, a.justify_center]}> 351 - <Text 352 - style={[ 353 - a.text_xs, 354 - t.atoms.text_contrast_medium, 355 - {includeFontPadding: false}, 356 - ]}> 357 - {reactions.length} 358 - </Text> 359 - </View> 360 - ) : null} 361 - </Pressable> 362 - </View> 363 - <ReactionsDialog 364 - control={reactionsControl} 365 - members={convo.members} 366 - reactions={message.reactions} 367 - groupedReactions={groupedReactions} 368 - /> 369 - </> 315 + a.flex_row, 316 + a.gap_2xs, 317 + a.px_xs, 318 + isFromSelf ? a.justify_end : a.justify_start, 319 + a.flex_wrap, 320 + a.rounded_lg, 321 + a.border, 322 + t.atoms.border_contrast_low, 323 + t.atoms.bg_contrast_25, 324 + t.atoms.shadow_sm, 325 + { 326 + paddingTop: platform({android: 2, default: 3}), 327 + paddingBottom: platform({android: 2, default: 3}), 328 + transform: [{translateY: -8}], 329 + }, 330 + ]} 331 + onPress={() => (isGroupChat ? reactionsControl.open() : undefined)}> 332 + {groupedReactions.map(group => ( 333 + <Animated.View 334 + entering={native(ZoomIn.springify(200).delay(400))} 335 + exiting={ 336 + groupedReactions.length > 1 && native(ZoomOut.delay(200)) 337 + } 338 + layout={native(LinearTransition.delay(300))} 339 + key={group.value} 340 + style={[a.py_2xs]}> 341 + <Text 342 + emoji 343 + style={[ 344 + a.text_xs, 345 + {textAlignVertical: 'center', includeFontPadding: false}, 346 + ]}> 347 + {group.value} 348 + </Text> 349 + </Animated.View> 350 + ))} 351 + {groupedReactions.length !== reactions.length && 352 + reactions.length > 1 ? ( 353 + <View style={[a.p_2xs, a.pl_0, a.justify_center]}> 354 + <Text 355 + style={[ 356 + a.text_xs, 357 + t.atoms.text_contrast_medium, 358 + {textAlignVertical: 'center', includeFontPadding: false}, 359 + ]}> 360 + {reactions.length} 361 + </Text> 362 + </View> 363 + ) : null} 364 + </Pressable> 365 + </View> 370 366 ) : null} 367 + <ReactionsDialog 368 + control={reactionsControl} 369 + members={convo.members} 370 + message={message} 371 + reactions={message.reactions} 372 + groupedReactions={groupedReactions} 373 + /> 371 374 </LayoutAnimationConfig> 372 375 ) 373 376 377 + const messageInset = platform<ViewStyle | undefined>({ 378 + ios: isFromSelf ? a.mr_md : isGroupChat ? a.ml_md : a.ml_sm, 379 + android: isFromSelf ? a.mr_sm : isGroupChat ? a.ml_sm : undefined, 380 + web: isFromSelf ? a.mr_sm : isGroupChat ? a.ml_sm : undefined, 381 + }) 382 + 374 383 return ( 375 384 <> 376 385 {(showDateDivider || isDateDividerToggled) && ( ··· 379 388 </Animated.View> 380 389 )} 381 390 <View 382 - style={[ 383 - isFromSelf ? a.mr_sm : a.ml_sm, 384 - effectiveFirstInCluster && 385 - !(showDateDivider || isDateDividerToggled) && 386 - a.mt_sm, 387 - ]}> 391 + style={[messageInset, isFirstInCluster && !showDateDivider && a.mt_sm]}> 388 392 <View style={[a.relative]}> 389 393 {showAvatar ? ( 390 - <View style={[a.absolute, {bottom: hasReactions ? 10 : 0}]}> 394 + <View 395 + style={[ 396 + a.absolute, 397 + a.bottom_0, 398 + a.z_50, 399 + { 400 + transform: [{translateY: hasReactions ? -24 : 0}], 401 + }, 402 + ]}> 391 403 {avatar} 392 404 </View> 393 405 ) : null} 394 406 <View 395 407 style={[ 396 408 a.flex_grow, 397 - !isFromSelf && 398 - isGroupChat && { 399 - paddingLeft: AVATAR_SIZE, 400 - }, 409 + !isFromSelf && isGroupChat && {paddingLeft: AVATAR_SIZE}, 401 410 ]}> 402 411 {showDisplayName ? ( 403 412 <Text ··· 537 546 } 538 547 MessageItemMetadata = memo(MessageItemMetadata) 539 548 export {MessageItemMetadata} 540 - 541 - function ReactionsDialog({ 542 - control, 543 - members, 544 - reactions, 545 - groupedReactions, 546 - }: { 547 - control: Dialog.DialogControlProps 548 - members: bsky.profile.AnyProfileView[] 549 - reactions?: ChatBskyConvoDefs.ReactionView[] 550 - groupedReactions?: Reaction[] 551 - }) { 552 - const t = useTheme() 553 - const {t: l} = useLingui() 554 - 555 - const [selected, setSelected] = useState('all') 556 - 557 - const handleFilter = (value: string) => { 558 - setSelected(value) 559 - } 560 - 561 - const filteredMembers = 562 - selected === 'all' 563 - ? members 564 - : members.filter(m => 565 - reactions?.some(r => r.sender.did === m.did && r.value === selected), 566 - ) 567 - 568 - const minHeight = members.length * ROW_HEIGHT 569 - 570 - return ( 571 - <Dialog.Outer 572 - control={control} 573 - onClose={() => setSelected('all')} 574 - nativeOptions={{preventExpansion: true, minHeight}}> 575 - <Dialog.Handle /> 576 - <View style={[a.px_2xl, a.pt_3xl, t.atoms.bg]}> 577 - <Text style={[a.font_bold, a.text_2xl, a.mb_sm]}> 578 - <Trans>Reactions</Trans> 579 - </Text> 580 - </View> 581 - <ReactionTabs 582 - groupedReactions={groupedReactions} 583 - selected={selected} 584 - totalReactions={reactions?.length ?? 0} 585 - onFilter={handleFilter} 586 - /> 587 - <Dialog.ScrollableInner 588 - label={l`Reactions`} 589 - contentContainerStyle={[a.pt_0]} 590 - style={[web({maxWidth: 400})]}> 591 - {filteredMembers.map(profile => { 592 - const displayName = sanitizeDisplayName( 593 - profile?.displayName || sanitizeHandle(profile?.handle ?? ''), 594 - ) 595 - const handle = sanitizeHandle(profile?.handle ?? '', '@') 596 - const reaction = reactions?.find( 597 - ({sender}) => sender.did === profile.did, 598 - ) 599 - const rt = reaction 600 - ? new RichTextAPI({text: reaction.value}) 601 - : undefined 602 - 603 - return rt ? ( 604 - <View 605 - key={profile.did} 606 - style={[ 607 - a.flex_row, 608 - a.gap_sm, 609 - a.align_center, 610 - a.justify_between, 611 - a.my_sm, 612 - ]}> 613 - <View style={[a.flex_row, a.gap_sm]}> 614 - <UserAvatar 615 - avatar={profile.avatar} 616 - size={42} 617 - type="user" 618 - hideLiveBadge 619 - /> 620 - <View> 621 - <Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}> 622 - {displayName} 623 - </Text> 624 - <Text style={[a.text_xs, t.atoms.text_contrast_medium]}> 625 - {handle} 626 - </Text> 627 - </View> 628 - </View> 629 - <View> 630 - <RichText 631 - value={rt} 632 - style={[a.text_md]} 633 - interactiveStyle={a.underline} 634 - enableTags 635 - emojiMultiplier={2} 636 - shouldProxyLinks={true} 637 - /> 638 - </View> 639 - </View> 640 - ) : null 641 - })} 642 - </Dialog.ScrollableInner> 643 - </Dialog.Outer> 644 - ) 645 - } 646 - 647 - function ReactionTabs({ 648 - groupedReactions, 649 - selected, 650 - totalReactions, 651 - onFilter, 652 - }: { 653 - groupedReactions?: Reaction[] 654 - selected: string 655 - totalReactions: number 656 - onFilter: (value: string) => void 657 - }) { 658 - const t = useTheme() 659 - const {t: l} = useLingui() 660 - 661 - const contentSize = useSharedValue(0) 662 - const scrollX = useSharedValue(0) 663 - 664 - const handlePress = (value: string) => { 665 - onFilter(value) 666 - } 667 - 668 - const tabs = [ 669 - { 670 - key: 'all', 671 - value: l`All`, 672 - senders: [], 673 - count: totalReactions, 674 - } as Reaction, 675 - ...(groupedReactions ?? []), 676 - ] 677 - 678 - return ( 679 - <View accessibilityRole="list" style={[t.atoms.bg]}> 680 - <DraggableScrollView 681 - horizontal={true} 682 - showsHorizontalScrollIndicator={false} 683 - onScroll={e => { 684 - scrollX.set(Math.round(e.nativeEvent.contentOffset.x)) 685 - }}> 686 - <Animated.View 687 - style={[ 688 - a.flex_row, 689 - a.flex_grow, 690 - a.gap_sm, 691 - a.align_center, 692 - a.justify_start, 693 - ]} 694 - onLayout={e => { 695 - contentSize.set(e.nativeEvent.layout.width) 696 - }}> 697 - {tabs?.map((reaction, index) => ( 698 - <ReactionTab 699 - key={reaction.value} 700 - index={index} 701 - reaction={reaction} 702 - selected={selected} 703 - total={tabs.length} 704 - onPress={handlePress} 705 - /> 706 - ))} 707 - </Animated.View> 708 - </DraggableScrollView> 709 - </View> 710 - ) 711 - } 712 - 713 - function ReactionTab({ 714 - index, 715 - reaction, 716 - selected, 717 - total, 718 - onPress, 719 - }: { 720 - index: number 721 - reaction: Reaction 722 - selected: string 723 - total: number 724 - onPress: (value: string) => void 725 - }) { 726 - const t = useTheme() 727 - const {t: l} = useLingui() 728 - 729 - return ( 730 - <Pressable 731 - accessibilityRole="button" 732 - accessibilityHint={ 733 - reaction.key === 'all' 734 - ? l`Tap to show all reactions ` 735 - : l`Tap to show ${reaction.value} reactions` 736 - } 737 - hitSlop={HITSLOP_10} 738 - style={[ 739 - a.flex_row, 740 - a.align_center, 741 - a.border, 742 - a.justify_center, 743 - a.rounded_lg, 744 - a.px_md, 745 - a.py_sm, 746 - a.mb_sm, 747 - t.atoms.border_contrast_low, 748 - selected === reaction.key ? t.atoms.bg_contrast_50 : t.atoms.bg, 749 - index === 0 ? a.ml_2xl : index === total - 1 ? a.mr_2xl : null, 750 - ]} 751 - onPress={() => onPress(reaction.key)}> 752 - <Text emoji style={[a.text_sm]}> 753 - {l`${reaction.value} ${reaction.count}`} 754 - </Text> 755 - </Pressable> 756 - ) 757 - }
+391
src/components/dms/ReactionsDialog.tsx
··· 1 + import {useRef, useState} from 'react' 2 + import { 3 + LayoutAnimation, 4 + Pressable, 5 + type ScrollView, 6 + useWindowDimensions, 7 + View, 8 + } from 'react-native' 9 + import Animated from 'react-native-reanimated' 10 + import {type ChatBskyConvoDefs} from '@atproto/api' 11 + import {Trans, useLingui} from '@lingui/react/macro' 12 + 13 + import {HITSLOP_10} from '#/lib/constants' 14 + import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' 15 + import {sanitizeHandle} from '#/lib/strings/handles' 16 + import {type ActiveConvoStates, useConvoActive} from '#/state/messages/convo' 17 + import {useSession} from '#/state/session' 18 + import {DraggableScrollView} from '#/view/com/pager/DraggableScrollView' 19 + import {UserAvatar} from '#/view/com/util/UserAvatar' 20 + import {atoms as a, useTheme, web} from '#/alf' 21 + import * as Dialog from '#/components/Dialog' 22 + import * as Toast from '#/components/Toast' 23 + import {Text} from '#/components/Typography' 24 + import {IS_NATIVE, IS_WEB} from '#/env' 25 + import type * as bsky from '#/types/bsky' 26 + 27 + type Reaction = { 28 + key: string 29 + value: string 30 + senders: ChatBskyConvoDefs.ReactionViewSender[] 31 + count: number 32 + } 33 + 34 + export 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 + 125 + function 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 + 239 + function 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 + 337 + function 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 + }