Preload conversation open data

Co-authored-by: Scott Nonnenberg <scott@signal.org>
This commit is contained in:
Fedor Indutny 2024-08-21 18:48:29 -07:00 committed by GitHub
parent 6ea47d9c6b
commit 7db33a6708
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 332 additions and 89 deletions

View file

@ -2,6 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useState } from 'react';
import { noop } from 'lodash';
import type {
ConversationType,
ShowConversationType,
@ -45,6 +46,7 @@ export function AnnouncementsOnlyGroupBanner({
onClick={() => {
showConversation({ conversationId: admin.id });
}}
onMouseDown={noop}
theme={theme}
/>
))}

View file

@ -68,6 +68,7 @@ function Wrapper({
shouldRecomputeRowHeights={false}
i18n={i18n}
blockConversation={action('blockConversation')}
onPreloadConversation={action('onPreloadConversation')}
onSelectConversation={action('onSelectConversation')}
onOutgoingAudioCallInConversation={action(
'onOutgoingAudioCallInConversation'

View file

@ -197,6 +197,7 @@ export type PropsType = {
conversationId: string,
disabledReason: undefined | ContactCheckboxDisabledReason
) => void;
onPreloadConversation: (conversationId: string, messageId?: string) => void;
onSelectConversation: (conversationId: string, messageId?: string) => void;
onOutgoingAudioCallInConversation: (conversationId: string) => void;
onOutgoingVideoCallInConversation: (conversationId: string) => void;
@ -220,6 +221,7 @@ export function ConversationList({
blockConversation,
onClickArchiveButton,
onClickContactCheckbox,
onPreloadConversation,
onSelectConversation,
onOutgoingAudioCallInConversation,
onOutgoingVideoCallInConversation,
@ -411,6 +413,7 @@ export function ConversationList({
})}
key={key}
badge={getPreferredBadge(badges)}
onMouseDown={onPreloadConversation}
onClick={onSelectConversation}
i18n={i18n}
theme={theme}
@ -527,6 +530,7 @@ export function ConversationList({
onClickContactCheckbox,
onOutgoingAudioCallInConversation,
onOutgoingVideoCallInConversation,
onPreloadConversation,
onSelectConversation,
removeConversation,
renderMessageSearchResult,

View file

@ -400,6 +400,7 @@ export function ForwardMessagesModal({
showConversation={shouldNeverBeCalled}
showUserNotFoundModal={shouldNeverBeCalled}
setIsFetchingUUID={shouldNeverBeCalled}
onPreloadConversation={shouldNeverBeCalled}
onSelectConversation={shouldNeverBeCalled}
blockConversation={shouldNeverBeCalled}
removeConversation={shouldNeverBeCalled}

View file

@ -172,6 +172,7 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => {
makeFakeLookupConversationWithoutServiceId(),
showUserNotFoundModal: action('showUserNotFoundModal'),
setIsFetchingUUID,
preloadConversation: action('preloadConversation'),
showConversation: action('showConversation'),
blockConversation: action('blockConversation'),
onOutgoingAudioCallInConversation: action(

View file

@ -139,6 +139,7 @@ export type PropsType = {
showFindByUsername: () => void;
showFindByPhoneNumber: () => void;
showConversation: ShowConversationType;
preloadConversation: (conversationId: string) => void;
showInbox: () => void;
startComposing: () => void;
startSearch: () => unknown;
@ -205,6 +206,7 @@ export function LeftPane({
openUsernameReservationModal,
preferredWidthFromStorage,
preloadConversation,
removeConversation,
renderCaptchaDialog,
renderCrashReportDialog,
@ -776,6 +778,7 @@ export function LeftPane({
}
showConversation={showConversation}
blockConversation={blockConversation}
onPreloadConversation={preloadConversation}
onSelectConversation={onSelectConversation}
onOutgoingAudioCallInConversation={
onOutgoingAudioCallInConversation

View file

@ -1220,6 +1220,7 @@ export function EditDistributionListModal({
onClickContactCheckbox={(conversationId: string) => {
toggleSelectedConversation(conversationId);
}}
onPreloadConversation={shouldNeverBeCalled}
onSelectConversation={shouldNeverBeCalled}
blockConversation={shouldNeverBeCalled}
removeConversation={shouldNeverBeCalled}

View file

@ -50,6 +50,7 @@ type PropsType = {
messageText?: ReactNode;
messageTextIsAlwaysFullSize?: boolean;
onClick?: () => void;
onMouseDown?: () => void;
shouldShowSpinner?: boolean;
unreadCount?: number;
unreadMentionsCount?: number;
@ -100,6 +101,7 @@ export const BaseConversationListItem: FunctionComponent<PropsType> =
messageText,
messageTextIsAlwaysFullSize,
onClick,
onMouseDown,
phoneNumber,
profileName,
sharedGroupNames,
@ -289,6 +291,7 @@ export const BaseConversationListItem: FunctionComponent<PropsType> =
data-testid={testId}
disabled={disabled}
onClick={onClick}
onMouseDown={onMouseDown}
type="button"
>
{contents}

View file

@ -74,6 +74,7 @@ type PropsHousekeeping = {
buttonAriaLabel?: string;
i18n: LocalizerType;
onClick: (id: string) => void;
onMouseDown: (id: string) => void;
theme: ThemeType;
};
@ -98,6 +99,7 @@ export const ConversationListItem: FunctionComponent<Props> = React.memo(
markedUnread,
muteExpiresAt,
onClick,
onMouseDown,
phoneNumber,
profileName,
removalStage,
@ -202,6 +204,10 @@ export const ConversationListItem: FunctionComponent<Props> = React.memo(
}
const onClickItem = useCallback(() => onClick(id), [onClick, id]);
const onMouseDownItem = useCallback(
() => onMouseDown(id),
[onMouseDown, id]
);
return (
<BaseConversationListItem
@ -223,6 +229,7 @@ export const ConversationListItem: FunctionComponent<Props> = React.memo(
messageText={messageText}
messageTextIsAlwaysFullSize
onClick={onClickItem}
onMouseDown={onMouseDownItem}
phoneNumber={phoneNumber}
profileName={profileName}
sharedGroupNames={sharedGroupNames}

View file

@ -137,6 +137,7 @@ import {
isIncoming,
isStory,
} from '../state/selectors/message';
import { getPreloadedConversationId } from '../state/selectors/conversations';
import {
conversationJobQueue,
conversationQueueJobEnum,
@ -180,6 +181,7 @@ import {
getConversationToDelete,
getMessageToDelete,
} from '../util/deleteForMe';
import { explodePromise } from '../util/explodePromise';
import { getCallHistorySelector } from '../state/selectors/callHistory';
/* eslint-disable more/no-then */
@ -1503,24 +1505,32 @@ export class ConversationModel extends window.Backbone
}
}
private setInProgressFetch(): () => unknown {
private async setInProgressFetch(): Promise<() => void> {
const logId = `setInProgressFetch(${this.idForLogging()})`;
while (this.inProgressFetch != null) {
log.warn(`${logId}: blocked, waiting`);
// eslint-disable-next-line no-await-in-loop
await this.inProgressFetch;
}
const start = Date.now();
let resolvePromise: (value?: unknown) => void;
this.inProgressFetch = new Promise(resolve => {
resolvePromise = resolve;
});
const { resolve, promise } = explodePromise<void>();
this.inProgressFetch = promise;
let isFinished = false;
let timeout: NodeJS.Timeout;
const finish = () => {
strictAssert(!isFinished, 'inProgressFetch.finish called twice');
isFinished = true;
const duration = Date.now() - start;
if (duration > 500) {
log.warn(`${logId}: in progress fetch took ${duration}ms`);
}
resolvePromise();
resolve();
clearTimeout(timeout);
strictAssert(this.inProgressFetch === promise, `${logId}: conflict`);
this.inProgressFetch = undefined;
};
timeout = setTimeout(() => {
@ -1531,13 +1541,85 @@ export class ConversationModel extends window.Backbone
return finish;
}
async preloadNewestMessages(): Promise<void> {
const logId = `preloadNewestMessages/${this.idForLogging()}`;
const { addPreloadData } = window.reduxActions.conversations;
// Bail-out of complex paths
if (!this.getAccepted()) {
log.info(`${logId}: not accepted, skipping`);
return;
}
const finish = await this.setInProgressFetch();
log.info(`${logId}: starting`);
try {
let metrics = await getMessageMetricsForConversation({
conversationId: this.id,
includeStoryReplies: !isGroup(this.attributes),
});
let messages: ReadonlyArray<MessageAttributesType>;
if (metrics.oldestUnseen) {
const unseen = await getMessageById(metrics.oldestUnseen.id);
if (!unseen) {
throw new Error(
`preloadNewestMessages: failed to load oldestUnseen ${metrics.oldestUnseen.id}`
);
}
const receivedAt = unseen.received_at;
const sentAt = unseen.sent_at;
const {
older,
newer,
metrics: freshMetrics,
} = await getConversationRangeCenteredOnMessage({
conversationId: this.id,
includeStoryReplies: !isGroup(this.attributes),
limit: MESSAGE_LOAD_CHUNK_SIZE,
messageId: unseen.id,
receivedAt,
sentAt,
storyId: undefined,
});
messages = [...older, unseen, ...newer];
metrics = freshMetrics;
} else {
messages = await getOlderMessagesByConversation({
conversationId: this.id,
includeStoryReplies: !isGroup(this.attributes),
limit: MESSAGE_LOAD_CHUNK_SIZE,
storyId: undefined,
});
}
const cleaned = await this.cleanAttributes(messages);
log.info(
`${logId}: preloaded ${cleaned.length} messages, ` +
`latest timestamp=${cleaned.at(-1)?.sent_at}`
);
addPreloadData({
conversationId: this.id,
messages: cleaned,
metrics,
});
} finally {
finish();
}
}
async loadNewestMessages(
newestMessageId: string | undefined,
setFocus: boolean | undefined
): Promise<void> {
const logId = `loadNewestMessages/${this.idForLogging()}`;
const { messagesReset, setMessageLoadingState } =
const { messagesReset, setMessageLoadingState, consumePreloadData } =
window.reduxActions.conversations;
const conversationId = this.id;
@ -1545,11 +1627,29 @@ export class ConversationModel extends window.Backbone
conversationId,
TimelineMessageLoadingState.DoingInitialLoad
);
const finish = this.setInProgressFetch();
let finish: undefined | (() => void) = await this.setInProgressFetch();
const preloadedId = getPreloadedConversationId(
window.reduxStore.getState()
);
try {
let scrollToLatestUnread = true;
if (
// Arguments provided by onConversationOpened
newestMessageId == null &&
!setFocus &&
// Cache conditions for preloadNewestMessages above (in case they are
// invalidated after loading cache)
this.getAccepted() &&
// Existing preload
preloadedId === conversationId
) {
log.info(`${logId}: preload cache still valid, skipping`);
consumePreloadData(preloadedId);
return;
}
if (newestMessageId) {
const newestInMemoryMessage = await getMessageById(newestMessageId);
if (newestInMemoryMessage) {
@ -1581,7 +1681,11 @@ export class ConversationModel extends window.Backbone
metrics.oldest
) {
log.info(`${logId}: scrolling to oldest ${metrics.oldest.sent_at}`);
void this.loadAndScroll(metrics.oldest.id, { disableScroll: true });
void this.loadAndScroll(metrics.oldest.id, {
disableScroll: true,
onFinish: finish,
});
finish = undefined;
return;
}
@ -1591,7 +1695,9 @@ export class ConversationModel extends window.Backbone
);
void this.loadAndScroll(metrics.oldestUnseen.id, {
disableScroll: !setFocus,
onFinish: finish,
});
finish = undefined;
return;
}
@ -1628,7 +1734,7 @@ export class ConversationModel extends window.Backbone
setMessageLoadingState(conversationId, undefined);
throw error;
} finally {
finish();
finish?.();
}
}
async loadOlderMessages(oldestMessageId: string): Promise<void> {
@ -1642,7 +1748,7 @@ export class ConversationModel extends window.Backbone
conversationId,
TimelineMessageLoadingState.LoadingOlderMessages
);
const finish = this.setInProgressFetch();
const finish = await this.setInProgressFetch();
try {
const message = await getMessageById(oldestMessageId);
@ -1699,7 +1805,7 @@ export class ConversationModel extends window.Backbone
conversationId,
TimelineMessageLoadingState.LoadingNewerMessages
);
const finish = this.setInProgressFetch();
const finish = await this.setInProgressFetch();
try {
const message = await getMessageById(newestMessageId);
@ -1744,7 +1850,7 @@ export class ConversationModel extends window.Backbone
async loadAndScroll(
messageId: string,
options?: { disableScroll?: boolean }
options: { disableScroll?: boolean; onFinish?: () => void } = {}
): Promise<void> {
const { messagesReset, setMessageLoadingState } =
window.reduxActions.conversations;
@ -1754,7 +1860,10 @@ export class ConversationModel extends window.Backbone
conversationId,
TimelineMessageLoadingState.DoingInitialLoad
);
const finish = this.setInProgressFetch();
let { onFinish: finish } = options;
if (!finish) {
finish = await this.setInProgressFetch();
}
try {
const message = await getMessageById(messageId);

View file

@ -3443,11 +3443,9 @@ function getMessageMetricsForConversation(
);
return {
oldest: oldest ? pick(oldest, ['received_at', 'sent_at', 'id']) : undefined,
newest: newest ? pick(newest, ['received_at', 'sent_at', 'id']) : undefined,
oldestUnseen: oldestUnseen
? pick(oldestUnseen, ['received_at', 'sent_at', 'id'])
: undefined,
oldest,
newest,
oldestUnseen,
totalUnseen,
};
}

View file

@ -435,6 +435,11 @@ export type ConversationMessageType = ReadonlyDeep<{
scrollToMessageId?: string;
scrollToMessageCounter: number;
}>;
export type ConversationPreloadDataType = ReadonlyDeep<{
conversationId: string;
messages: ReadonlyArray<ReadonlyMessageAttributesType>;
metrics: MessageMetricsType;
}>;
export type MessagesByConversationType = ReadonlyDeep<{
[key: string]: ConversationMessageType | undefined;
@ -550,6 +555,8 @@ export type ConversationsStateType = ReadonlyDeep<{
// Note: it's very important that both of these locations are always kept up to date
messagesLookup: MessageLookupType;
messagesByConversation: MessagesByConversationType;
preloadData?: ConversationPreloadDataType;
}>;
// Helpers
@ -979,9 +986,20 @@ type ReplaceAvatarsActionType = ReadonlyDeep<{
avatars: ReadonlyArray<AvatarDataType>;
};
}>;
export type AddPreloadDataActionType = ReadonlyDeep<{
type: 'ADD_PRELOAD_DATA';
payload: ConversationPreloadDataType;
}>;
export type ConsumePreloadDataActionType = ReadonlyDeep<{
type: 'CONSUME_PRELOAD_DATA';
payload: {
conversationId: string;
};
}>;
// eslint-disable-next-line local-rules/type-alias-readonlydeep
export type ConversationActionType =
| AddPreloadDataActionType
| CancelVerificationDataByConversationActionType
| ClearCancelledVerificationActionType
| ClearGroupCreationErrorActionType
@ -997,6 +1015,7 @@ export type ConversationActionType =
| ComposeDeleteAvatarActionType
| ComposeReplaceAvatarsActionType
| ComposeSaveAvatarActionType
| ConsumePreloadDataActionType
| ConversationAddedActionType
| ConversationChangedActionType
| ConversationRemovedActionType
@ -1057,6 +1076,7 @@ export const actions = {
acceptConversation,
acknowledgeGroupMemberNameCollisions,
addMembersToGroup,
addPreloadData,
approvePendingMembershipFromGroupV2,
reportSpam,
blockAndReportSpam,
@ -1076,6 +1096,7 @@ export const actions = {
composeDeleteAvatarFromDisk,
composeReplaceAvatar,
composeSaveAvatarToDisk,
consumePreloadData,
conversationAdded,
conversationChanged,
conversationRemoved,
@ -3028,6 +3049,33 @@ function messagesReset({
},
};
}
function addPreloadData(
preloadData: ConversationPreloadDataType
): AddPreloadDataActionType {
const { messages, conversationId } = preloadData;
for (const message of messages) {
strictAssert(
message.conversationId === conversationId,
`addPreloadData(${conversationId}): invalid message conversationId ` +
`${message.conversationId}`
);
}
return {
type: 'ADD_PRELOAD_DATA',
payload: preloadData,
};
}
function consumePreloadData(
conversationId: string
): ConsumePreloadDataActionType {
return {
type: 'CONSUME_PRELOAD_DATA',
payload: {
conversationId,
},
};
}
function setMessageLoadingState(
conversationId: string,
messageLoadingState: undefined | TimelineMessageLoadingState
@ -4795,6 +4843,94 @@ function updateNicknameAndNote(
};
}
function updateMessageLookup(
state: ConversationsStateType,
{
conversationId,
messages,
metrics,
scrollToMessageId,
unboundedFetch,
}: {
conversationId: string;
messages: ReadonlyArray<ReadonlyMessageAttributesType>;
metrics: MessageMetricsType;
scrollToMessageId?: string | undefined;
unboundedFetch: boolean;
}
): ConversationsStateType {
const { messagesByConversation, messagesLookup } = state;
const existingConversation = messagesByConversation[conversationId];
const lookup = fromPairs(messages.map(message => [message.id, message]));
const sorted = orderBy(
values(lookup),
['received_at', 'sent_at'],
['ASC', 'ASC']
);
let { newest, oldest } = metrics;
// If our metrics are a little out of date, we'll fix them up
if (sorted.length > 0) {
const first = sorted[0];
if (first && (!oldest || first.received_at <= oldest.received_at)) {
oldest = pick(first, ['id', 'received_at', 'sent_at']);
}
const last = sorted[sorted.length - 1];
if (
last &&
(!newest || unboundedFetch || last.received_at >= newest.received_at)
) {
newest = pick(last, ['id', 'received_at', 'sent_at']);
}
}
const messageIds = sorted.map(message => message.id);
return {
...state,
preloadData: undefined,
...(state.selectedConversationId === conversationId
? {
targetedMessage: scrollToMessageId,
targetedMessageCounter: state.targetedMessageCounter + 1,
targetedMessageSource: TargetedMessageSource.Reset,
}
: {}),
messagesLookup: {
...messagesLookup,
...lookup,
},
messagesByConversation: {
...messagesByConversation,
[conversationId]: {
messageChangeCounter: 0,
scrollToMessageId,
scrollToMessageCounter: existingConversation
? existingConversation.scrollToMessageCounter + 1
: 0,
messageIds,
metrics: {
...metrics,
newest,
oldest,
},
},
},
};
}
function dropPreloadData(
state: ConversationsStateType
): ConversationsStateType {
if (state.preloadData == null) {
return state;
}
return { ...state, preloadData: undefined };
}
export function reducer(
state: Readonly<ConversationsStateType> = getEmptyState(),
action: Readonly<
@ -5395,7 +5531,7 @@ export function reducer(
if (!existingConversation) {
return maybeUpdateSelectedMessageForDetails(
{ messageId: id, targetedMessageForDetails: data },
state
dropPreloadData(state)
);
}
@ -5404,14 +5540,14 @@ export function reducer(
if (!existingMessage) {
return maybeUpdateSelectedMessageForDetails(
{ messageId: id, targetedMessageForDetails: data },
state
dropPreloadData(state)
);
}
const conversationAttrs = state.conversationLookup[conversationId];
const isGroupStoryReply = isGroup(conversationAttrs) && data.storyId;
if (isGroupStoryReply) {
return state;
return dropPreloadData(state);
}
const hasNewEdit =
@ -5434,6 +5570,7 @@ export function reducer(
},
state
),
preloadData: undefined,
messagesByConversation: {
...state.messagesByConversation,
[conversationId]: {
@ -5514,75 +5651,32 @@ export function reducer(
}
if (action.type === 'MESSAGES_RESET') {
const {
conversationId,
messages,
metrics,
scrollToMessageId,
unboundedFetch,
} = action.payload;
const { messagesByConversation, messagesLookup } = state;
const existingConversation = messagesByConversation[conversationId];
const lookup = fromPairs(messages.map(message => [message.id, message]));
const sorted = orderBy(
values(lookup),
['received_at', 'sent_at'],
['ASC', 'ASC']
);
let { newest, oldest } = metrics;
// If our metrics are a little out of date, we'll fix them up
if (sorted.length > 0) {
const first = sorted[0];
if (first && (!oldest || first.received_at <= oldest.received_at)) {
oldest = pick(first, ['id', 'received_at', 'sent_at']);
}
const last = sorted[sorted.length - 1];
if (
last &&
(!newest || unboundedFetch || last.received_at >= newest.received_at)
) {
newest = pick(last, ['id', 'received_at', 'sent_at']);
}
}
const messageIds = sorted.map(message => message.id);
return updateMessageLookup(state, action.payload);
}
if (action.type === 'ADD_PRELOAD_DATA') {
return {
...state,
...(state.selectedConversationId === conversationId
? {
targetedMessage: scrollToMessageId,
targetedMessageCounter: state.targetedMessageCounter + 1,
targetedMessageSource: TargetedMessageSource.Reset,
}
: {}),
messagesLookup: {
...messagesLookup,
...lookup,
},
messagesByConversation: {
...messagesByConversation,
[conversationId]: {
messageChangeCounter: 0,
scrollToMessageId,
scrollToMessageCounter: existingConversation
? existingConversation.scrollToMessageCounter + 1
: 0,
messageIds,
metrics: {
...metrics,
newest,
oldest,
},
},
},
preloadData: action.payload,
};
}
if (action.type === 'CONSUME_PRELOAD_DATA') {
const { preloadData, selectedConversationId } = state;
const { conversationId } = action.payload;
if (!preloadData) {
return state;
}
if (
preloadData.conversationId !== conversationId ||
selectedConversationId !== conversationId
) {
return dropPreloadData(state);
}
return updateMessageLookup(state, {
...preloadData,
unboundedFetch: true,
});
}
if (action.type === 'SET_MESSAGE_LOADING_STATE') {
const { payload } = action;
const { conversationId, messageLoadingState } = payload;
@ -5676,7 +5770,7 @@ export function reducer(
if (!existingConversation) {
return maybeUpdateSelectedMessageForDetails(
{ messageId: id, targetedMessageForDetails: undefined },
state
dropPreloadData(state)
);
}
@ -5725,6 +5819,7 @@ export function reducer(
{ messageId: id, targetedMessageForDetails: undefined },
state
),
preloadData: undefined,
messagesLookup: omit(messagesLookup, id),
messagesByConversation: {
[conversationId]: {
@ -5863,7 +5958,7 @@ export function reducer(
);
}
return state;
return dropPreloadData(state);
}
}
@ -5908,6 +6003,7 @@ export function reducer(
return {
...state,
preloadData: undefined,
messagesLookup: {
...messagesLookup,
...lookup,
@ -5978,6 +6074,10 @@ export function reducer(
const nextState = {
...state,
preloadData:
state.preloadData?.conversationId === conversationId
? state.preloadData
: undefined,
hasContactSpoofingReview: false,
selectedConversationId: conversationId,
targetedMessage: messageId,

View file

@ -1335,3 +1335,8 @@ export const getLastEditableMessageId = createSelector(
return undefined;
}
);
export const getPreloadedConversationId = createSelector(
getConversations,
({ preloadData }): string | undefined => preloadData?.conversationId
);

View file

@ -20,6 +20,7 @@ import { getCountryDataForLocale } from '../../util/getCountryData';
import { lookupConversationWithoutServiceId } from '../../util/lookupConversationWithoutServiceId';
import { missingCaseError } from '../../util/missingCaseError';
import { isDone as isRegistrationDone } from '../../util/registration';
import { drop } from '../../util/drop';
import { useCallingActions } from '../ducks/calling';
import { useConversationsActions } from '../ducks/conversations';
import { ComposerStep, OneTimeModalState } from '../ducks/conversationsEnums';
@ -256,6 +257,12 @@ const getModeSpecificProps = (
}
};
function preloadConversation(conversationId: string): void {
drop(
window.ConversationController.get(conversationId)?.preloadNewestMessages()
);
}
export const SmartLeftPane = memo(function SmartLeftPane({
hasFailedStorySends,
hasPendingUpdate,
@ -385,6 +392,7 @@ export const SmartLeftPane = memo(function SmartLeftPane({
openUsernameReservationModal={openUsernameReservationModal}
otherTabsUnreadStats={otherTabsUnreadStats}
preferredWidthFromStorage={preferredWidthFromStorage}
preloadConversation={preloadConversation}
removeConversation={removeConversation}
renderCaptchaDialog={renderCaptchaDialog}
renderCrashReportDialog={renderCrashReportDialog}