Mount message dialogs once at the list level (#10435)

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