Remove own reaction from reactions dialog on tap (#10231)
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import * as React from 'react'
|
import {Component, createRef} from 'react'
|
||||||
import {
|
import {
|
||||||
Dimensions,
|
Dimensions,
|
||||||
type LayoutChangeEvent,
|
type LayoutChangeEvent,
|
||||||
@@ -39,14 +39,14 @@ const IS_IOS15 =
|
|||||||
const IS_NON_E2E_ANDROID =
|
const IS_NON_E2E_ANDROID =
|
||||||
Platform.OS === 'android' && Number(Platform.Version) < 35
|
Platform.OS === 'android' && Number(Platform.Version) < 35
|
||||||
|
|
||||||
export class BottomSheetNativeComponent extends React.Component<
|
export class BottomSheetNativeComponent extends Component<
|
||||||
BottomSheetViewProps,
|
BottomSheetViewProps,
|
||||||
{
|
{
|
||||||
open: boolean
|
open: boolean
|
||||||
viewHeight?: number
|
viewHeight?: number
|
||||||
}
|
}
|
||||||
> {
|
> {
|
||||||
ref = React.createRef<any>()
|
ref = createRef<any>()
|
||||||
|
|
||||||
static contextType = PortalContext
|
static contextType = PortalContext
|
||||||
|
|
||||||
@@ -129,6 +129,7 @@ export class BottomSheetNativeComponent extends React.Component<
|
|||||||
function BottomSheetNativeComponentInner({
|
function BottomSheetNativeComponentInner({
|
||||||
children,
|
children,
|
||||||
backgroundColor,
|
backgroundColor,
|
||||||
|
maxHeight,
|
||||||
onLayout,
|
onLayout,
|
||||||
onStateChange,
|
onStateChange,
|
||||||
nativeViewRef,
|
nativeViewRef,
|
||||||
@@ -156,6 +157,7 @@ function BottomSheetNativeComponentInner({
|
|||||||
return (
|
return (
|
||||||
<NativeView
|
<NativeView
|
||||||
{...rest}
|
{...rest}
|
||||||
|
maxHeight={maxHeight}
|
||||||
onStateChange={onStateChange}
|
onStateChange={onStateChange}
|
||||||
ref={nativeViewRef}
|
ref={nativeViewRef}
|
||||||
style={{
|
style={{
|
||||||
@@ -170,6 +172,7 @@ function BottomSheetNativeComponentInner({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor,
|
backgroundColor,
|
||||||
},
|
},
|
||||||
|
maxHeight != null && {maxHeight},
|
||||||
Platform.OS === 'android' && {
|
Platform.OS === 'android' && {
|
||||||
borderTopLeftRadius: cornerRadius,
|
borderTopLeftRadius: cornerRadius,
|
||||||
borderTopRightRadius: cornerRadius,
|
borderTopRightRadius: cornerRadius,
|
||||||
@@ -177,7 +180,9 @@ function BottomSheetNativeComponentInner({
|
|||||||
},
|
},
|
||||||
extraStyles,
|
extraStyles,
|
||||||
]}>
|
]}>
|
||||||
<View onLayout={onLayout}>
|
<View
|
||||||
|
onLayout={onLayout}
|
||||||
|
style={maxHeight == null ? undefined : {flex: 1}}>
|
||||||
<BottomSheetPortalProvider>{children}</BottomSheetPortalProvider>
|
<BottomSheetPortalProvider>{children}</BottomSheetPortalProvider>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export const Context = createContext<DialogContextProps>({
|
|||||||
disableDrag: false,
|
disableDrag: false,
|
||||||
setDisableDrag: () => {},
|
setDisableDrag: () => {},
|
||||||
isWithinDialog: false,
|
isWithinDialog: false,
|
||||||
|
isHeightConstrained: false,
|
||||||
})
|
})
|
||||||
Context.displayName = 'DialogContext'
|
Context.displayName = 'DialogContext'
|
||||||
|
|
||||||
|
|||||||
@@ -157,6 +157,8 @@ export function Outer({
|
|||||||
[open, close],
|
[open, close],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const isHeightConstrained = nativeOptions?.maxHeight != null
|
||||||
|
|
||||||
const context = useMemo(
|
const context = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
close,
|
close,
|
||||||
@@ -165,8 +167,9 @@ export function Outer({
|
|||||||
disableDrag,
|
disableDrag,
|
||||||
setDisableDrag,
|
setDisableDrag,
|
||||||
isWithinDialog: true,
|
isWithinDialog: true,
|
||||||
|
isHeightConstrained,
|
||||||
}),
|
}),
|
||||||
[close, snapPoint, disableDrag, setDisableDrag],
|
[close, snapPoint, disableDrag, setDisableDrag, isHeightConstrained],
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -180,7 +183,9 @@ export function Outer({
|
|||||||
onStateChange={onStateChange}
|
onStateChange={onStateChange}
|
||||||
disableDrag={disableDrag}>
|
disableDrag={disableDrag}>
|
||||||
<Context.Provider value={context}>
|
<Context.Provider value={context}>
|
||||||
<View testID={testID} style={[a.relative]}>
|
<View
|
||||||
|
testID={testID}
|
||||||
|
style={[a.relative, isHeightConstrained && a.flex_1]}>
|
||||||
{children}
|
{children}
|
||||||
</View>
|
</View>
|
||||||
</Context.Provider>
|
</Context.Provider>
|
||||||
@@ -213,10 +218,11 @@ export function Inner({children, style, header}: DialogInnerProps) {
|
|||||||
|
|
||||||
export const ScrollableInner = forwardRef<ScrollView, DialogInnerProps>(
|
export const ScrollableInner = forwardRef<ScrollView, DialogInnerProps>(
|
||||||
function ScrollableInner(
|
function ScrollableInner(
|
||||||
{children, contentContainerStyle, header, ...props},
|
{children, contentContainerStyle, header, style, ...props},
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext()
|
const {nativeSnapPoint, disableDrag, setDisableDrag, isHeightConstrained} =
|
||||||
|
useDialogContext()
|
||||||
const isAtMaxSnapPoint = nativeSnapPoint === BottomSheetSnapPoint.Full
|
const isAtMaxSnapPoint = nativeSnapPoint === BottomSheetSnapPoint.Full
|
||||||
const insets = useSafeAreaInsets()
|
const insets = useSafeAreaInsets()
|
||||||
const [keyboardHeight, setKeyboardHeight] = useState(() =>
|
const [keyboardHeight, setKeyboardHeight] = useState(() =>
|
||||||
@@ -243,6 +249,7 @@ export const ScrollableInner = forwardRef<ScrollView, DialogInnerProps>(
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
|
style={[isHeightConstrained && a.flex_1, style]}
|
||||||
contentContainerStyle={[
|
contentContainerStyle={[
|
||||||
a.pt_2xl,
|
a.pt_2xl,
|
||||||
IS_LIQUID_GLASS ? a.px_2xl : a.px_xl,
|
IS_LIQUID_GLASS ? a.px_2xl : a.px_xl,
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ export function Outer({
|
|||||||
disableDrag: false,
|
disableDrag: false,
|
||||||
setDisableDrag: () => {},
|
setDisableDrag: () => {},
|
||||||
isWithinDialog: true,
|
isWithinDialog: true,
|
||||||
|
isHeightConstrained: false,
|
||||||
}),
|
}),
|
||||||
[close],
|
[close],
|
||||||
)
|
)
|
||||||
@@ -196,6 +197,7 @@ export function Inner({
|
|||||||
a.border,
|
a.border,
|
||||||
t.atoms.bg,
|
t.atoms.bg,
|
||||||
{
|
{
|
||||||
|
cursor: 'default', // The overlay applies `cursor: 'pointer'` to all children.
|
||||||
maxWidth: 600,
|
maxWidth: 600,
|
||||||
borderColor: t.palette.contrast_200,
|
borderColor: t.palette.contrast_200,
|
||||||
shadowColor: t.palette.black,
|
shadowColor: t.palette.black,
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export type DialogContextProps = {
|
|||||||
setDisableDrag: React.Dispatch<React.SetStateAction<boolean>>
|
setDisableDrag: React.Dispatch<React.SetStateAction<boolean>>
|
||||||
// in the event that the hook is used outside of a dialog
|
// in the event that the hook is used outside of a dialog
|
||||||
isWithinDialog: boolean
|
isWithinDialog: boolean
|
||||||
|
isHeightConstrained: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DialogControlOpenOptions = {
|
export type DialogControlOpenOptions = {
|
||||||
|
|||||||
@@ -60,8 +60,7 @@ export function Error({
|
|||||||
color="primary"
|
color="primary"
|
||||||
label={_(msg`Press to retry`)}
|
label={_(msg`Press to retry`)}
|
||||||
onPress={onRetry}
|
onPress={onRetry}
|
||||||
size="large"
|
size="large">
|
||||||
style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]}>
|
|
||||||
<ButtonText>
|
<ButtonText>
|
||||||
<Trans>Retry</Trans>
|
<Trans>Retry</Trans>
|
||||||
</ButtonText>
|
</ButtonText>
|
||||||
@@ -73,8 +72,7 @@ export function Error({
|
|||||||
color={onRetry ? 'secondary' : 'primary'}
|
color={onRetry ? 'secondary' : 'primary'}
|
||||||
label={_(msg`Return to previous page`)}
|
label={_(msg`Return to previous page`)}
|
||||||
onPress={goBack}
|
onPress={goBack}
|
||||||
size="large"
|
size="large">
|
||||||
style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]}>
|
|
||||||
<ButtonText>
|
<ButtonText>
|
||||||
<Trans>Go Back</Trans>
|
<Trans>Go Back</Trans>
|
||||||
</ButtonText>
|
</ButtonText>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {memo, useCallback, useEffect, useMemo, useState} from 'react'
|
import {memo, useCallback, useEffect, useMemo} from 'react'
|
||||||
import {
|
import {
|
||||||
type GestureResponderEvent,
|
type GestureResponderEvent,
|
||||||
LayoutAnimation,
|
LayoutAnimation,
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
type StyleProp,
|
type StyleProp,
|
||||||
type TextStyle,
|
type TextStyle,
|
||||||
View,
|
View,
|
||||||
|
type ViewStyle,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import Animated, {
|
import Animated, {
|
||||||
FadeIn,
|
FadeIn,
|
||||||
@@ -25,22 +26,21 @@ import {
|
|||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
import {plural} from '@lingui/core/macro'
|
import {plural} from '@lingui/core/macro'
|
||||||
import {Trans, useLingui} from '@lingui/react/macro'
|
import {Trans, useLingui} from '@lingui/react/macro'
|
||||||
|
import {useQueryClient} from '@tanstack/react-query'
|
||||||
|
|
||||||
import {HITSLOP_10} from '#/lib/constants'
|
import {makeProfileLink} from '#/lib/routes/links'
|
||||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||||
import {useConvoActive} from '#/state/messages/convo'
|
import {useConvoActive} from '#/state/messages/convo'
|
||||||
import {type ConvoItem} from '#/state/messages/convo/types'
|
import {type ConvoItem} from '#/state/messages/convo/types'
|
||||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||||
|
import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache'
|
||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
import {DraggableScrollView} from '#/view/com/pager/DraggableScrollView'
|
import {atoms as a, native, platform, useTheme} from '#/alf'
|
||||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
|
||||||
import {atoms as a, native, useTheme, web} from '#/alf'
|
|
||||||
import {isOnlyEmoji} from '#/alf/typography'
|
import {isOnlyEmoji} from '#/alf/typography'
|
||||||
import * as Dialog from '#/components/Dialog'
|
|
||||||
import {useDialogControl} from '#/components/Dialog'
|
import {useDialogControl} from '#/components/Dialog'
|
||||||
import {ActionsWrapper} from '#/components/dms/ActionsWrapper'
|
import {ActionsWrapper} from '#/components/dms/ActionsWrapper'
|
||||||
import {InlineLinkText} from '#/components/Link'
|
import {InlineLinkText, Link} from '#/components/Link'
|
||||||
import * as ProfileCard from '#/components/ProfileCard'
|
import * as ProfileCard from '#/components/ProfileCard'
|
||||||
import {RichText} from '#/components/RichText'
|
import {RichText} from '#/components/RichText'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
@@ -48,6 +48,7 @@ import type * as bsky from '#/types/bsky'
|
|||||||
import {DateDivider} from './DateDivider'
|
import {DateDivider} from './DateDivider'
|
||||||
import {useDateDividerToggle} from './DateDividerToggle'
|
import {useDateDividerToggle} from './DateDividerToggle'
|
||||||
import {MessageItemEmbed} from './MessageItemEmbed'
|
import {MessageItemEmbed} from './MessageItemEmbed'
|
||||||
|
import {ReactionsDialog} from './ReactionsDialog'
|
||||||
|
|
||||||
const AVATAR_SIZE = 28
|
const AVATAR_SIZE = 28
|
||||||
const CLUSTERED_MESSAGE_GAP = 2
|
const CLUSTERED_MESSAGE_GAP = 2
|
||||||
@@ -55,19 +56,9 @@ const BORDER_RADIUS = 18
|
|||||||
const SQUARED_BORDER_RADIUS = 4
|
const SQUARED_BORDER_RADIUS = 4
|
||||||
const DISPLAY_NAME_INSET = 22
|
const DISPLAY_NAME_INSET = 22
|
||||||
|
|
||||||
// 42px avatar + 2 * 8px my_sm margins
|
|
||||||
const ROW_HEIGHT = 58
|
|
||||||
|
|
||||||
const CLUSTERED_MESSAGE_THRESHOLD_MS = 5 * 60 * 1000
|
const CLUSTERED_MESSAGE_THRESHOLD_MS = 5 * 60 * 1000
|
||||||
const MESSAGE_GAP_THRESHOLD_MS = 60 * 60 * 1000
|
const MESSAGE_GAP_THRESHOLD_MS = 60 * 60 * 1000
|
||||||
|
|
||||||
type Reaction = {
|
|
||||||
key: string
|
|
||||||
value: string
|
|
||||||
senders: ChatBskyConvoDefs.ReactionViewSender[]
|
|
||||||
count: number
|
|
||||||
}
|
|
||||||
|
|
||||||
function isWithinCluster({
|
function isWithinCluster({
|
||||||
isPending,
|
isPending,
|
||||||
adjacentMessage,
|
adjacentMessage,
|
||||||
@@ -112,6 +103,7 @@ let MessageItem = ({
|
|||||||
const {t: l} = useLingui()
|
const {t: l} = useLingui()
|
||||||
const {convo} = useConvoActive()
|
const {convo} = useConvoActive()
|
||||||
const moderationOpts = useModerationOpts()
|
const moderationOpts = useModerationOpts()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
const reactionsControl = useDialogControl()
|
const reactionsControl = useDialogControl()
|
||||||
|
|
||||||
@@ -203,11 +195,7 @@ let MessageItem = ({
|
|||||||
const topRadiusSV = useSharedValue(targetTopRadius)
|
const topRadiusSV = useSharedValue(targetTopRadius)
|
||||||
|
|
||||||
const showDisplayName =
|
const showDisplayName =
|
||||||
isGroupChat &&
|
isGroupChat && !isFromSelf && isFirstInCluster && !isOnlyEmoji(message.text)
|
||||||
!isFromSelf &&
|
|
||||||
effectiveFirstInCluster &&
|
|
||||||
!isDateDividerToggled &&
|
|
||||||
!isOnlyEmoji(message.text)
|
|
||||||
const showAvatar = isGroupChat && !isFromSelf && isLastInCluster
|
const showAvatar = isGroupChat && !isFromSelf && isLastInCluster
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -231,12 +219,23 @@ let MessageItem = ({
|
|||||||
)
|
)
|
||||||
|
|
||||||
const avatar = profile ? (
|
const avatar = profile ? (
|
||||||
|
<Link
|
||||||
|
label={l`${sanitizeDisplayName(
|
||||||
|
profile.displayName || sanitizeHandle(profile.handle),
|
||||||
|
)}’s avatar`}
|
||||||
|
accessibilityHint={l`Opens this profile`}
|
||||||
|
to={makeProfileLink({
|
||||||
|
did: profile.did,
|
||||||
|
handle: profile.handle,
|
||||||
|
})}
|
||||||
|
onPress={() => unstableCacheProfileView(queryClient, profile)}>
|
||||||
<ProfileCard.Avatar
|
<ProfileCard.Avatar
|
||||||
profile={profile}
|
profile={profile}
|
||||||
size={AVATAR_SIZE}
|
size={AVATAR_SIZE}
|
||||||
moderationOpts={moderationOpts!}
|
moderationOpts={moderationOpts!}
|
||||||
disabledPreview
|
disabledPreview
|
||||||
/>
|
/>
|
||||||
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<ProfileCard.AvatarPlaceholder size={AVATAR_SIZE} />
|
<ProfileCard.AvatarPlaceholder size={AVATAR_SIZE} />
|
||||||
)
|
)
|
||||||
@@ -299,12 +298,12 @@ let MessageItem = ({
|
|||||||
const appliedReactions = (
|
const appliedReactions = (
|
||||||
<LayoutAnimationConfig skipEntering skipExiting>
|
<LayoutAnimationConfig skipEntering skipExiting>
|
||||||
{hasReactions ? (
|
{hasReactions ? (
|
||||||
<>
|
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
isFromSelf ? a.align_end : a.align_start,
|
a.relative,
|
||||||
|
a.bottom_0,
|
||||||
|
isFromSelf ? [a.align_end] : [a.ml_sm, a.align_start],
|
||||||
a.px_sm,
|
a.px_sm,
|
||||||
a.pb_2xs,
|
|
||||||
]}>
|
]}>
|
||||||
<Pressable
|
<Pressable
|
||||||
accessible={true}
|
accessible={true}
|
||||||
@@ -315,7 +314,6 @@ let MessageItem = ({
|
|||||||
style={[
|
style={[
|
||||||
a.flex_row,
|
a.flex_row,
|
||||||
a.gap_2xs,
|
a.gap_2xs,
|
||||||
a.py_xs,
|
|
||||||
a.px_xs,
|
a.px_xs,
|
||||||
isFromSelf ? a.justify_end : a.justify_start,
|
isFromSelf ? a.justify_end : a.justify_start,
|
||||||
a.flex_wrap,
|
a.flex_wrap,
|
||||||
@@ -325,12 +323,12 @@ let MessageItem = ({
|
|||||||
t.atoms.bg_contrast_25,
|
t.atoms.bg_contrast_25,
|
||||||
t.atoms.shadow_sm,
|
t.atoms.shadow_sm,
|
||||||
{
|
{
|
||||||
|
paddingTop: platform({android: 2, default: 3}),
|
||||||
|
paddingBottom: platform({android: 2, default: 3}),
|
||||||
transform: [{translateY: -8}],
|
transform: [{translateY: -8}],
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
onPress={() =>
|
onPress={() => (isGroupChat ? reactionsControl.open() : undefined)}>
|
||||||
isGroupChat ? reactionsControl.open() : undefined
|
|
||||||
}>
|
|
||||||
{groupedReactions.map(group => (
|
{groupedReactions.map(group => (
|
||||||
<Animated.View
|
<Animated.View
|
||||||
entering={native(ZoomIn.springify(200).delay(400))}
|
entering={native(ZoomIn.springify(200).delay(400))}
|
||||||
@@ -339,20 +337,25 @@ let MessageItem = ({
|
|||||||
}
|
}
|
||||||
layout={native(LinearTransition.delay(300))}
|
layout={native(LinearTransition.delay(300))}
|
||||||
key={group.value}
|
key={group.value}
|
||||||
style={[a.p_2xs]}>
|
style={[a.py_2xs]}>
|
||||||
<Text emoji style={[a.text_sm]}>
|
<Text
|
||||||
|
emoji
|
||||||
|
style={[
|
||||||
|
a.text_xs,
|
||||||
|
{textAlignVertical: 'center', includeFontPadding: false},
|
||||||
|
]}>
|
||||||
{group.value}
|
{group.value}
|
||||||
</Text>
|
</Text>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
))}
|
))}
|
||||||
{groupedReactions.length !== reactions.length &&
|
{groupedReactions.length !== reactions.length &&
|
||||||
reactions.length > 1 ? (
|
reactions.length > 1 ? (
|
||||||
<View style={[a.p_2xs, a.justify_center]}>
|
<View style={[a.p_2xs, a.pl_0, a.justify_center]}>
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
a.text_xs,
|
a.text_xs,
|
||||||
t.atoms.text_contrast_medium,
|
t.atoms.text_contrast_medium,
|
||||||
{includeFontPadding: false},
|
{textAlignVertical: 'center', includeFontPadding: false},
|
||||||
]}>
|
]}>
|
||||||
{reactions.length}
|
{reactions.length}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -360,17 +363,23 @@ let MessageItem = ({
|
|||||||
) : null}
|
) : null}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
) : null}
|
||||||
<ReactionsDialog
|
<ReactionsDialog
|
||||||
control={reactionsControl}
|
control={reactionsControl}
|
||||||
members={convo.members}
|
members={convo.members}
|
||||||
|
message={message}
|
||||||
reactions={message.reactions}
|
reactions={message.reactions}
|
||||||
groupedReactions={groupedReactions}
|
groupedReactions={groupedReactions}
|
||||||
/>
|
/>
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</LayoutAnimationConfig>
|
</LayoutAnimationConfig>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const messageInset = platform<ViewStyle | undefined>({
|
||||||
|
ios: isFromSelf ? a.mr_md : isGroupChat ? a.ml_md : a.ml_sm,
|
||||||
|
android: isFromSelf ? a.mr_sm : isGroupChat ? a.ml_sm : undefined,
|
||||||
|
web: isFromSelf ? a.mr_sm : isGroupChat ? a.ml_sm : undefined,
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{(showDateDivider || isDateDividerToggled) && (
|
{(showDateDivider || isDateDividerToggled) && (
|
||||||
@@ -379,25 +388,25 @@ let MessageItem = ({
|
|||||||
</Animated.View>
|
</Animated.View>
|
||||||
)}
|
)}
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[messageInset, isFirstInCluster && !showDateDivider && a.mt_sm]}>
|
||||||
isFromSelf ? a.mr_sm : a.ml_sm,
|
|
||||||
effectiveFirstInCluster &&
|
|
||||||
!(showDateDivider || isDateDividerToggled) &&
|
|
||||||
a.mt_sm,
|
|
||||||
]}>
|
|
||||||
<View style={[a.relative]}>
|
<View style={[a.relative]}>
|
||||||
{showAvatar ? (
|
{showAvatar ? (
|
||||||
<View style={[a.absolute, {bottom: hasReactions ? 10 : 0}]}>
|
<View
|
||||||
|
style={[
|
||||||
|
a.absolute,
|
||||||
|
a.bottom_0,
|
||||||
|
a.z_50,
|
||||||
|
{
|
||||||
|
transform: [{translateY: hasReactions ? -24 : 0}],
|
||||||
|
},
|
||||||
|
]}>
|
||||||
{avatar}
|
{avatar}
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
a.flex_grow,
|
a.flex_grow,
|
||||||
!isFromSelf &&
|
!isFromSelf && isGroupChat && {paddingLeft: AVATAR_SIZE},
|
||||||
isGroupChat && {
|
|
||||||
paddingLeft: AVATAR_SIZE,
|
|
||||||
},
|
|
||||||
]}>
|
]}>
|
||||||
{showDisplayName ? (
|
{showDisplayName ? (
|
||||||
<Text
|
<Text
|
||||||
@@ -537,221 +546,3 @@ let MessageItemMetadata = ({
|
|||||||
}
|
}
|
||||||
MessageItemMetadata = memo(MessageItemMetadata)
|
MessageItemMetadata = memo(MessageItemMetadata)
|
||||||
export {MessageItemMetadata}
|
export {MessageItemMetadata}
|
||||||
|
|
||||||
function ReactionsDialog({
|
|
||||||
control,
|
|
||||||
members,
|
|
||||||
reactions,
|
|
||||||
groupedReactions,
|
|
||||||
}: {
|
|
||||||
control: Dialog.DialogControlProps
|
|
||||||
members: bsky.profile.AnyProfileView[]
|
|
||||||
reactions?: ChatBskyConvoDefs.ReactionView[]
|
|
||||||
groupedReactions?: Reaction[]
|
|
||||||
}) {
|
|
||||||
const t = useTheme()
|
|
||||||
const {t: l} = useLingui()
|
|
||||||
|
|
||||||
const [selected, setSelected] = useState('all')
|
|
||||||
|
|
||||||
const handleFilter = (value: string) => {
|
|
||||||
setSelected(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
const filteredMembers =
|
|
||||||
selected === 'all'
|
|
||||||
? members
|
|
||||||
: members.filter(m =>
|
|
||||||
reactions?.some(r => r.sender.did === m.did && r.value === selected),
|
|
||||||
)
|
|
||||||
|
|
||||||
const minHeight = members.length * ROW_HEIGHT
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog.Outer
|
|
||||||
control={control}
|
|
||||||
onClose={() => setSelected('all')}
|
|
||||||
nativeOptions={{preventExpansion: true, minHeight}}>
|
|
||||||
<Dialog.Handle />
|
|
||||||
<View style={[a.px_2xl, a.pt_3xl, t.atoms.bg]}>
|
|
||||||
<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={handleFilter}
|
|
||||||
/>
|
|
||||||
<Dialog.ScrollableInner
|
|
||||||
label={l`Reactions`}
|
|
||||||
contentContainerStyle={[a.pt_0]}
|
|
||||||
style={[web({maxWidth: 400})]}>
|
|
||||||
{filteredMembers.map(profile => {
|
|
||||||
const displayName = sanitizeDisplayName(
|
|
||||||
profile?.displayName || sanitizeHandle(profile?.handle ?? ''),
|
|
||||||
)
|
|
||||||
const handle = sanitizeHandle(profile?.handle ?? '', '@')
|
|
||||||
const reaction = reactions?.find(
|
|
||||||
({sender}) => sender.did === profile.did,
|
|
||||||
)
|
|
||||||
const rt = reaction
|
|
||||||
? new RichTextAPI({text: reaction.value})
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
return rt ? (
|
|
||||||
<View
|
|
||||||
key={profile.did}
|
|
||||||
style={[
|
|
||||||
a.flex_row,
|
|
||||||
a.gap_sm,
|
|
||||||
a.align_center,
|
|
||||||
a.justify_between,
|
|
||||||
a.my_sm,
|
|
||||||
]}>
|
|
||||||
<View style={[a.flex_row, a.gap_sm]}>
|
|
||||||
<UserAvatar
|
|
||||||
avatar={profile.avatar}
|
|
||||||
size={42}
|
|
||||||
type="user"
|
|
||||||
hideLiveBadge
|
|
||||||
/>
|
|
||||||
<View>
|
|
||||||
<Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
|
|
||||||
{displayName}
|
|
||||||
</Text>
|
|
||||||
<Text style={[a.text_xs, t.atoms.text_contrast_medium]}>
|
|
||||||
{handle}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<View>
|
|
||||||
<RichText
|
|
||||||
value={rt}
|
|
||||||
style={[a.text_md]}
|
|
||||||
interactiveStyle={a.underline}
|
|
||||||
enableTags
|
|
||||||
emojiMultiplier={2}
|
|
||||||
shouldProxyLinks={true}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
) : null
|
|
||||||
})}
|
|
||||||
</Dialog.ScrollableInner>
|
|
||||||
</Dialog.Outer>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ReactionTabs({
|
|
||||||
groupedReactions,
|
|
||||||
selected,
|
|
||||||
totalReactions,
|
|
||||||
onFilter,
|
|
||||||
}: {
|
|
||||||
groupedReactions?: Reaction[]
|
|
||||||
selected: string
|
|
||||||
totalReactions: number
|
|
||||||
onFilter: (value: string) => void
|
|
||||||
}) {
|
|
||||||
const t = useTheme()
|
|
||||||
const {t: l} = useLingui()
|
|
||||||
|
|
||||||
const contentSize = useSharedValue(0)
|
|
||||||
const scrollX = useSharedValue(0)
|
|
||||||
|
|
||||||
const handlePress = (value: string) => {
|
|
||||||
onFilter(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
const tabs = [
|
|
||||||
{
|
|
||||||
key: 'all',
|
|
||||||
value: l`All`,
|
|
||||||
senders: [],
|
|
||||||
count: totalReactions,
|
|
||||||
} as Reaction,
|
|
||||||
...(groupedReactions ?? []),
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View accessibilityRole="list" style={[t.atoms.bg]}>
|
|
||||||
<DraggableScrollView
|
|
||||||
horizontal={true}
|
|
||||||
showsHorizontalScrollIndicator={false}
|
|
||||||
onScroll={e => {
|
|
||||||
scrollX.set(Math.round(e.nativeEvent.contentOffset.x))
|
|
||||||
}}>
|
|
||||||
<Animated.View
|
|
||||||
style={[
|
|
||||||
a.flex_row,
|
|
||||||
a.flex_grow,
|
|
||||||
a.gap_sm,
|
|
||||||
a.align_center,
|
|
||||||
a.justify_start,
|
|
||||||
]}
|
|
||||||
onLayout={e => {
|
|
||||||
contentSize.set(e.nativeEvent.layout.width)
|
|
||||||
}}>
|
|
||||||
{tabs?.map((reaction, index) => (
|
|
||||||
<ReactionTab
|
|
||||||
key={reaction.value}
|
|
||||||
index={index}
|
|
||||||
reaction={reaction}
|
|
||||||
selected={selected}
|
|
||||||
total={tabs.length}
|
|
||||||
onPress={handlePress}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Animated.View>
|
|
||||||
</DraggableScrollView>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ReactionTab({
|
|
||||||
index,
|
|
||||||
reaction,
|
|
||||||
selected,
|
|
||||||
total,
|
|
||||||
onPress,
|
|
||||||
}: {
|
|
||||||
index: number
|
|
||||||
reaction: Reaction
|
|
||||||
selected: string
|
|
||||||
total: number
|
|
||||||
onPress: (value: string) => void
|
|
||||||
}) {
|
|
||||||
const t = useTheme()
|
|
||||||
const {t: l} = useLingui()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Pressable
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityHint={
|
|
||||||
reaction.key === 'all'
|
|
||||||
? l`Tap to show all reactions `
|
|
||||||
: l`Tap to show ${reaction.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,
|
|
||||||
t.atoms.border_contrast_low,
|
|
||||||
selected === reaction.key ? t.atoms.bg_contrast_50 : t.atoms.bg,
|
|
||||||
index === 0 ? a.ml_2xl : index === total - 1 ? a.mr_2xl : null,
|
|
||||||
]}
|
|
||||||
onPress={() => onPress(reaction.key)}>
|
|
||||||
<Text emoji style={[a.text_sm]}>
|
|
||||||
{l`${reaction.value} ${reaction.count}`}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,391 @@
|
|||||||
|
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 = (
|
||||||
|
<>
|
||||||
|
<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={handleFilter}
|
||||||
|
/>
|
||||||
|
<Dialog.Close />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog.Outer
|
||||||
|
control={control}
|
||||||
|
onClose={() => setSelected('all')}
|
||||||
|
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 = members.find(m => m.did === 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?: bsky.profile.AnyProfileView
|
||||||
|
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 = [
|
||||||
|
{
|
||||||
|
key: 'all',
|
||||||
|
value: l`All`,
|
||||||
|
senders: [],
|
||||||
|
count: totalReactions,
|
||||||
|
} as Reaction,
|
||||||
|
...(groupedReactions ?? []),
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View accessibilityRole="list" 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
|
||||||
|
}}>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
a.flex_row,
|
||||||
|
a.flex_grow,
|
||||||
|
a.gap_sm,
|
||||||
|
a.align_center,
|
||||||
|
a.justify_start,
|
||||||
|
]}>
|
||||||
|
{tabs?.map((reaction, index) => (
|
||||||
|
<ReactionTab
|
||||||
|
key={reaction.value}
|
||||||
|
index={index}
|
||||||
|
reaction={reaction}
|
||||||
|
selected={selected}
|
||||||
|
total={tabs.length}
|
||||||
|
onPress={handlePress}
|
||||||
|
onTabLayout={handleTabLayout}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Animated.View>
|
||||||
|
</DraggableScrollView>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityHint={
|
||||||
|
reaction.key === 'all'
|
||||||
|
? l`Tap to show all reactions `
|
||||||
|
: l`Tap to show ${reaction.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 === reaction.key
|
||||||
|
? t.atoms.border_contrast_low
|
||||||
|
: {borderColor: t.palette.contrast_50},
|
||||||
|
selected === reaction.key ? t.atoms.bg_contrast_50 : t.atoms.bg,
|
||||||
|
index === 0 ? a.ml_2xl : index === total - 1 ? a.mr_2xl : null,
|
||||||
|
]}
|
||||||
|
onLayout={e => {
|
||||||
|
onTabLayout(reaction.key, {
|
||||||
|
x: e.nativeEvent.layout.x,
|
||||||
|
width: e.nativeEvent.layout.width,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
onPress={() => onPress(reaction.key)}>
|
||||||
|
<Text emoji style={[a.text_sm]}>
|
||||||
|
{l`${reaction.value} ${reaction.count}`}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user