Preload conversation open data
Co-authored-by: Scott Nonnenberg <scott@signal.org>
This commit is contained in:
parent
6ea47d9c6b
commit
7db33a6708
14 changed files with 332 additions and 89 deletions
|
@ -2,6 +2,7 @@
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
|
import { noop } from 'lodash';
|
||||||
import type {
|
import type {
|
||||||
ConversationType,
|
ConversationType,
|
||||||
ShowConversationType,
|
ShowConversationType,
|
||||||
|
@ -45,6 +46,7 @@ export function AnnouncementsOnlyGroupBanner({
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
showConversation({ conversationId: admin.id });
|
showConversation({ conversationId: admin.id });
|
||||||
}}
|
}}
|
||||||
|
onMouseDown={noop}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
|
@ -68,6 +68,7 @@ function Wrapper({
|
||||||
shouldRecomputeRowHeights={false}
|
shouldRecomputeRowHeights={false}
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
blockConversation={action('blockConversation')}
|
blockConversation={action('blockConversation')}
|
||||||
|
onPreloadConversation={action('onPreloadConversation')}
|
||||||
onSelectConversation={action('onSelectConversation')}
|
onSelectConversation={action('onSelectConversation')}
|
||||||
onOutgoingAudioCallInConversation={action(
|
onOutgoingAudioCallInConversation={action(
|
||||||
'onOutgoingAudioCallInConversation'
|
'onOutgoingAudioCallInConversation'
|
||||||
|
|
|
@ -197,6 +197,7 @@ export type PropsType = {
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
disabledReason: undefined | ContactCheckboxDisabledReason
|
disabledReason: undefined | ContactCheckboxDisabledReason
|
||||||
) => void;
|
) => void;
|
||||||
|
onPreloadConversation: (conversationId: string, messageId?: string) => void;
|
||||||
onSelectConversation: (conversationId: string, messageId?: string) => void;
|
onSelectConversation: (conversationId: string, messageId?: string) => void;
|
||||||
onOutgoingAudioCallInConversation: (conversationId: string) => void;
|
onOutgoingAudioCallInConversation: (conversationId: string) => void;
|
||||||
onOutgoingVideoCallInConversation: (conversationId: string) => void;
|
onOutgoingVideoCallInConversation: (conversationId: string) => void;
|
||||||
|
@ -220,6 +221,7 @@ export function ConversationList({
|
||||||
blockConversation,
|
blockConversation,
|
||||||
onClickArchiveButton,
|
onClickArchiveButton,
|
||||||
onClickContactCheckbox,
|
onClickContactCheckbox,
|
||||||
|
onPreloadConversation,
|
||||||
onSelectConversation,
|
onSelectConversation,
|
||||||
onOutgoingAudioCallInConversation,
|
onOutgoingAudioCallInConversation,
|
||||||
onOutgoingVideoCallInConversation,
|
onOutgoingVideoCallInConversation,
|
||||||
|
@ -411,6 +413,7 @@ export function ConversationList({
|
||||||
})}
|
})}
|
||||||
key={key}
|
key={key}
|
||||||
badge={getPreferredBadge(badges)}
|
badge={getPreferredBadge(badges)}
|
||||||
|
onMouseDown={onPreloadConversation}
|
||||||
onClick={onSelectConversation}
|
onClick={onSelectConversation}
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
|
@ -527,6 +530,7 @@ export function ConversationList({
|
||||||
onClickContactCheckbox,
|
onClickContactCheckbox,
|
||||||
onOutgoingAudioCallInConversation,
|
onOutgoingAudioCallInConversation,
|
||||||
onOutgoingVideoCallInConversation,
|
onOutgoingVideoCallInConversation,
|
||||||
|
onPreloadConversation,
|
||||||
onSelectConversation,
|
onSelectConversation,
|
||||||
removeConversation,
|
removeConversation,
|
||||||
renderMessageSearchResult,
|
renderMessageSearchResult,
|
||||||
|
|
|
@ -400,6 +400,7 @@ export function ForwardMessagesModal({
|
||||||
showConversation={shouldNeverBeCalled}
|
showConversation={shouldNeverBeCalled}
|
||||||
showUserNotFoundModal={shouldNeverBeCalled}
|
showUserNotFoundModal={shouldNeverBeCalled}
|
||||||
setIsFetchingUUID={shouldNeverBeCalled}
|
setIsFetchingUUID={shouldNeverBeCalled}
|
||||||
|
onPreloadConversation={shouldNeverBeCalled}
|
||||||
onSelectConversation={shouldNeverBeCalled}
|
onSelectConversation={shouldNeverBeCalled}
|
||||||
blockConversation={shouldNeverBeCalled}
|
blockConversation={shouldNeverBeCalled}
|
||||||
removeConversation={shouldNeverBeCalled}
|
removeConversation={shouldNeverBeCalled}
|
||||||
|
|
|
@ -172,6 +172,7 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => {
|
||||||
makeFakeLookupConversationWithoutServiceId(),
|
makeFakeLookupConversationWithoutServiceId(),
|
||||||
showUserNotFoundModal: action('showUserNotFoundModal'),
|
showUserNotFoundModal: action('showUserNotFoundModal'),
|
||||||
setIsFetchingUUID,
|
setIsFetchingUUID,
|
||||||
|
preloadConversation: action('preloadConversation'),
|
||||||
showConversation: action('showConversation'),
|
showConversation: action('showConversation'),
|
||||||
blockConversation: action('blockConversation'),
|
blockConversation: action('blockConversation'),
|
||||||
onOutgoingAudioCallInConversation: action(
|
onOutgoingAudioCallInConversation: action(
|
||||||
|
|
|
@ -139,6 +139,7 @@ export type PropsType = {
|
||||||
showFindByUsername: () => void;
|
showFindByUsername: () => void;
|
||||||
showFindByPhoneNumber: () => void;
|
showFindByPhoneNumber: () => void;
|
||||||
showConversation: ShowConversationType;
|
showConversation: ShowConversationType;
|
||||||
|
preloadConversation: (conversationId: string) => void;
|
||||||
showInbox: () => void;
|
showInbox: () => void;
|
||||||
startComposing: () => void;
|
startComposing: () => void;
|
||||||
startSearch: () => unknown;
|
startSearch: () => unknown;
|
||||||
|
@ -205,6 +206,7 @@ export function LeftPane({
|
||||||
|
|
||||||
openUsernameReservationModal,
|
openUsernameReservationModal,
|
||||||
preferredWidthFromStorage,
|
preferredWidthFromStorage,
|
||||||
|
preloadConversation,
|
||||||
removeConversation,
|
removeConversation,
|
||||||
renderCaptchaDialog,
|
renderCaptchaDialog,
|
||||||
renderCrashReportDialog,
|
renderCrashReportDialog,
|
||||||
|
@ -776,6 +778,7 @@ export function LeftPane({
|
||||||
}
|
}
|
||||||
showConversation={showConversation}
|
showConversation={showConversation}
|
||||||
blockConversation={blockConversation}
|
blockConversation={blockConversation}
|
||||||
|
onPreloadConversation={preloadConversation}
|
||||||
onSelectConversation={onSelectConversation}
|
onSelectConversation={onSelectConversation}
|
||||||
onOutgoingAudioCallInConversation={
|
onOutgoingAudioCallInConversation={
|
||||||
onOutgoingAudioCallInConversation
|
onOutgoingAudioCallInConversation
|
||||||
|
|
|
@ -1220,6 +1220,7 @@ export function EditDistributionListModal({
|
||||||
onClickContactCheckbox={(conversationId: string) => {
|
onClickContactCheckbox={(conversationId: string) => {
|
||||||
toggleSelectedConversation(conversationId);
|
toggleSelectedConversation(conversationId);
|
||||||
}}
|
}}
|
||||||
|
onPreloadConversation={shouldNeverBeCalled}
|
||||||
onSelectConversation={shouldNeverBeCalled}
|
onSelectConversation={shouldNeverBeCalled}
|
||||||
blockConversation={shouldNeverBeCalled}
|
blockConversation={shouldNeverBeCalled}
|
||||||
removeConversation={shouldNeverBeCalled}
|
removeConversation={shouldNeverBeCalled}
|
||||||
|
|
|
@ -50,6 +50,7 @@ type PropsType = {
|
||||||
messageText?: ReactNode;
|
messageText?: ReactNode;
|
||||||
messageTextIsAlwaysFullSize?: boolean;
|
messageTextIsAlwaysFullSize?: boolean;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
|
onMouseDown?: () => void;
|
||||||
shouldShowSpinner?: boolean;
|
shouldShowSpinner?: boolean;
|
||||||
unreadCount?: number;
|
unreadCount?: number;
|
||||||
unreadMentionsCount?: number;
|
unreadMentionsCount?: number;
|
||||||
|
@ -100,6 +101,7 @@ export const BaseConversationListItem: FunctionComponent<PropsType> =
|
||||||
messageText,
|
messageText,
|
||||||
messageTextIsAlwaysFullSize,
|
messageTextIsAlwaysFullSize,
|
||||||
onClick,
|
onClick,
|
||||||
|
onMouseDown,
|
||||||
phoneNumber,
|
phoneNumber,
|
||||||
profileName,
|
profileName,
|
||||||
sharedGroupNames,
|
sharedGroupNames,
|
||||||
|
@ -289,6 +291,7 @@ export const BaseConversationListItem: FunctionComponent<PropsType> =
|
||||||
data-testid={testId}
|
data-testid={testId}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
onMouseDown={onMouseDown}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{contents}
|
{contents}
|
||||||
|
|
|
@ -74,6 +74,7 @@ type PropsHousekeeping = {
|
||||||
buttonAriaLabel?: string;
|
buttonAriaLabel?: string;
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
onClick: (id: string) => void;
|
onClick: (id: string) => void;
|
||||||
|
onMouseDown: (id: string) => void;
|
||||||
theme: ThemeType;
|
theme: ThemeType;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -98,6 +99,7 @@ export const ConversationListItem: FunctionComponent<Props> = React.memo(
|
||||||
markedUnread,
|
markedUnread,
|
||||||
muteExpiresAt,
|
muteExpiresAt,
|
||||||
onClick,
|
onClick,
|
||||||
|
onMouseDown,
|
||||||
phoneNumber,
|
phoneNumber,
|
||||||
profileName,
|
profileName,
|
||||||
removalStage,
|
removalStage,
|
||||||
|
@ -202,6 +204,10 @@ export const ConversationListItem: FunctionComponent<Props> = React.memo(
|
||||||
}
|
}
|
||||||
|
|
||||||
const onClickItem = useCallback(() => onClick(id), [onClick, id]);
|
const onClickItem = useCallback(() => onClick(id), [onClick, id]);
|
||||||
|
const onMouseDownItem = useCallback(
|
||||||
|
() => onMouseDown(id),
|
||||||
|
[onMouseDown, id]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseConversationListItem
|
<BaseConversationListItem
|
||||||
|
@ -223,6 +229,7 @@ export const ConversationListItem: FunctionComponent<Props> = React.memo(
|
||||||
messageText={messageText}
|
messageText={messageText}
|
||||||
messageTextIsAlwaysFullSize
|
messageTextIsAlwaysFullSize
|
||||||
onClick={onClickItem}
|
onClick={onClickItem}
|
||||||
|
onMouseDown={onMouseDownItem}
|
||||||
phoneNumber={phoneNumber}
|
phoneNumber={phoneNumber}
|
||||||
profileName={profileName}
|
profileName={profileName}
|
||||||
sharedGroupNames={sharedGroupNames}
|
sharedGroupNames={sharedGroupNames}
|
||||||
|
|
|
@ -137,6 +137,7 @@ import {
|
||||||
isIncoming,
|
isIncoming,
|
||||||
isStory,
|
isStory,
|
||||||
} from '../state/selectors/message';
|
} from '../state/selectors/message';
|
||||||
|
import { getPreloadedConversationId } from '../state/selectors/conversations';
|
||||||
import {
|
import {
|
||||||
conversationJobQueue,
|
conversationJobQueue,
|
||||||
conversationQueueJobEnum,
|
conversationQueueJobEnum,
|
||||||
|
@ -180,6 +181,7 @@ import {
|
||||||
getConversationToDelete,
|
getConversationToDelete,
|
||||||
getMessageToDelete,
|
getMessageToDelete,
|
||||||
} from '../util/deleteForMe';
|
} from '../util/deleteForMe';
|
||||||
|
import { explodePromise } from '../util/explodePromise';
|
||||||
import { getCallHistorySelector } from '../state/selectors/callHistory';
|
import { getCallHistorySelector } from '../state/selectors/callHistory';
|
||||||
|
|
||||||
/* eslint-disable more/no-then */
|
/* 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()})`;
|
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();
|
const start = Date.now();
|
||||||
|
|
||||||
let resolvePromise: (value?: unknown) => void;
|
const { resolve, promise } = explodePromise<void>();
|
||||||
this.inProgressFetch = new Promise(resolve => {
|
this.inProgressFetch = promise;
|
||||||
resolvePromise = resolve;
|
|
||||||
});
|
|
||||||
|
|
||||||
|
let isFinished = false;
|
||||||
let timeout: NodeJS.Timeout;
|
let timeout: NodeJS.Timeout;
|
||||||
const finish = () => {
|
const finish = () => {
|
||||||
|
strictAssert(!isFinished, 'inProgressFetch.finish called twice');
|
||||||
|
isFinished = true;
|
||||||
|
|
||||||
const duration = Date.now() - start;
|
const duration = Date.now() - start;
|
||||||
if (duration > 500) {
|
if (duration > 500) {
|
||||||
log.warn(`${logId}: in progress fetch took ${duration}ms`);
|
log.warn(`${logId}: in progress fetch took ${duration}ms`);
|
||||||
}
|
}
|
||||||
|
|
||||||
resolvePromise();
|
resolve();
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
|
strictAssert(this.inProgressFetch === promise, `${logId}: conflict`);
|
||||||
this.inProgressFetch = undefined;
|
this.inProgressFetch = undefined;
|
||||||
};
|
};
|
||||||
timeout = setTimeout(() => {
|
timeout = setTimeout(() => {
|
||||||
|
@ -1531,13 +1541,85 @@ export class ConversationModel extends window.Backbone
|
||||||
return finish;
|
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(
|
async loadNewestMessages(
|
||||||
newestMessageId: string | undefined,
|
newestMessageId: string | undefined,
|
||||||
setFocus: boolean | undefined
|
setFocus: boolean | undefined
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const logId = `loadNewestMessages/${this.idForLogging()}`;
|
const logId = `loadNewestMessages/${this.idForLogging()}`;
|
||||||
|
|
||||||
const { messagesReset, setMessageLoadingState } =
|
const { messagesReset, setMessageLoadingState, consumePreloadData } =
|
||||||
window.reduxActions.conversations;
|
window.reduxActions.conversations;
|
||||||
const conversationId = this.id;
|
const conversationId = this.id;
|
||||||
|
|
||||||
|
@ -1545,11 +1627,29 @@ export class ConversationModel extends window.Backbone
|
||||||
conversationId,
|
conversationId,
|
||||||
TimelineMessageLoadingState.DoingInitialLoad
|
TimelineMessageLoadingState.DoingInitialLoad
|
||||||
);
|
);
|
||||||
const finish = this.setInProgressFetch();
|
let finish: undefined | (() => void) = await this.setInProgressFetch();
|
||||||
|
|
||||||
|
const preloadedId = getPreloadedConversationId(
|
||||||
|
window.reduxStore.getState()
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
let scrollToLatestUnread = true;
|
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) {
|
if (newestMessageId) {
|
||||||
const newestInMemoryMessage = await getMessageById(newestMessageId);
|
const newestInMemoryMessage = await getMessageById(newestMessageId);
|
||||||
if (newestInMemoryMessage) {
|
if (newestInMemoryMessage) {
|
||||||
|
@ -1581,7 +1681,11 @@ export class ConversationModel extends window.Backbone
|
||||||
metrics.oldest
|
metrics.oldest
|
||||||
) {
|
) {
|
||||||
log.info(`${logId}: scrolling to oldest ${metrics.oldest.sent_at}`);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1591,7 +1695,9 @@ export class ConversationModel extends window.Backbone
|
||||||
);
|
);
|
||||||
void this.loadAndScroll(metrics.oldestUnseen.id, {
|
void this.loadAndScroll(metrics.oldestUnseen.id, {
|
||||||
disableScroll: !setFocus,
|
disableScroll: !setFocus,
|
||||||
|
onFinish: finish,
|
||||||
});
|
});
|
||||||
|
finish = undefined;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1628,7 +1734,7 @@ export class ConversationModel extends window.Backbone
|
||||||
setMessageLoadingState(conversationId, undefined);
|
setMessageLoadingState(conversationId, undefined);
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
finish();
|
finish?.();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async loadOlderMessages(oldestMessageId: string): Promise<void> {
|
async loadOlderMessages(oldestMessageId: string): Promise<void> {
|
||||||
|
@ -1642,7 +1748,7 @@ export class ConversationModel extends window.Backbone
|
||||||
conversationId,
|
conversationId,
|
||||||
TimelineMessageLoadingState.LoadingOlderMessages
|
TimelineMessageLoadingState.LoadingOlderMessages
|
||||||
);
|
);
|
||||||
const finish = this.setInProgressFetch();
|
const finish = await this.setInProgressFetch();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const message = await getMessageById(oldestMessageId);
|
const message = await getMessageById(oldestMessageId);
|
||||||
|
@ -1699,7 +1805,7 @@ export class ConversationModel extends window.Backbone
|
||||||
conversationId,
|
conversationId,
|
||||||
TimelineMessageLoadingState.LoadingNewerMessages
|
TimelineMessageLoadingState.LoadingNewerMessages
|
||||||
);
|
);
|
||||||
const finish = this.setInProgressFetch();
|
const finish = await this.setInProgressFetch();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const message = await getMessageById(newestMessageId);
|
const message = await getMessageById(newestMessageId);
|
||||||
|
@ -1744,7 +1850,7 @@ export class ConversationModel extends window.Backbone
|
||||||
|
|
||||||
async loadAndScroll(
|
async loadAndScroll(
|
||||||
messageId: string,
|
messageId: string,
|
||||||
options?: { disableScroll?: boolean }
|
options: { disableScroll?: boolean; onFinish?: () => void } = {}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { messagesReset, setMessageLoadingState } =
|
const { messagesReset, setMessageLoadingState } =
|
||||||
window.reduxActions.conversations;
|
window.reduxActions.conversations;
|
||||||
|
@ -1754,7 +1860,10 @@ export class ConversationModel extends window.Backbone
|
||||||
conversationId,
|
conversationId,
|
||||||
TimelineMessageLoadingState.DoingInitialLoad
|
TimelineMessageLoadingState.DoingInitialLoad
|
||||||
);
|
);
|
||||||
const finish = this.setInProgressFetch();
|
let { onFinish: finish } = options;
|
||||||
|
if (!finish) {
|
||||||
|
finish = await this.setInProgressFetch();
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const message = await getMessageById(messageId);
|
const message = await getMessageById(messageId);
|
||||||
|
|
|
@ -3443,11 +3443,9 @@ function getMessageMetricsForConversation(
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
oldest: oldest ? pick(oldest, ['received_at', 'sent_at', 'id']) : undefined,
|
oldest,
|
||||||
newest: newest ? pick(newest, ['received_at', 'sent_at', 'id']) : undefined,
|
newest,
|
||||||
oldestUnseen: oldestUnseen
|
oldestUnseen,
|
||||||
? pick(oldestUnseen, ['received_at', 'sent_at', 'id'])
|
|
||||||
: undefined,
|
|
||||||
totalUnseen,
|
totalUnseen,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -435,6 +435,11 @@ export type ConversationMessageType = ReadonlyDeep<{
|
||||||
scrollToMessageId?: string;
|
scrollToMessageId?: string;
|
||||||
scrollToMessageCounter: number;
|
scrollToMessageCounter: number;
|
||||||
}>;
|
}>;
|
||||||
|
export type ConversationPreloadDataType = ReadonlyDeep<{
|
||||||
|
conversationId: string;
|
||||||
|
messages: ReadonlyArray<ReadonlyMessageAttributesType>;
|
||||||
|
metrics: MessageMetricsType;
|
||||||
|
}>;
|
||||||
|
|
||||||
export type MessagesByConversationType = ReadonlyDeep<{
|
export type MessagesByConversationType = ReadonlyDeep<{
|
||||||
[key: string]: ConversationMessageType | undefined;
|
[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
|
// Note: it's very important that both of these locations are always kept up to date
|
||||||
messagesLookup: MessageLookupType;
|
messagesLookup: MessageLookupType;
|
||||||
messagesByConversation: MessagesByConversationType;
|
messagesByConversation: MessagesByConversationType;
|
||||||
|
|
||||||
|
preloadData?: ConversationPreloadDataType;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
// Helpers
|
// Helpers
|
||||||
|
@ -979,9 +986,20 @@ type ReplaceAvatarsActionType = ReadonlyDeep<{
|
||||||
avatars: ReadonlyArray<AvatarDataType>;
|
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
|
// eslint-disable-next-line local-rules/type-alias-readonlydeep
|
||||||
export type ConversationActionType =
|
export type ConversationActionType =
|
||||||
|
| AddPreloadDataActionType
|
||||||
| CancelVerificationDataByConversationActionType
|
| CancelVerificationDataByConversationActionType
|
||||||
| ClearCancelledVerificationActionType
|
| ClearCancelledVerificationActionType
|
||||||
| ClearGroupCreationErrorActionType
|
| ClearGroupCreationErrorActionType
|
||||||
|
@ -997,6 +1015,7 @@ export type ConversationActionType =
|
||||||
| ComposeDeleteAvatarActionType
|
| ComposeDeleteAvatarActionType
|
||||||
| ComposeReplaceAvatarsActionType
|
| ComposeReplaceAvatarsActionType
|
||||||
| ComposeSaveAvatarActionType
|
| ComposeSaveAvatarActionType
|
||||||
|
| ConsumePreloadDataActionType
|
||||||
| ConversationAddedActionType
|
| ConversationAddedActionType
|
||||||
| ConversationChangedActionType
|
| ConversationChangedActionType
|
||||||
| ConversationRemovedActionType
|
| ConversationRemovedActionType
|
||||||
|
@ -1057,6 +1076,7 @@ export const actions = {
|
||||||
acceptConversation,
|
acceptConversation,
|
||||||
acknowledgeGroupMemberNameCollisions,
|
acknowledgeGroupMemberNameCollisions,
|
||||||
addMembersToGroup,
|
addMembersToGroup,
|
||||||
|
addPreloadData,
|
||||||
approvePendingMembershipFromGroupV2,
|
approvePendingMembershipFromGroupV2,
|
||||||
reportSpam,
|
reportSpam,
|
||||||
blockAndReportSpam,
|
blockAndReportSpam,
|
||||||
|
@ -1076,6 +1096,7 @@ export const actions = {
|
||||||
composeDeleteAvatarFromDisk,
|
composeDeleteAvatarFromDisk,
|
||||||
composeReplaceAvatar,
|
composeReplaceAvatar,
|
||||||
composeSaveAvatarToDisk,
|
composeSaveAvatarToDisk,
|
||||||
|
consumePreloadData,
|
||||||
conversationAdded,
|
conversationAdded,
|
||||||
conversationChanged,
|
conversationChanged,
|
||||||
conversationRemoved,
|
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(
|
function setMessageLoadingState(
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
messageLoadingState: undefined | TimelineMessageLoadingState
|
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(
|
export function reducer(
|
||||||
state: Readonly<ConversationsStateType> = getEmptyState(),
|
state: Readonly<ConversationsStateType> = getEmptyState(),
|
||||||
action: Readonly<
|
action: Readonly<
|
||||||
|
@ -5395,7 +5531,7 @@ export function reducer(
|
||||||
if (!existingConversation) {
|
if (!existingConversation) {
|
||||||
return maybeUpdateSelectedMessageForDetails(
|
return maybeUpdateSelectedMessageForDetails(
|
||||||
{ messageId: id, targetedMessageForDetails: data },
|
{ messageId: id, targetedMessageForDetails: data },
|
||||||
state
|
dropPreloadData(state)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -5404,14 +5540,14 @@ export function reducer(
|
||||||
if (!existingMessage) {
|
if (!existingMessage) {
|
||||||
return maybeUpdateSelectedMessageForDetails(
|
return maybeUpdateSelectedMessageForDetails(
|
||||||
{ messageId: id, targetedMessageForDetails: data },
|
{ messageId: id, targetedMessageForDetails: data },
|
||||||
state
|
dropPreloadData(state)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const conversationAttrs = state.conversationLookup[conversationId];
|
const conversationAttrs = state.conversationLookup[conversationId];
|
||||||
const isGroupStoryReply = isGroup(conversationAttrs) && data.storyId;
|
const isGroupStoryReply = isGroup(conversationAttrs) && data.storyId;
|
||||||
if (isGroupStoryReply) {
|
if (isGroupStoryReply) {
|
||||||
return state;
|
return dropPreloadData(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasNewEdit =
|
const hasNewEdit =
|
||||||
|
@ -5434,6 +5570,7 @@ export function reducer(
|
||||||
},
|
},
|
||||||
state
|
state
|
||||||
),
|
),
|
||||||
|
preloadData: undefined,
|
||||||
messagesByConversation: {
|
messagesByConversation: {
|
||||||
...state.messagesByConversation,
|
...state.messagesByConversation,
|
||||||
[conversationId]: {
|
[conversationId]: {
|
||||||
|
@ -5514,75 +5651,32 @@ export function reducer(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action.type === 'MESSAGES_RESET') {
|
if (action.type === 'MESSAGES_RESET') {
|
||||||
const {
|
return updateMessageLookup(state, action.payload);
|
||||||
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']);
|
|
||||||
}
|
}
|
||||||
|
if (action.type === 'ADD_PRELOAD_DATA') {
|
||||||
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 {
|
return {
|
||||||
...state,
|
...state,
|
||||||
...(state.selectedConversationId === conversationId
|
preloadData: action.payload,
|
||||||
? {
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
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') {
|
if (action.type === 'SET_MESSAGE_LOADING_STATE') {
|
||||||
const { payload } = action;
|
const { payload } = action;
|
||||||
const { conversationId, messageLoadingState } = payload;
|
const { conversationId, messageLoadingState } = payload;
|
||||||
|
@ -5676,7 +5770,7 @@ export function reducer(
|
||||||
if (!existingConversation) {
|
if (!existingConversation) {
|
||||||
return maybeUpdateSelectedMessageForDetails(
|
return maybeUpdateSelectedMessageForDetails(
|
||||||
{ messageId: id, targetedMessageForDetails: undefined },
|
{ messageId: id, targetedMessageForDetails: undefined },
|
||||||
state
|
dropPreloadData(state)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -5725,6 +5819,7 @@ export function reducer(
|
||||||
{ messageId: id, targetedMessageForDetails: undefined },
|
{ messageId: id, targetedMessageForDetails: undefined },
|
||||||
state
|
state
|
||||||
),
|
),
|
||||||
|
preloadData: undefined,
|
||||||
messagesLookup: omit(messagesLookup, id),
|
messagesLookup: omit(messagesLookup, id),
|
||||||
messagesByConversation: {
|
messagesByConversation: {
|
||||||
[conversationId]: {
|
[conversationId]: {
|
||||||
|
@ -5863,7 +5958,7 @@ export function reducer(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return state;
|
return dropPreloadData(state);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -5908,6 +6003,7 @@ export function reducer(
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
|
preloadData: undefined,
|
||||||
messagesLookup: {
|
messagesLookup: {
|
||||||
...messagesLookup,
|
...messagesLookup,
|
||||||
...lookup,
|
...lookup,
|
||||||
|
@ -5978,6 +6074,10 @@ export function reducer(
|
||||||
|
|
||||||
const nextState = {
|
const nextState = {
|
||||||
...state,
|
...state,
|
||||||
|
preloadData:
|
||||||
|
state.preloadData?.conversationId === conversationId
|
||||||
|
? state.preloadData
|
||||||
|
: undefined,
|
||||||
hasContactSpoofingReview: false,
|
hasContactSpoofingReview: false,
|
||||||
selectedConversationId: conversationId,
|
selectedConversationId: conversationId,
|
||||||
targetedMessage: messageId,
|
targetedMessage: messageId,
|
||||||
|
|
|
@ -1335,3 +1335,8 @@ export const getLastEditableMessageId = createSelector(
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const getPreloadedConversationId = createSelector(
|
||||||
|
getConversations,
|
||||||
|
({ preloadData }): string | undefined => preloadData?.conversationId
|
||||||
|
);
|
||||||
|
|
|
@ -20,6 +20,7 @@ import { getCountryDataForLocale } from '../../util/getCountryData';
|
||||||
import { lookupConversationWithoutServiceId } from '../../util/lookupConversationWithoutServiceId';
|
import { lookupConversationWithoutServiceId } from '../../util/lookupConversationWithoutServiceId';
|
||||||
import { missingCaseError } from '../../util/missingCaseError';
|
import { missingCaseError } from '../../util/missingCaseError';
|
||||||
import { isDone as isRegistrationDone } from '../../util/registration';
|
import { isDone as isRegistrationDone } from '../../util/registration';
|
||||||
|
import { drop } from '../../util/drop';
|
||||||
import { useCallingActions } from '../ducks/calling';
|
import { useCallingActions } from '../ducks/calling';
|
||||||
import { useConversationsActions } from '../ducks/conversations';
|
import { useConversationsActions } from '../ducks/conversations';
|
||||||
import { ComposerStep, OneTimeModalState } from '../ducks/conversationsEnums';
|
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({
|
export const SmartLeftPane = memo(function SmartLeftPane({
|
||||||
hasFailedStorySends,
|
hasFailedStorySends,
|
||||||
hasPendingUpdate,
|
hasPendingUpdate,
|
||||||
|
@ -385,6 +392,7 @@ export const SmartLeftPane = memo(function SmartLeftPane({
|
||||||
openUsernameReservationModal={openUsernameReservationModal}
|
openUsernameReservationModal={openUsernameReservationModal}
|
||||||
otherTabsUnreadStats={otherTabsUnreadStats}
|
otherTabsUnreadStats={otherTabsUnreadStats}
|
||||||
preferredWidthFromStorage={preferredWidthFromStorage}
|
preferredWidthFromStorage={preferredWidthFromStorage}
|
||||||
|
preloadConversation={preloadConversation}
|
||||||
removeConversation={removeConversation}
|
removeConversation={removeConversation}
|
||||||
renderCaptchaDialog={renderCaptchaDialog}
|
renderCaptchaDialog={renderCaptchaDialog}
|
||||||
renderCrashReportDialog={renderCrashReportDialog}
|
renderCrashReportDialog={renderCrashReportDialog}
|
||||||
|
|
Loading…
Reference in a new issue