Mount message dialogs once at the list level (#10435)
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
This commit is contained in:
@@ -224,6 +224,7 @@ export function Basic({
|
||||
cancelButtonCta,
|
||||
confirmButtonCta,
|
||||
onConfirm,
|
||||
onClose,
|
||||
confirmButtonColor,
|
||||
showCancel = true,
|
||||
}: React.PropsWithChildren<{
|
||||
@@ -240,11 +241,12 @@ export function Basic({
|
||||
* should NOT close the dialog as a side effect of this method.
|
||||
*/
|
||||
onConfirm: (e: GestureResponderEvent) => void
|
||||
onClose?: () => void
|
||||
confirmButtonColor?: ButtonColor
|
||||
showCancel?: boolean
|
||||
}>) {
|
||||
return (
|
||||
<Outer control={control} testID="confirmModal">
|
||||
<Outer control={control} testID="confirmModal" onClose={onClose}>
|
||||
<Content>
|
||||
<TitleText>{title}</TitleText>
|
||||
{description && <DescriptionText>{description}</DescriptionText>}
|
||||
|
||||
@@ -33,14 +33,19 @@ export const AfterReportDialog = memo(function BlockOrDeleteDialogInner({
|
||||
control,
|
||||
params,
|
||||
currentScreen,
|
||||
onClose,
|
||||
}: {
|
||||
control: Dialog.DialogControlProps
|
||||
params: ReportDialogParams
|
||||
currentScreen: 'list' | 'conversation'
|
||||
onClose?: () => void
|
||||
}): React.ReactNode {
|
||||
const {t: l} = useLingui()
|
||||
return (
|
||||
<Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
|
||||
<Dialog.Outer
|
||||
control={control}
|
||||
onClose={onClose}
|
||||
nativeOptions={{preventExpansion: true}}>
|
||||
<Dialog.Handle />
|
||||
<Dialog.ScrollableInner
|
||||
label={l`Would you like to block this user and/or delete this conversation?`}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {memo, useCallback} from 'react'
|
||||
import {LayoutAnimation, Platform} from 'react-native'
|
||||
import {Platform} from 'react-native'
|
||||
import * as Clipboard from 'expo-clipboard'
|
||||
import {
|
||||
type ChatBskyConvoDefs,
|
||||
@@ -7,26 +7,21 @@ import {
|
||||
RichText,
|
||||
} from '@atproto/api'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {useGoogleTranslate} from '#/lib/hooks/useGoogleTranslate'
|
||||
import {richTextToString} from '#/lib/strings/rich-text-helpers'
|
||||
import {useMaybeProfileShadow} from '#/state/cache/profile-shadow'
|
||||
import {useConvoActive} from '#/state/messages/convo'
|
||||
import {useLanguagePrefs} from '#/state/preferences'
|
||||
import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache'
|
||||
import {useSession} from '#/state/session'
|
||||
import {atoms as a} from '#/alf'
|
||||
import * as ContextMenu from '#/components/ContextMenu'
|
||||
import {type TriggerProps} from '#/components/ContextMenu/types'
|
||||
import {AfterReportDialog} from '#/components/dms/AfterReportDialog'
|
||||
import {useMessageDialogs} from '#/components/dms/MessageOverlays'
|
||||
import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard'
|
||||
import {Flag_Stroke2_Corner0_Rounded as FlagIcon} from '#/components/icons/Flag'
|
||||
import {Language_Stroke2_Corner2_Rounded as LanguageIcon} from '#/components/icons/Language'
|
||||
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
|
||||
import {ReportDialog} from '#/components/moderation/ReportDialog'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {usePromptControl} from '#/components/Prompt'
|
||||
import * as Toast from '#/components/Toast'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import {IS_NATIVE} from '#/env'
|
||||
@@ -48,11 +43,8 @@ export let MessageContextMenu = ({
|
||||
const {t: l, i18n} = useLingui()
|
||||
const ax = useAnalytics()
|
||||
const {currentAccount} = useSession()
|
||||
const queryClient = useQueryClient()
|
||||
const convo = useConvoActive()
|
||||
const deleteControl = usePromptControl()
|
||||
const reportControl = usePromptControl()
|
||||
const blockOrDeleteControl = usePromptControl()
|
||||
const {openDeleteMessage, openReportMessage} = useMessageDialogs()
|
||||
const langPrefs = useLanguagePrefs()
|
||||
const translate = useGoogleTranslate()
|
||||
|
||||
@@ -93,14 +85,6 @@ export let MessageContextMenu = ({
|
||||
})
|
||||
}, [ax, langPrefs.primaryLanguage, message.text, translate])
|
||||
|
||||
const onDelete = useCallback(() => {
|
||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
|
||||
convo
|
||||
.deleteMessage(message.id)
|
||||
.then(() => Toast.show(l({message: 'Message deleted', context: 'toast'})))
|
||||
.catch(() => Toast.show(l`Failed to delete message`))
|
||||
}, [l, convo, message.id])
|
||||
|
||||
const onEmojiSelect = useCallback(
|
||||
(emoji: string) => {
|
||||
if (
|
||||
@@ -128,104 +112,72 @@ export let MessageContextMenu = ({
|
||||
const sender = senderProfile
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContextMenu.Root>
|
||||
{IS_NATIVE && reactionsAvailable && (
|
||||
<ContextMenu.AuxiliaryView
|
||||
align={isFromSelf ? 'right' : 'left'}
|
||||
style={[isFromSelf && isGroupChatEnabled ? null : a.ml_sm]}>
|
||||
<EmojiReactionPicker
|
||||
message={message}
|
||||
onEmojiSelect={onEmojiSelect}
|
||||
/>
|
||||
</ContextMenu.AuxiliaryView>
|
||||
)}
|
||||
|
||||
<ContextMenu.Trigger
|
||||
label={l`Message options`}
|
||||
contentLabel={l`Message from @${
|
||||
sender?.handle ?? 'unknown' // should always be defined
|
||||
}: ${message.text}`}>
|
||||
{children}
|
||||
</ContextMenu.Trigger>
|
||||
|
||||
<ContextMenu.Outer
|
||||
<ContextMenu.Root>
|
||||
{IS_NATIVE && reactionsAvailable && (
|
||||
<ContextMenu.AuxiliaryView
|
||||
align={isFromSelf ? 'right' : 'left'}
|
||||
label={l`Sent at ${i18n.date(new Date(message.sentAt), {
|
||||
timeStyle: 'short',
|
||||
})}`}
|
||||
style={[isFromSelf && isGroupChatEnabled ? null : a.ml_sm]}>
|
||||
{message.text.length > 0 && (
|
||||
<>
|
||||
<ContextMenu.Item
|
||||
testID="messageDropdownTranslateBtn"
|
||||
label={l`Translate`}
|
||||
onPress={onPressTranslateMessage}>
|
||||
<ContextMenu.ItemIcon icon={LanguageIcon} position="left" />
|
||||
<ContextMenu.ItemText>{l`Translate`}</ContextMenu.ItemText>
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item
|
||||
testID="messageDropdownCopyBtn"
|
||||
label={l`Copy message text`}
|
||||
onPress={onCopyMessage}>
|
||||
<ContextMenu.ItemIcon icon={ClipboardIcon} position="left" />
|
||||
<ContextMenu.ItemText>
|
||||
{l`Copy message text`}
|
||||
</ContextMenu.ItemText>
|
||||
</ContextMenu.Item>
|
||||
</>
|
||||
)}
|
||||
<EmojiReactionPicker
|
||||
message={message}
|
||||
onEmojiSelect={onEmojiSelect}
|
||||
/>
|
||||
</ContextMenu.AuxiliaryView>
|
||||
)}
|
||||
|
||||
<ContextMenu.Trigger
|
||||
label={l`Message options`}
|
||||
contentLabel={l`Message from @${
|
||||
sender?.handle ?? 'unknown' // should always be defined
|
||||
}: ${message.text}`}>
|
||||
{children}
|
||||
</ContextMenu.Trigger>
|
||||
|
||||
<ContextMenu.Outer
|
||||
align={isFromSelf ? 'right' : 'left'}
|
||||
label={l`Sent at ${i18n.date(new Date(message.sentAt), {
|
||||
timeStyle: 'short',
|
||||
})}`}
|
||||
style={[isFromSelf && isGroupChatEnabled ? null : a.ml_sm]}>
|
||||
{message.text.length > 0 && (
|
||||
<>
|
||||
<ContextMenu.Item
|
||||
testID="messageDropdownTranslateBtn"
|
||||
label={l`Translate`}
|
||||
onPress={onPressTranslateMessage}>
|
||||
<ContextMenu.ItemIcon icon={LanguageIcon} position="left" />
|
||||
<ContextMenu.ItemText>{l`Translate`}</ContextMenu.ItemText>
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item
|
||||
testID="messageDropdownCopyBtn"
|
||||
label={l`Copy message text`}
|
||||
onPress={onCopyMessage}>
|
||||
<ContextMenu.ItemIcon icon={ClipboardIcon} position="left" />
|
||||
<ContextMenu.ItemText>
|
||||
{l`Copy message text`}
|
||||
</ContextMenu.ItemText>
|
||||
</ContextMenu.Item>
|
||||
</>
|
||||
)}
|
||||
<ContextMenu.Item
|
||||
destructive
|
||||
testID="messageDropdownDeleteBtn"
|
||||
label={l`Delete message for me`}
|
||||
onPress={() => openDeleteMessage(message)}>
|
||||
<ContextMenu.ItemIcon icon={TrashIcon} position="left" />
|
||||
<ContextMenu.ItemText>{l`Delete for me`}</ContextMenu.ItemText>
|
||||
</ContextMenu.Item>
|
||||
{!isFromSelf && (
|
||||
<ContextMenu.Item
|
||||
destructive
|
||||
testID="messageDropdownDeleteBtn"
|
||||
label={l`Delete message for me`}
|
||||
onPress={() => deleteControl.open()}>
|
||||
<ContextMenu.ItemIcon icon={TrashIcon} position="left" />
|
||||
<ContextMenu.ItemText>{l`Delete for me`}</ContextMenu.ItemText>
|
||||
testID="messageDropdownReportBtn"
|
||||
label={l`Report message`}
|
||||
onPress={() => openReportMessage(message, senderProfile)}>
|
||||
<ContextMenu.ItemIcon icon={FlagIcon} position="left" />
|
||||
<ContextMenu.ItemText>{l`Report`}</ContextMenu.ItemText>
|
||||
</ContextMenu.Item>
|
||||
{!isFromSelf && (
|
||||
<ContextMenu.Item
|
||||
destructive
|
||||
testID="messageDropdownReportBtn"
|
||||
label={l`Report message`}
|
||||
onPress={() => reportControl.open()}>
|
||||
<ContextMenu.ItemIcon icon={FlagIcon} position="left" />
|
||||
<ContextMenu.ItemText>{l`Report`}</ContextMenu.ItemText>
|
||||
</ContextMenu.Item>
|
||||
)}
|
||||
</ContextMenu.Outer>
|
||||
</ContextMenu.Root>
|
||||
<ReportDialog
|
||||
control={reportControl}
|
||||
subject={{
|
||||
view: 'message',
|
||||
convoId: convo.convo.view.id,
|
||||
message,
|
||||
}}
|
||||
onAfterSubmit={() => {
|
||||
if (sender) {
|
||||
unstableCacheProfileView(queryClient, sender)
|
||||
}
|
||||
blockOrDeleteControl.open()
|
||||
}}
|
||||
/>
|
||||
<AfterReportDialog
|
||||
control={blockOrDeleteControl}
|
||||
currentScreen="conversation"
|
||||
params={{
|
||||
convoId: convo.convo.view.id,
|
||||
did: message.sender.did,
|
||||
}}
|
||||
/>
|
||||
<Prompt.Basic
|
||||
control={deleteControl}
|
||||
title={l`Delete message`}
|
||||
description={l`Are you sure you want to delete this message? The message will be deleted for you, but not for the other participants.`}
|
||||
confirmButtonCta={l`Delete`}
|
||||
confirmButtonColor="negative"
|
||||
onConfirm={onDelete}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</ContextMenu.Outer>
|
||||
</ContextMenu.Root>
|
||||
)
|
||||
}
|
||||
MessageContextMenu = memo(MessageContextMenu)
|
||||
|
||||
@@ -40,8 +40,8 @@ import {useSession} from '#/state/session'
|
||||
import {atoms as a, native, platform, useTheme} from '#/alf'
|
||||
import {isOnlyEmoji} from '#/alf/typography'
|
||||
import {Button} from '#/components/Button'
|
||||
import {useDialogControl} from '#/components/Dialog'
|
||||
import {ActionsWrapper} from '#/components/dms/ActionsWrapper'
|
||||
import {useMessageDialogs} from '#/components/dms/MessageOverlays'
|
||||
import {InlineLinkText, Link} from '#/components/Link'
|
||||
import * as ProfileCard from '#/components/ProfileCard'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
@@ -49,7 +49,7 @@ import {RichText} from '#/components/RichText'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {DateDivider} from './DateDivider'
|
||||
import {MessageItemEmbed} from './MessageItemEmbed'
|
||||
import {ReactionsDialog} from './ReactionsDialog'
|
||||
import {groupReactions} from './ReactionsDialog'
|
||||
import {CLUSTERED_MESSAGE_THRESHOLD_MS, MESSAGE_GAP_THRESHOLD_MS} from './util'
|
||||
|
||||
const AVATAR_SIZE = 28
|
||||
@@ -118,7 +118,7 @@ let MessageItem = ({
|
||||
const {message} = item
|
||||
const profile = useMaybeProfileShadow(relatedProfiles.get(message.sender.did))
|
||||
|
||||
const reactionsControl = useDialogControl()
|
||||
const {openReactions} = useMessageDialogs()
|
||||
|
||||
const isPending = item.type === 'pending-message'
|
||||
|
||||
@@ -243,34 +243,10 @@ let MessageItem = ({
|
||||
<ProfileCard.AvatarPlaceholder size={AVATAR_SIZE} />
|
||||
)
|
||||
|
||||
const groupedReactions = useMemo(() => {
|
||||
const reactions = message.reactions ?? []
|
||||
const grouped = new Map<
|
||||
string,
|
||||
{
|
||||
key: string
|
||||
value: string
|
||||
senders: ChatBskyConvoDefs.ReactionViewSender[]
|
||||
count: number
|
||||
}
|
||||
>()
|
||||
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())
|
||||
}, [message.reactions])
|
||||
const groupedReactions = useMemo(
|
||||
() => groupReactions(message.reactions),
|
||||
[message.reactions],
|
||||
)
|
||||
|
||||
const reactions = useMemo(() => message.reactions ?? [], [message.reactions])
|
||||
|
||||
@@ -336,7 +312,7 @@ let MessageItem = ({
|
||||
transform: [{translateY: -8}],
|
||||
},
|
||||
]}
|
||||
onPress={isGroupChat ? reactionsControl.open : undefined}>
|
||||
onPress={isGroupChat ? () => openReactions(message) : undefined}>
|
||||
{groupedReactions.map(group => (
|
||||
<Animated.View
|
||||
entering={native(ZoomIn.springify(200).delay(400))}
|
||||
@@ -377,13 +353,6 @@ let MessageItem = ({
|
||||
</Pressable>
|
||||
</View>
|
||||
) : null}
|
||||
<ReactionsDialog
|
||||
control={reactionsControl}
|
||||
relatedProfiles={relatedProfiles}
|
||||
message={message}
|
||||
reactions={message.reactions}
|
||||
groupedReactions={groupedReactions}
|
||||
/>
|
||||
</LayoutAnimationConfig>
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {LayoutAnimation} from 'react-native'
|
||||
import {type ChatBskyConvoDefs} from '@atproto/api'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {useConvoActive} from '#/state/messages/convo'
|
||||
import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache'
|
||||
import {useDialogControl} from '#/components/Dialog'
|
||||
import {AfterReportDialog} from '#/components/dms/AfterReportDialog'
|
||||
import {ReactionsDialog} from '#/components/dms/ReactionsDialog'
|
||||
import {ReportDialog} from '#/components/moderation/ReportDialog'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {usePromptControl} from '#/components/Prompt'
|
||||
import * as Toast from '#/components/Toast'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
|
||||
type MessageDialogsContextType = {
|
||||
openDeleteMessage: (message: ChatBskyConvoDefs.MessageView) => void
|
||||
openReportMessage: (
|
||||
message: ChatBskyConvoDefs.MessageView,
|
||||
senderProfile: bsky.profile.AnyProfileView | undefined,
|
||||
) => void
|
||||
openReactions: (message: ChatBskyConvoDefs.MessageView) => void
|
||||
}
|
||||
|
||||
const Context = createContext<MessageDialogsContextType | null>(null)
|
||||
|
||||
export function useMessageDialogs() {
|
||||
const ctx = useContext(Context)
|
||||
if (!ctx) {
|
||||
throw new Error('useMessageDialogs must be used within a MessageOverlays')
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
export function MessageOverlays({children}: {children: React.ReactNode}) {
|
||||
const {t: l} = useLingui()
|
||||
const queryClient = useQueryClient()
|
||||
const convo = useConvoActive()
|
||||
|
||||
const deleteControl = usePromptControl()
|
||||
const reportControl = usePromptControl()
|
||||
const afterReportControl = usePromptControl()
|
||||
const reactionsControl = useDialogControl()
|
||||
|
||||
const [deleteTarget, setDeleteTarget] =
|
||||
useState<ChatBskyConvoDefs.MessageView | null>(null)
|
||||
const [reportTarget, setReportTarget] = useState<{
|
||||
message: ChatBskyConvoDefs.MessageView
|
||||
senderProfile: bsky.profile.AnyProfileView | undefined
|
||||
} | null>(null)
|
||||
const [afterReportTarget, setAfterReportTarget] =
|
||||
useState<ChatBskyConvoDefs.MessageView | null>(null)
|
||||
const [reactionsTarget, setReactionsTarget] =
|
||||
useState<ChatBskyConvoDefs.MessageView | null>(null)
|
||||
|
||||
const openDeleteMessage = useCallback(
|
||||
(message: ChatBskyConvoDefs.MessageView) => {
|
||||
setDeleteTarget(message)
|
||||
deleteControl.open()
|
||||
},
|
||||
[deleteControl],
|
||||
)
|
||||
|
||||
const openReportMessage = useCallback(
|
||||
(
|
||||
message: ChatBskyConvoDefs.MessageView,
|
||||
senderProfile: bsky.profile.AnyProfileView | undefined,
|
||||
) => {
|
||||
setReportTarget({message, senderProfile})
|
||||
reportControl.open()
|
||||
},
|
||||
[reportControl],
|
||||
)
|
||||
|
||||
const openReactions = useCallback(
|
||||
(message: ChatBskyConvoDefs.MessageView) => {
|
||||
setReactionsTarget(message)
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
// These dialogs are conditionally mounted, so we can't open them in the same
|
||||
// tick that we set their targets - the control refs aren't attached yet. Open
|
||||
// in an effect after the dialog has mounted.
|
||||
useEffect(() => {
|
||||
if (reactionsTarget) {
|
||||
reactionsControl.open()
|
||||
}
|
||||
}, [reactionsTarget, reactionsControl])
|
||||
|
||||
useEffect(() => {
|
||||
if (afterReportTarget) {
|
||||
afterReportControl.open()
|
||||
}
|
||||
}, [afterReportTarget, afterReportControl])
|
||||
|
||||
const onConfirmDelete = useCallback(() => {
|
||||
if (!deleteTarget) return
|
||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
|
||||
convo
|
||||
.deleteMessage(deleteTarget.id)
|
||||
.then(() => Toast.show(l({message: 'Message deleted', context: 'toast'})))
|
||||
.catch(() => Toast.show(l`Failed to delete message`))
|
||||
}, [l, convo, deleteTarget])
|
||||
|
||||
const onAfterReportSubmit = useCallback(() => {
|
||||
if (!reportTarget) return
|
||||
if (reportTarget.senderProfile) {
|
||||
unstableCacheProfileView(queryClient, reportTarget.senderProfile)
|
||||
}
|
||||
setAfterReportTarget(reportTarget.message)
|
||||
}, [queryClient, reportTarget])
|
||||
|
||||
const ctx = useMemo<MessageDialogsContextType>(
|
||||
() => ({openDeleteMessage, openReportMessage, openReactions}),
|
||||
[openDeleteMessage, openReportMessage, openReactions],
|
||||
)
|
||||
|
||||
const reportSubject = reportTarget
|
||||
? ({
|
||||
view: 'message',
|
||||
convoId: convo.convo.view.id,
|
||||
message: reportTarget.message,
|
||||
} as const)
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<Context.Provider value={ctx}>
|
||||
{children}
|
||||
<ReportDialog
|
||||
control={reportControl}
|
||||
subject={reportSubject}
|
||||
onAfterSubmit={onAfterReportSubmit}
|
||||
onClose={() => setReportTarget(null)}
|
||||
/>
|
||||
{afterReportTarget && (
|
||||
<AfterReportDialog
|
||||
control={afterReportControl}
|
||||
currentScreen="conversation"
|
||||
params={{
|
||||
convoId: convo.convo.view.id,
|
||||
did: afterReportTarget.sender.did,
|
||||
}}
|
||||
onClose={() => setAfterReportTarget(null)}
|
||||
/>
|
||||
)}
|
||||
{reactionsTarget && (
|
||||
<ReactionsDialog
|
||||
control={reactionsControl}
|
||||
relatedProfiles={convo.relatedProfiles}
|
||||
message={reactionsTarget}
|
||||
onClose={() => setReactionsTarget(null)}
|
||||
/>
|
||||
)}
|
||||
<Prompt.Basic
|
||||
control={deleteControl}
|
||||
title={l`Delete message`}
|
||||
description={l`Are you sure you want to delete this message? The message will be deleted for you, but not for the other participants.`}
|
||||
confirmButtonCta={l`Delete`}
|
||||
confirmButtonColor="negative"
|
||||
onConfirm={onConfirmDelete}
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
/>
|
||||
</Context.Provider>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import {useRef, useState} from 'react'
|
||||
import {useMemo, useRef, useState} from 'react'
|
||||
import {
|
||||
LayoutAnimation,
|
||||
Pressable,
|
||||
@@ -37,14 +37,12 @@ export function ReactionsDialog({
|
||||
control,
|
||||
relatedProfiles,
|
||||
message,
|
||||
reactions,
|
||||
groupedReactions,
|
||||
onClose,
|
||||
}: {
|
||||
control: Dialog.DialogControlProps
|
||||
relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic>
|
||||
message: ChatBskyConvoDefs.MessageView
|
||||
reactions?: ChatBskyConvoDefs.ReactionView[]
|
||||
groupedReactions?: Reaction[]
|
||||
onClose?: () => void
|
||||
}) {
|
||||
const {t: l} = useLingui()
|
||||
|
||||
@@ -54,6 +52,9 @@ export function ReactionsDialog({
|
||||
|
||||
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,
|
||||
)
|
||||
@@ -78,7 +79,10 @@ export function ReactionsDialog({
|
||||
return (
|
||||
<Dialog.Outer
|
||||
control={control}
|
||||
onClose={() => setSelected('all')}
|
||||
onClose={() => {
|
||||
setSelected('all')
|
||||
onClose?.()
|
||||
}}
|
||||
nativeOptions={{
|
||||
preventExpansion: true,
|
||||
minHeight: screenHeight / 2,
|
||||
@@ -388,3 +392,25 @@ function ReactionTab({
|
||||
</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())
|
||||
}
|
||||
|
||||
@@ -75,9 +75,11 @@ export function ReportDialog(
|
||||
() => (props.subject ? parseReportSubject(props.subject) : undefined),
|
||||
[props.subject],
|
||||
)
|
||||
const propsOnClose = props.onClose
|
||||
const onClose = useCallback(() => {
|
||||
ax.metric('reportDialog:close', {})
|
||||
}, [ax])
|
||||
propsOnClose?.()
|
||||
}, [ax, propsOnClose])
|
||||
return (
|
||||
<Dialog.Outer control={props.control} onClose={onClose}>
|
||||
<Dialog.Handle />
|
||||
|
||||
@@ -88,4 +88,8 @@ export type ReportDialogProps = {
|
||||
* Called if the report was successfully submitted.
|
||||
*/
|
||||
onAfterSubmit?: () => void
|
||||
/**
|
||||
* Called after the dialog finishes closing.
|
||||
*/
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ import {MessageListError} from '#/screens/Messages/components/MessageListError'
|
||||
import {atoms as a, platform, tokens, useTheme, web} from '#/alf'
|
||||
import {DateDivider} from '#/components/dms/DateDivider'
|
||||
import {MessageItem} from '#/components/dms/MessageItem'
|
||||
import {MessageOverlays} from '#/components/dms/MessageOverlays'
|
||||
import {NewMessagesPill} from '#/components/dms/NewMessagesPill'
|
||||
import {SystemMessageGroup} from '#/components/dms/SystemMessageGroup'
|
||||
import {SystemMessageItem} from '#/components/dms/SystemMessageItem'
|
||||
@@ -498,127 +499,133 @@ export function MessagesList({
|
||||
|
||||
return (
|
||||
<InviteLinkDialogProvider convo={convoState.convo}>
|
||||
<KeyboardGestureArea
|
||||
interpolator="ios"
|
||||
// HACKFIX: https://github.com/kirillzyusko/react-native-keyboard-controller/issues/1419
|
||||
offset={Math.round(inputHeightJS)}
|
||||
// slightly too buggy unfortunately, enable when possible
|
||||
// textInputNativeID={textInputId}
|
||||
style={[a.flex_1]}>
|
||||
{/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */}
|
||||
<Animated.View style={[a.flex_1, animatedListStyle]}>
|
||||
<ScrollProvider onScroll={onScroll}>
|
||||
<List
|
||||
ref={flatListRef}
|
||||
data={renderItems}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
disableFullWindowScroll={true}
|
||||
disableVirtualization={true}
|
||||
// The extra two items account for the header and the footer components
|
||||
initialNumToRender={IS_NATIVE ? 32 : 62}
|
||||
maxToRenderPerBatch={IS_WEB ? 32 : 62}
|
||||
keyboardDismissMode="interactive"
|
||||
keyboardShouldPersistTaps="handled"
|
||||
maintainVisibleContentPosition={{minIndexForVisible: 0}}
|
||||
removeClippedSubviews={false}
|
||||
sideBorders={false}
|
||||
onContentSizeChange={onContentSizeChange}
|
||||
onStartReached={onStartReached}
|
||||
onScrollToIndexFailed={onScrollToIndexFailed}
|
||||
showsVerticalScrollIndicator={!IS_ANDROID}
|
||||
scrollEventThrottle={100}
|
||||
ListHeaderComponent={
|
||||
<>
|
||||
<MaybeLoader isLoading={convoState.isFetchingHistory} />
|
||||
{convoState.hasAllHistory ? (
|
||||
convoState.convo?.kind === 'group' ? (
|
||||
<MessagesListGroupInfoPanel convo={convoState.convo} />
|
||||
) : (
|
||||
<MessagesListInfoPanel convo={convoState.convo} />
|
||||
)
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
// native only (prop is not supported on web)
|
||||
renderScrollComponent={renderScrollComponent}
|
||||
contentContainerStyle={{
|
||||
paddingBottom: platform({
|
||||
// ios is slightly larger as the input has no top padding
|
||||
ios: tokens.space.lg,
|
||||
android: tokens.space.md,
|
||||
web: 0, // web uses ListFooterComponent instead for scroll reasons
|
||||
}),
|
||||
}}
|
||||
ListFooterComponent={
|
||||
<View
|
||||
style={web({height: tokens.space.md + inputHeightJS})}
|
||||
onLayout={onFooterLayout}
|
||||
/>
|
||||
}
|
||||
style={[
|
||||
web({
|
||||
scrollbarWidth: 'thin',
|
||||
scrollbarColor: `${t.palette.contrast_100} transparent`,
|
||||
scrollbarGutter: 'stable',
|
||||
}),
|
||||
]}
|
||||
pointerEvents={!hasScrolled ? 'none' : 'auto'}
|
||||
contentInset={{top: transparentHeaderHeight}}
|
||||
scrollIndicatorInsets={{top: transparentHeaderHeight}}
|
||||
/>
|
||||
</ScrollProvider>
|
||||
</Animated.View>
|
||||
<KeyboardStickyView
|
||||
style={[a.absolute, a.bottom_0, a.left_0, a.right_0]}
|
||||
onLayout={onInputLayout}
|
||||
minimumOffset={bottomInset}
|
||||
offset={{
|
||||
closed: platform({
|
||||
ios: tokens.space.lg, // hide bottom padding when closed
|
||||
default: 0,
|
||||
}),
|
||||
opened: 0,
|
||||
}}>
|
||||
{footer ?? (
|
||||
<ConversationFooter
|
||||
convoState={convoState}
|
||||
hasAcceptOverride={hasAcceptOverride}>
|
||||
{({loading}) =>
|
||||
ax.features.enabled(ax.features.DmsNewMessageComposerEnable) ? (
|
||||
<MessageComposer
|
||||
textInputId={textInputId}
|
||||
onSendMessage={(message: string) =>
|
||||
void onSendMessage(message)
|
||||
}
|
||||
hasEmbed={!!embedUri}
|
||||
setEmbed={setEmbed}
|
||||
loading={loading}>
|
||||
<MessageInputEmbed
|
||||
embedUri={embedUri}
|
||||
<MessageOverlays>
|
||||
<KeyboardGestureArea
|
||||
interpolator="ios"
|
||||
// HACKFIX: https://github.com/kirillzyusko/react-native-keyboard-controller/issues/1419
|
||||
offset={Math.round(inputHeightJS)}
|
||||
// slightly too buggy unfortunately, enable when possible
|
||||
// textInputNativeID={textInputId}
|
||||
style={[a.flex_1]}>
|
||||
{/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */}
|
||||
<Animated.View style={[a.flex_1, animatedListStyle]}>
|
||||
<ScrollProvider onScroll={onScroll}>
|
||||
<List
|
||||
ref={flatListRef}
|
||||
data={renderItems}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={keyExtractor}
|
||||
disableFullWindowScroll={true}
|
||||
disableVirtualization={true}
|
||||
// The extra two items account for the header and the footer components
|
||||
initialNumToRender={IS_NATIVE ? 32 : 62}
|
||||
maxToRenderPerBatch={IS_WEB ? 32 : 62}
|
||||
keyboardDismissMode="interactive"
|
||||
keyboardShouldPersistTaps="handled"
|
||||
maintainVisibleContentPosition={{minIndexForVisible: 0}}
|
||||
removeClippedSubviews={false}
|
||||
sideBorders={false}
|
||||
onContentSizeChange={onContentSizeChange}
|
||||
onStartReached={onStartReached}
|
||||
onScrollToIndexFailed={onScrollToIndexFailed}
|
||||
showsVerticalScrollIndicator={!IS_ANDROID}
|
||||
scrollEventThrottle={100}
|
||||
ListHeaderComponent={
|
||||
<>
|
||||
<MaybeLoader isLoading={convoState.isFetchingHistory} />
|
||||
{convoState.hasAllHistory ? (
|
||||
convoState.convo?.kind === 'group' ? (
|
||||
<MessagesListGroupInfoPanel convo={convoState.convo} />
|
||||
) : (
|
||||
<MessagesListInfoPanel convo={convoState.convo} />
|
||||
)
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
// native only (prop is not supported on web)
|
||||
renderScrollComponent={renderScrollComponent}
|
||||
contentContainerStyle={{
|
||||
paddingBottom: platform({
|
||||
// ios is slightly larger as the input has no top padding
|
||||
ios: tokens.space.lg,
|
||||
android: tokens.space.md,
|
||||
web: 0, // web uses ListFooterComponent instead for scroll reasons
|
||||
}),
|
||||
}}
|
||||
ListFooterComponent={
|
||||
<View
|
||||
style={web({height: tokens.space.md + inputHeightJS})}
|
||||
onLayout={onFooterLayout}
|
||||
/>
|
||||
}
|
||||
style={[
|
||||
web({
|
||||
scrollbarWidth: 'thin',
|
||||
scrollbarColor: `${t.palette.contrast_100} transparent`,
|
||||
scrollbarGutter: 'stable',
|
||||
}),
|
||||
]}
|
||||
pointerEvents={!hasScrolled ? 'none' : 'auto'}
|
||||
contentInset={{top: transparentHeaderHeight}}
|
||||
scrollIndicatorInsets={{top: transparentHeaderHeight}}
|
||||
/>
|
||||
</ScrollProvider>
|
||||
</Animated.View>
|
||||
<KeyboardStickyView
|
||||
style={[a.absolute, a.bottom_0, a.left_0, a.right_0]}
|
||||
onLayout={onInputLayout}
|
||||
minimumOffset={bottomInset}
|
||||
offset={{
|
||||
closed: platform({
|
||||
ios: tokens.space.lg, // hide bottom padding when closed
|
||||
default: 0,
|
||||
}),
|
||||
opened: 0,
|
||||
}}>
|
||||
{footer ?? (
|
||||
<ConversationFooter
|
||||
convoState={convoState}
|
||||
hasAcceptOverride={hasAcceptOverride}>
|
||||
{({loading}) =>
|
||||
ax.features.enabled(
|
||||
ax.features.DmsNewMessageComposerEnable,
|
||||
) ? (
|
||||
<MessageComposer
|
||||
textInputId={textInputId}
|
||||
onSendMessage={(message: string) =>
|
||||
void onSendMessage(message)
|
||||
}
|
||||
hasEmbed={!!embedUri}
|
||||
setEmbed={setEmbed}
|
||||
/>
|
||||
</MessageComposer>
|
||||
) : (
|
||||
<MessageInput
|
||||
textInputId={textInputId}
|
||||
onSendMessage={onSendMessage}
|
||||
hasEmbed={!!embedUri}
|
||||
setEmbed={setEmbed}
|
||||
loading={loading}>
|
||||
<MessageInputEmbed
|
||||
embedUri={embedUri}
|
||||
loading={loading}>
|
||||
<MessageInputEmbed
|
||||
embedUri={embedUri}
|
||||
setEmbed={setEmbed}
|
||||
/>
|
||||
</MessageComposer>
|
||||
) : (
|
||||
<MessageInput
|
||||
textInputId={textInputId}
|
||||
onSendMessage={onSendMessage}
|
||||
hasEmbed={!!embedUri}
|
||||
setEmbed={setEmbed}
|
||||
/>
|
||||
</MessageInput>
|
||||
)
|
||||
}
|
||||
</ConversationFooter>
|
||||
)}
|
||||
</KeyboardStickyView>
|
||||
</KeyboardGestureArea>
|
||||
loading={loading}>
|
||||
<MessageInputEmbed
|
||||
embedUri={embedUri}
|
||||
setEmbed={setEmbed}
|
||||
/>
|
||||
</MessageInput>
|
||||
)
|
||||
}
|
||||
</ConversationFooter>
|
||||
)}
|
||||
</KeyboardStickyView>
|
||||
</KeyboardGestureArea>
|
||||
|
||||
{newMessagesPill.show && <NewMessagesPill onPress={scrollToEndOnPress} />}
|
||||
{newMessagesPill.show && (
|
||||
<NewMessagesPill onPress={scrollToEndOnPress} />
|
||||
)}
|
||||
</MessageOverlays>
|
||||
</InviteLinkDialogProvider>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user