Revert "When you send a message, scroll it into view"

This reverts commit a3525c16ef.
This commit is contained in:
Fedor Indutny 2021-11-23 14:09:07 +01:00 committed by GitHub
parent 3601279287
commit a52530262f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 438 additions and 387 deletions

View file

@ -68,6 +68,7 @@ const useProps = (overrideProps: Partial<Props> = {}): Props => ({
clearQuotedMessage: action('clearQuotedMessage'), clearQuotedMessage: action('clearQuotedMessage'),
getPreferredBadge: () => undefined, getPreferredBadge: () => undefined,
getQuotedMessage: action('getQuotedMessage'), getQuotedMessage: action('getQuotedMessage'),
scrollToBottom: action('scrollToBottom'),
sortedGroupMembers: [], sortedGroupMembers: [],
// EmojiButton // EmojiButton
onPickEmoji: action('onPickEmoji'), onPickEmoji: action('onPickEmoji'),

View file

@ -123,6 +123,7 @@ export type OwnProps = Readonly<{
setQuotedMessage(message: undefined): unknown; setQuotedMessage(message: undefined): unknown;
shouldSendHighQualityAttachments: boolean; shouldSendHighQualityAttachments: boolean;
startRecording: () => unknown; startRecording: () => unknown;
scrollToBottom: (converstionId: string) => unknown;
theme: ThemeType; theme: ThemeType;
}>; }>;
@ -202,6 +203,7 @@ export const CompositionArea = ({
clearQuotedMessage, clearQuotedMessage,
getPreferredBadge, getPreferredBadge,
getQuotedMessage, getQuotedMessage,
scrollToBottom,
sortedGroupMembers, sortedGroupMembers,
// EmojiButton // EmojiButton
onPickEmoji, onPickEmoji,
@ -628,13 +630,14 @@ export const CompositionArea = ({
{!large ? leftHandSideButtonsFragment : null} {!large ? leftHandSideButtonsFragment : null}
<div className="CompositionArea__input"> <div className="CompositionArea__input">
<CompositionInput <CompositionInput
i18n={i18n}
conversationId={conversationId}
clearQuotedMessage={clearQuotedMessage} clearQuotedMessage={clearQuotedMessage}
disabled={disabled} disabled={disabled}
draftBodyRanges={draftBodyRanges} draftBodyRanges={draftBodyRanges}
draftText={draftText} draftText={draftText}
getPreferredBadge={getPreferredBadge} getPreferredBadge={getPreferredBadge}
getQuotedMessage={getQuotedMessage} getQuotedMessage={getQuotedMessage}
i18n={i18n}
inputApi={inputApiRef} inputApi={inputApiRef}
large={large} large={large}
onDirtyChange={setDirty} onDirtyChange={setDirty}
@ -642,6 +645,7 @@ export const CompositionArea = ({
onPickEmoji={onPickEmoji} onPickEmoji={onPickEmoji}
onSubmit={handleSubmit} onSubmit={handleSubmit}
onTextTooLong={onTextTooLong} onTextTooLong={onTextTooLong}
scrollToBottom={scrollToBottom}
skinTone={skinTone} skinTone={skinTone}
sortedGroupMembers={sortedGroupMembers} sortedGroupMembers={sortedGroupMembers}
theme={theme} theme={theme}

View file

@ -21,6 +21,7 @@ const story = storiesOf('Components/CompositionInput', module);
const useProps = (overrideProps: Partial<Props> = {}): Props => ({ const useProps = (overrideProps: Partial<Props> = {}): Props => ({
i18n, i18n,
conversationId: 'conversation-id',
disabled: boolean('disabled', overrideProps.disabled || false), disabled: boolean('disabled', overrideProps.disabled || false),
onSubmit: action('onSubmit'), onSubmit: action('onSubmit'),
onEditorStateChange: action('onEditorStateChange'), onEditorStateChange: action('onEditorStateChange'),
@ -32,6 +33,7 @@ const useProps = (overrideProps: Partial<Props> = {}): Props => ({
getQuotedMessage: action('getQuotedMessage'), getQuotedMessage: action('getQuotedMessage'),
onPickEmoji: action('onPickEmoji'), onPickEmoji: action('onPickEmoji'),
large: boolean('large', overrideProps.large || false), large: boolean('large', overrideProps.large || false),
scrollToBottom: action('scrollToBottom'),
sortedGroupMembers: overrideProps.sortedGroupMembers || [], sortedGroupMembers: overrideProps.sortedGroupMembers || [],
skinTone: select( skinTone: select(
'skinTone', 'skinTone',

View file

@ -62,6 +62,7 @@ export type InputApi = {
export type Props = { export type Props = {
readonly i18n: LocalizerType; readonly i18n: LocalizerType;
readonly conversationId: string;
readonly disabled?: boolean; readonly disabled?: boolean;
readonly getPreferredBadge: PreferredBadgeSelectorType; readonly getPreferredBadge: PreferredBadgeSelectorType;
readonly large?: boolean; readonly large?: boolean;
@ -87,6 +88,7 @@ export type Props = {
): unknown; ): unknown;
getQuotedMessage(): unknown; getQuotedMessage(): unknown;
clearQuotedMessage(): unknown; clearQuotedMessage(): unknown;
scrollToBottom: (converstionId: string) => unknown;
}; };
const MAX_LENGTH = 64 * 1024; const MAX_LENGTH = 64 * 1024;
@ -95,6 +97,7 @@ const BASE_CLASS_NAME = 'module-composition-input';
export function CompositionInput(props: Props): React.ReactElement { export function CompositionInput(props: Props): React.ReactElement {
const { const {
i18n, i18n,
conversationId,
disabled, disabled,
large, large,
inputApi, inputApi,
@ -107,6 +110,7 @@ export function CompositionInput(props: Props): React.ReactElement {
getPreferredBadge, getPreferredBadge,
getQuotedMessage, getQuotedMessage,
clearQuotedMessage, clearQuotedMessage,
scrollToBottom,
sortedGroupMembers, sortedGroupMembers,
theme, theme,
} = props; } = props;
@ -237,6 +241,7 @@ export function CompositionInput(props: Props): React.ReactElement {
`CompositionInput: Submitting message ${timestamp} with ${mentions.length} mentions` `CompositionInput: Submitting message ${timestamp} with ${mentions.length} mentions`
); );
onSubmit(text, mentions, timestamp); onSubmit(text, mentions, timestamp);
scrollToBottom(conversationId);
}; };
if (inputApi) { if (inputApi) {

View file

@ -44,6 +44,7 @@ const candidateConversations = Array.from(Array(100), () =>
const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({ const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
attachments: overrideProps.attachments, attachments: overrideProps.attachments,
conversationId: 'conversation-id',
candidateConversations, candidateConversations,
doForwardMessage: action('doForwardMessage'), doForwardMessage: action('doForwardMessage'),
getPreferredBadge: () => undefined, getPreferredBadge: () => undefined,

View file

@ -41,6 +41,7 @@ import { useAnimated } from '../hooks/useAnimated';
export type DataPropsType = { export type DataPropsType = {
attachments?: Array<AttachmentDraftType>; attachments?: Array<AttachmentDraftType>;
candidateConversations: ReadonlyArray<ConversationType>; candidateConversations: ReadonlyArray<ConversationType>;
conversationId: string;
doForwardMessage: ( doForwardMessage: (
selectedContacts: Array<string>, selectedContacts: Array<string>,
messageBody?: string, messageBody?: string,
@ -76,6 +77,7 @@ const MAX_FORWARD = 5;
export const ForwardMessageModal: FunctionComponent<PropsType> = ({ export const ForwardMessageModal: FunctionComponent<PropsType> = ({
attachments, attachments,
candidateConversations, candidateConversations,
conversationId,
doForwardMessage, doForwardMessage,
getPreferredBadge, getPreferredBadge,
i18n, i18n,
@ -186,10 +188,10 @@ export const ForwardMessageModal: FunctionComponent<PropsType> = ({
}, [candidateConversations]); }, [candidateConversations]);
const toggleSelectedConversation = useCallback( const toggleSelectedConversation = useCallback(
(conversationId: string) => { (selectedConversationId: string) => {
let removeContact = false; let removeContact = false;
const nextSelectedContacts = selectedContacts.filter(contact => { const nextSelectedContacts = selectedContacts.filter(contact => {
if (contact.id === conversationId) { if (contact.id === selectedConversationId) {
removeContact = true; removeContact = true;
return false; return false;
} }
@ -199,7 +201,7 @@ export const ForwardMessageModal: FunctionComponent<PropsType> = ({
setSelectedContacts(nextSelectedContacts); setSelectedContacts(nextSelectedContacts);
return; return;
} }
const selectedContact = contactLookup.get(conversationId); const selectedContact = contactLookup.get(selectedConversationId);
if (selectedContact) { if (selectedContact) {
if (selectedContact.announcementsOnly && !selectedContact.areWeAdmin) { if (selectedContact.announcementsOnly && !selectedContact.areWeAdmin) {
setCannotMessage(true); setCannotMessage(true);
@ -335,6 +337,7 @@ export const ForwardMessageModal: FunctionComponent<PropsType> = ({
) : null} ) : null}
<div className="module-ForwardMessageModal__text-edit-area"> <div className="module-ForwardMessageModal__text-edit-area">
<CompositionInput <CompositionInput
conversationId={conversationId}
clearQuotedMessage={shouldNeverBeCalled} clearQuotedMessage={shouldNeverBeCalled}
draftText={messageBodyText} draftText={messageBodyText}
getPreferredBadge={getPreferredBadge} getPreferredBadge={getPreferredBadge}
@ -343,6 +346,7 @@ export const ForwardMessageModal: FunctionComponent<PropsType> = ({
inputApi={inputApiRef} inputApi={inputApiRef}
large large
moduleClassName="module-ForwardMessageModal__input" moduleClassName="module-ForwardMessageModal__input"
scrollToBottom={noop}
onEditorStateChange={( onEditorStateChange={(
messageText, messageText,
bodyRanges, bodyRanges,
@ -399,7 +403,7 @@ export const ForwardMessageModal: FunctionComponent<PropsType> = ({
i18n={i18n} i18n={i18n}
onClickArchiveButton={shouldNeverBeCalled} onClickArchiveButton={shouldNeverBeCalled}
onClickContactCheckbox={( onClickContactCheckbox={(
conversationId: string, selectedConversationId: string,
disabledReason: disabledReason:
| undefined | undefined
| ContactCheckboxDisabledReason | ContactCheckboxDisabledReason
@ -408,7 +412,9 @@ export const ForwardMessageModal: FunctionComponent<PropsType> = ({
disabledReason !== disabledReason !==
ContactCheckboxDisabledReason.MaximumContactsSelected ContactCheckboxDisabledReason.MaximumContactsSelected
) { ) {
toggleSelectedConversation(conversationId); toggleSelectedConversation(
selectedConversationId
);
} }
}} }}
onSelectConversation={shouldNeverBeCalled} onSelectConversation={shouldNeverBeCalled}

View file

@ -473,7 +473,10 @@ const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
overrideProps.isLoadingMessages === false overrideProps.isLoadingMessages === false
), ),
items: overrideProps.items || Object.keys(items), items: overrideProps.items || Object.keys(items),
loadCountdownStart: undefined,
messageHeightChangeIndex: undefined,
resetCounter: 0, resetCounter: 0,
scrollToBottomCounter: 0,
scrollToIndex: overrideProps.scrollToIndex, scrollToIndex: overrideProps.scrollToIndex,
scrollToIndexCounter: 0, scrollToIndexCounter: 0,
totalUnread: number('totalUnread', overrideProps.totalUnread || 0), totalUnread: number('totalUnread', overrideProps.totalUnread || 0),
@ -485,6 +488,7 @@ const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
warning: overrideProps.warning, warning: overrideProps.warning,
id: uuid(), id: uuid(),
isNearBottom: false,
renderItem, renderItem,
renderLastSeenIndicator, renderLastSeenIndicator,
renderHeroRow, renderHeroRow,

View file

@ -78,13 +78,14 @@ export type PropsDataType = {
haveNewest: boolean; haveNewest: boolean;
haveOldest: boolean; haveOldest: boolean;
isLoadingMessages: boolean; isLoadingMessages: boolean;
isNearBottom?: boolean; isNearBottom: boolean;
items: ReadonlyArray<string>; items: ReadonlyArray<string>;
loadCountdownStart?: number; loadCountdownStart: number | undefined;
messageHeightChangeIndex?: number; messageHeightChangeIndex: number | undefined;
oldestUnreadIndex?: number; oldestUnreadIndex: number | undefined;
resetCounter: number; resetCounter: number;
scrollToIndex?: number; scrollToBottomCounter: number;
scrollToIndex: number | undefined;
scrollToIndexCounter: number; scrollToIndexCounter: number;
totalUnread: number; totalUnread: number;
}; };
@ -959,7 +960,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
this.scrollDown(false); this.scrollDown(false);
}; };
public scrollDown = (setFocus?: boolean): void => { public scrollDown = (setFocus?: boolean, forceScrollDown?: boolean): void => {
const { const {
haveNewest, haveNewest,
id, id,
@ -976,7 +977,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
const lastId = items[items.length - 1]; const lastId = items[items.length - 1];
const lastSeenIndicatorRow = this.getLastSeenIndicatorRow(); const lastSeenIndicatorRow = this.getLastSeenIndicatorRow();
if (!this.visibleRows) { if (!this.visibleRows || forceScrollDown) {
if (haveNewest) { if (haveNewest) {
this.scrollToBottom(setFocus); this.scrollToBottom(setFocus);
} else if (!isLoadingMessages) { } else if (!isLoadingMessages) {
@ -1033,6 +1034,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
messageHeightChangeIndex, messageHeightChangeIndex,
oldestUnreadIndex, oldestUnreadIndex,
resetCounter, resetCounter,
scrollToBottomCounter,
scrollToIndex, scrollToIndex,
typingContactId, typingContactId,
} = this.props; } = this.props;
@ -1050,6 +1052,10 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
this.resizeHeroRow(); this.resizeHeroRow();
} }
if (scrollToBottomCounter !== prevProps.scrollToBottomCounter) {
this.scrollDown(false, true);
}
// There are a number of situations which can necessitate that we forget about row // There are a number of situations which can necessitate that we forget about row
// heights previously calculated. We reset the minimum number of rows to minimize // heights previously calculated. We reset the minimum number of rows to minimize
// unexpected changes to the scroll position. Those changes happen because // unexpected changes to the scroll position. Those changes happen because

View file

@ -101,7 +101,6 @@ import { getAvatarData } from '../util/getAvatarData';
import { createIdenticon } from '../util/createIdenticon'; import { createIdenticon } from '../util/createIdenticon';
import * as log from '../logging/log'; import * as log from '../logging/log';
import * as Errors from '../types/errors'; import * as Errors from '../types/errors';
import { isMessageUnread } from '../util/isMessageUnread';
/* eslint-disable more/no-then */ /* eslint-disable more/no-then */
window.Whisper = window.Whisper || {}; window.Whisper = window.Whisper || {};
@ -117,13 +116,7 @@ const {
upgradeMessageSchema, upgradeMessageSchema,
writeNewAttachmentData, writeNewAttachmentData,
} = window.Signal.Migrations; } = window.Signal.Migrations;
const { const { addStickerPackReference } = window.Signal.Data;
addStickerPackReference,
getOlderMessagesByConversation,
getMessageMetricsForConversation,
getMessageById,
getNewerMessagesByConversation,
} = window.Signal.Data;
const THREE_HOURS = durations.HOUR * 3; const THREE_HOURS = durations.HOUR * 3;
const FIVE_MINUTES = durations.MINUTE * 5; const FIVE_MINUTES = durations.MINUTE * 5;
@ -131,8 +124,6 @@ const FIVE_MINUTES = durations.MINUTE * 5;
const JOB_REPORTING_THRESHOLD_MS = 25; const JOB_REPORTING_THRESHOLD_MS = 25;
const SEND_REPORTING_THRESHOLD_MS = 25; const SEND_REPORTING_THRESHOLD_MS = 25;
const MESSAGE_LOAD_CHUNK_SIZE = 30;
const ATTRIBUTES_THAT_DONT_INVALIDATE_PROPS_CACHE = new Set([ const ATTRIBUTES_THAT_DONT_INVALIDATE_PROPS_CACHE = new Set([
'profileLastFetchedAt', 'profileLastFetchedAt',
'needsStorageServiceSync', 'needsStorageServiceSync',
@ -172,7 +163,7 @@ export class ConversationModel extends window.Backbone
inProgressFetch?: Promise<unknown>; inProgressFetch?: Promise<unknown>;
newMessageQueue?: typeof window.PQueueType; incomingMessageQueue?: typeof window.PQueueType;
jobQueue?: typeof window.PQueueType; jobQueue?: typeof window.PQueueType;
@ -1312,340 +1303,34 @@ export class ConversationModel extends window.Backbone
this.debouncedUpdateLastMessage!(); this.debouncedUpdateLastMessage!();
} }
addIncomingMessage(message: MessageModel): void { addSingleMessage(message: MessageModel): void {
this.addSingleMessage(message); const { messagesAdded } = window.reduxActions.conversations;
const isNewMessage = true;
messagesAdded(
this.id,
[{ ...message.attributes }],
isNewMessage,
window.isActive()
);
} }
// New messages might arrive while we're in the middle of a bulk fetch from the // For incoming messages, they might arrive while we're in the middle of a bulk fetch
// database. We'll wait until that is done before moving forward. // from the database. We'll wait until that is done to process this newly-arrived
async addSingleMessage( // message.
message: MessageModel, addIncomingMessage(message: MessageModel): void {
{ isJustSent }: { isJustSent: boolean } = { isJustSent: false } if (!this.incomingMessageQueue) {
): Promise<void> { this.incomingMessageQueue = new window.PQueue({
if (!this.newMessageQueue) {
this.newMessageQueue = new window.PQueue({
concurrency: 1, concurrency: 1,
timeout: 1000 * 60 * 2, timeout: 1000 * 60 * 2,
}); });
} }
// We use a queue here to ensure messages are added to the UI in the order received // We use a queue here to ensure messages are added to the UI in the order received
await this.newMessageQueue.add(async () => { this.incomingMessageQueue.add(async () => {
await this.inProgressFetch; await this.inProgressFetch;
this.addSingleMessage(message);
}); });
const { messagesAdded } = window.reduxActions.conversations;
const { conversations } = window.reduxStore.getState();
const { messagesByConversation } = conversations;
const conversationId = this.id;
const existingConversation = messagesByConversation[conversationId];
const newestId = existingConversation?.metrics?.newest?.id;
const messageIds = existingConversation?.messageIds;
const isLatestInMemory =
newestId && messageIds && messageIds[messageIds.length - 1] === newestId;
if (!isJustSent || isLatestInMemory) {
messagesAdded({
conversationId,
messages: [{ ...message.attributes }],
isActive: window.isActive(),
isJustSent,
isNewMessage: true,
});
}
await this.loadNewestMessages(undefined, undefined);
}
setInProgressFetch(): () => unknown {
let resolvePromise: (value?: unknown) => void;
this.inProgressFetch = new Promise(resolve => {
resolvePromise = resolve;
});
const finish = () => {
resolvePromise();
this.inProgressFetch = undefined;
};
return finish;
}
async loadNewestMessages(
newestMessageId: string | undefined,
setFocus: boolean | undefined
): Promise<void> {
const { messagesReset, setMessagesLoading } =
window.reduxActions.conversations;
const conversationId = this.id;
setMessagesLoading(conversationId, true);
const finish = this.setInProgressFetch();
try {
let scrollToLatestUnread = true;
if (newestMessageId) {
const newestInMemoryMessage = await getMessageById(newestMessageId, {
Message: window.Whisper.Message,
});
if (newestInMemoryMessage) {
// If newest in-memory message is unread, scrolling down would mean going to
// the very bottom, not the oldest unread.
if (isMessageUnread(newestInMemoryMessage.attributes)) {
scrollToLatestUnread = false;
}
} else {
log.warn(
`loadNewestMessages: did not find message ${newestMessageId}`
);
}
}
const metrics = await getMessageMetricsForConversation(conversationId);
// If this is a message request that has not yet been accepted, we always show the
// oldest messages, to ensure that the ConversationHero is shown. We don't want to
// scroll directly to the oldest message, because that could scroll the hero off
// the screen.
if (!newestMessageId && !this.getAccepted() && metrics.oldest) {
this.loadAndScroll(metrics.oldest.id, { disableScroll: true });
return;
}
if (scrollToLatestUnread && metrics.oldestUnread) {
this.loadAndScroll(metrics.oldestUnread.id, {
disableScroll: !setFocus,
});
return;
}
const messages = await getOlderMessagesByConversation(conversationId, {
limit: MESSAGE_LOAD_CHUNK_SIZE,
MessageCollection: window.Whisper.MessageCollection,
});
const cleaned: Array<MessageModel> = await this.cleanModels(messages);
const scrollToMessageId =
setFocus && metrics.newest ? metrics.newest.id : undefined;
// Because our `getOlderMessages` fetch above didn't specify a receivedAt, we got
// the most recent N messages in the conversation. If it has a conflict with
// metrics, fetched a bit before, that's likely a race condition. So we tell our
// reducer to trust the message set we just fetched for determining if we have
// the newest message loaded.
const unboundedFetch = true;
messagesReset(
conversationId,
cleaned.map((messageModel: MessageModel) => ({
...messageModel.attributes,
})),
metrics,
scrollToMessageId,
unboundedFetch
);
} catch (error) {
setMessagesLoading(conversationId, false);
throw error;
} finally {
finish();
}
}
async loadOlderMessages(oldestMessageId: string): Promise<void> {
const { messagesAdded, setMessagesLoading, repairOldestMessage } =
window.reduxActions.conversations;
const conversationId = this.id;
setMessagesLoading(conversationId, true);
const finish = this.setInProgressFetch();
try {
const message = await getMessageById(oldestMessageId, {
Message: window.Whisper.Message,
});
if (!message) {
throw new Error(
`loadOlderMessages: failed to load message ${oldestMessageId}`
);
}
const receivedAt = message.get('received_at');
const sentAt = message.get('sent_at');
const models = await getOlderMessagesByConversation(conversationId, {
receivedAt,
sentAt,
messageId: oldestMessageId,
limit: MESSAGE_LOAD_CHUNK_SIZE,
MessageCollection: window.Whisper.MessageCollection,
});
if (models.length < 1) {
log.warn('loadOlderMessages: requested, but loaded no messages');
repairOldestMessage(conversationId);
return;
}
const cleaned = await this.cleanModels(models);
messagesAdded({
conversationId,
messages: cleaned.map((messageModel: MessageModel) => ({
...messageModel.attributes,
})),
isActive: window.isActive(),
isJustSent: false,
isNewMessage: false,
});
} catch (error) {
setMessagesLoading(conversationId, true);
throw error;
} finally {
finish();
}
}
async loadNewerMessages(newestMessageId: string): Promise<void> {
const { messagesAdded, setMessagesLoading, repairNewestMessage } =
window.reduxActions.conversations;
const conversationId = this.id;
setMessagesLoading(conversationId, true);
const finish = this.setInProgressFetch();
try {
const message = await getMessageById(newestMessageId, {
Message: window.Whisper.Message,
});
if (!message) {
throw new Error(
`loadNewerMessages: failed to load message ${newestMessageId}`
);
}
const receivedAt = message.get('received_at');
const sentAt = message.get('sent_at');
const models = await getNewerMessagesByConversation(conversationId, {
receivedAt,
sentAt,
limit: MESSAGE_LOAD_CHUNK_SIZE,
MessageCollection: window.Whisper.MessageCollection,
});
if (models.length < 1) {
log.warn('loadNewerMessages: requested, but loaded no messages');
repairNewestMessage(conversationId);
return;
}
const cleaned = await this.cleanModels(models);
messagesAdded({
conversationId,
messages: cleaned.map((messageModel: MessageModel) => ({
...messageModel.attributes,
})),
isActive: window.isActive(),
isJustSent: false,
isNewMessage: false,
});
} catch (error) {
setMessagesLoading(conversationId, false);
throw error;
} finally {
finish();
}
}
async loadAndScroll(
messageId: string,
options?: { disableScroll?: boolean }
): Promise<void> {
const { messagesReset, setMessagesLoading } =
window.reduxActions.conversations;
const conversationId = this.id;
setMessagesLoading(conversationId, true);
const finish = this.setInProgressFetch();
try {
const message = await getMessageById(messageId, {
Message: window.Whisper.Message,
});
if (!message) {
throw new Error(
`loadMoreAndScroll: failed to load message ${messageId}`
);
}
const receivedAt = message.get('received_at');
const sentAt = message.get('sent_at');
const older = await getOlderMessagesByConversation(conversationId, {
limit: MESSAGE_LOAD_CHUNK_SIZE,
receivedAt,
sentAt,
messageId,
MessageCollection: window.Whisper.MessageCollection,
});
const newer = await getNewerMessagesByConversation(conversationId, {
limit: MESSAGE_LOAD_CHUNK_SIZE,
receivedAt,
sentAt,
MessageCollection: window.Whisper.MessageCollection,
});
const metrics = await getMessageMetricsForConversation(conversationId);
const all = [...older.models, message, ...newer.models];
const cleaned: Array<MessageModel> = await this.cleanModels(all);
const scrollToMessageId =
options && options.disableScroll ? undefined : messageId;
messagesReset(
conversationId,
cleaned.map((messageModel: MessageModel) => ({
...messageModel.attributes,
})),
metrics,
scrollToMessageId
);
} catch (error) {
setMessagesLoading(conversationId, false);
throw error;
} finally {
finish();
}
}
async cleanModels(
collection: MessageModelCollectionType | Array<MessageModel>
): Promise<Array<MessageModel>> {
const result = collection
.filter((message: MessageModel) => Boolean(message.id))
.map((message: MessageModel) =>
window.MessageController.register(message.id, message)
);
const eliminated = collection.length - result.length;
if (eliminated > 0) {
log.warn(`cleanModels: Eliminated ${eliminated} messages without an id`);
}
for (let max = result.length, i = 0; i < max; i += 1) {
const message = result[i];
const { attributes } = message;
const { schemaVersion } = attributes;
if (schemaVersion < Message.VERSION_NEEDED_FOR_DISPLAY) {
// Yep, we really do want to wait for each of these
// eslint-disable-next-line no-await-in-loop
const upgradedMessage = await upgradeMessageSchema(attributes);
message.set(upgradedMessage);
// eslint-disable-next-line no-await-in-loop
await window.Signal.Data.saveMessage(upgradedMessage);
}
}
return result;
} }
format(): ConversationType { format(): ConversationType {
@ -3974,7 +3659,7 @@ export class ConversationModel extends window.Backbone
const enableProfileSharing = Boolean( const enableProfileSharing = Boolean(
mandatoryProfileSharingEnabled && !this.get('profileSharing') mandatoryProfileSharingEnabled && !this.get('profileSharing')
); );
this.addSingleMessage(model, { isJustSent: true }); this.addSingleMessage(model);
const draftProperties = dontClearDraft const draftProperties = dontClearDraft
? {} ? {}

View file

@ -248,6 +248,7 @@ export type ConversationMessageType = {
messageIds: Array<string>; messageIds: Array<string>;
metrics: MessageMetricsType; metrics: MessageMetricsType;
resetCounter: number; resetCounter: number;
scrollToBottomCounter: number;
scrollToMessageId?: string; scrollToMessageId?: string;
scrollToMessageCounter: number; scrollToMessageCounter: number;
}; };
@ -551,10 +552,9 @@ export type MessagesAddedActionType = {
type: 'MESSAGES_ADDED'; type: 'MESSAGES_ADDED';
payload: { payload: {
conversationId: string; conversationId: string;
isActive: boolean;
isJustSent: boolean;
isNewMessage: boolean;
messages: Array<MessageAttributesType>; messages: Array<MessageAttributesType>;
isNewMessage: boolean;
isActive: boolean;
}; };
}; };
@ -611,6 +611,12 @@ export type SetSelectedConversationPanelDepthActionType = {
type: 'SET_SELECTED_CONVERSATION_PANEL_DEPTH'; type: 'SET_SELECTED_CONVERSATION_PANEL_DEPTH';
payload: { panelDepth: number }; payload: { panelDepth: number };
}; };
export type ScrollToBpttomActionType = {
type: 'SCROLL_TO_BOTTOM';
payload: {
conversationId: string;
};
};
export type ScrollToMessageActionType = { export type ScrollToMessageActionType = {
type: 'SCROLL_TO_MESSAGE'; type: 'SCROLL_TO_MESSAGE';
payload: { payload: {
@ -767,6 +773,7 @@ export type ConversationActionType =
| ReplaceAvatarsActionType | ReplaceAvatarsActionType
| ReviewGroupMemberNameCollisionActionType | ReviewGroupMemberNameCollisionActionType
| ReviewMessageRequestNameCollisionActionType | ReviewMessageRequestNameCollisionActionType
| ScrollToBpttomActionType
| ScrollToMessageActionType | ScrollToMessageActionType
| SelectedConversationChangedActionType | SelectedConversationChangedActionType
| SetComposeGroupAvatarActionType | SetComposeGroupAvatarActionType
@ -838,6 +845,7 @@ export const actions = {
reviewMessageRequestNameCollision, reviewMessageRequestNameCollision,
saveAvatarToDisk, saveAvatarToDisk,
saveUsername, saveUsername,
scrollToBottom,
scrollToMessage, scrollToMessage,
selectMessage, selectMessage,
setComposeGroupAvatar, setComposeGroupAvatar,
@ -1548,27 +1556,19 @@ function messageSizeChanged(
}, },
}; };
} }
function messagesAdded({ function messagesAdded(
conversationId, conversationId: string,
isActive, messages: Array<MessageAttributesType>,
isJustSent, isNewMessage: boolean,
isNewMessage, isActive: boolean
messages, ): MessagesAddedActionType {
}: {
conversationId: string;
isActive: boolean;
isJustSent: boolean;
isNewMessage: boolean;
messages: Array<MessageAttributesType>;
}): MessagesAddedActionType {
return { return {
type: 'MESSAGES_ADDED', type: 'MESSAGES_ADDED',
payload: { payload: {
conversationId, conversationId,
isActive,
isJustSent,
isNewMessage,
messages, messages,
isNewMessage,
isActive,
}, },
}; };
} }
@ -1734,6 +1734,15 @@ function closeMaximumGroupSizeModal(): CloseMaximumGroupSizeModalActionType {
function closeRecommendedGroupSizeModal(): CloseRecommendedGroupSizeModalActionType { function closeRecommendedGroupSizeModal(): CloseRecommendedGroupSizeModalActionType {
return { type: 'CLOSE_RECOMMENDED_GROUP_SIZE_MODAL' }; return { type: 'CLOSE_RECOMMENDED_GROUP_SIZE_MODAL' };
} }
function scrollToBottom(conversationId: string): ScrollToBpttomActionType {
return {
type: 'SCROLL_TO_BOTTOM',
payload: {
conversationId,
},
};
}
function scrollToMessage( function scrollToMessage(
conversationId: string, conversationId: string,
messageId: string messageId: string
@ -2648,6 +2657,9 @@ export function reducer(
scrollToMessageCounter: existingConversation scrollToMessageCounter: existingConversation
? existingConversation.scrollToMessageCounter + 1 ? existingConversation.scrollToMessageCounter + 1
: 0, : 0,
scrollToBottomCounter: existingConversation
? existingConversation.scrollToBottomCounter
: 0,
messageIds, messageIds,
metrics: { metrics: {
...metrics, ...metrics,
@ -2727,6 +2739,28 @@ export function reducer(
}, },
}; };
} }
if (action.type === 'SCROLL_TO_BOTTOM') {
const { payload } = action;
const { conversationId } = payload;
const { messagesByConversation } = state;
const existingConversation = messagesByConversation[conversationId];
if (!existingConversation) {
return state;
}
return {
...state,
messagesByConversation: {
...messagesByConversation,
[conversationId]: {
...existingConversation,
scrollToBottomCounter: existingConversation.scrollToBottomCounter + 1,
},
},
};
}
if (action.type === 'SCROLL_TO_MESSAGE') { if (action.type === 'SCROLL_TO_MESSAGE') {
const { payload } = action; const { payload } = action;
const { conversationId, messageId } = payload; const { conversationId, messageId } = payload;
@ -2913,8 +2947,7 @@ export function reducer(
} }
if (action.type === 'MESSAGES_ADDED') { if (action.type === 'MESSAGES_ADDED') {
const { conversationId, isActive, isJustSent, isNewMessage, messages } = const { conversationId, isActive, isNewMessage, messages } = action.payload;
action.payload;
const { messagesByConversation, messagesLookup } = state; const { messagesByConversation, messagesLookup } = state;
const existingConversation = messagesByConversation[conversationId]; const existingConversation = messagesByConversation[conversationId];
@ -2961,12 +2994,6 @@ export function reducer(
// won't add new messages to our message list. // won't add new messages to our message list.
const haveLatest = newest && newest.id === lastMessageId; const haveLatest = newest && newest.id === lastMessageId;
if (!haveLatest) { if (!haveLatest) {
if (isJustSent) {
log.warn(
'reducer/MESSAGES_ADDED: isJustSent is true, but haveLatest is false'
);
}
return state; return state;
} }
} }
@ -3028,7 +3055,7 @@ export function reducer(
isLoadingMessages: false, isLoadingMessages: false,
messageIds, messageIds,
heightChangeMessageIds, heightChangeMessageIds,
scrollToMessageId: isJustSent ? last.id : undefined, scrollToMessageId: undefined,
metrics: { metrics: {
...existingConversation.metrics, ...existingConversation.metrics,
newest, newest,

View file

@ -849,6 +849,7 @@ export function _conversationMessagesSelector(
messageIds, messageIds,
metrics, metrics,
resetCounter, resetCounter,
scrollToBottomCounter,
scrollToMessageId, scrollToMessageId,
scrollToMessageCounter, scrollToMessageCounter,
} = conversation; } = conversation;
@ -887,7 +888,7 @@ export function _conversationMessagesSelector(
isLoadingMessages, isLoadingMessages,
loadCountdownStart, loadCountdownStart,
items, items,
isNearBottom, isNearBottom: isNearBottom || false,
messageHeightChangeIndex: messageHeightChangeIndex:
isNumber(messageHeightChangeIndex) && messageHeightChangeIndex >= 0 isNumber(messageHeightChangeIndex) && messageHeightChangeIndex >= 0
? messageHeightChangeIndex ? messageHeightChangeIndex
@ -897,6 +898,7 @@ export function _conversationMessagesSelector(
? oldestUnreadIndex ? oldestUnreadIndex
: undefined, : undefined,
resetCounter, resetCounter,
scrollToBottomCounter,
scrollToIndex: scrollToIndex:
isNumber(scrollToIndex) && scrollToIndex >= 0 ? scrollToIndex : undefined, isNumber(scrollToIndex) && scrollToIndex >= 0 ? scrollToIndex : undefined,
scrollToIndexCounter: scrollToMessageCounter, scrollToIndexCounter: scrollToMessageCounter,
@ -932,10 +934,16 @@ export const getConversationMessagesSelector = createSelector(
haveNewest: false, haveNewest: false,
haveOldest: false, haveOldest: false,
isLoadingMessages: false, isLoadingMessages: false,
isNearBottom: false,
items: [],
loadCountdownStart: undefined,
messageHeightChangeIndex: undefined,
oldestUnreadIndex: undefined,
resetCounter: 0, resetCounter: 0,
scrollToBottomCounter: 0,
scrollToIndex: undefined,
scrollToIndexCounter: 0, scrollToIndexCounter: 0,
totalUnread: 0, totalUnread: 0,
items: [],
}; };
} }

View file

@ -18,6 +18,7 @@ import type { AttachmentDraftType } from '../../types/Attachment';
export type SmartForwardMessageModalProps = { export type SmartForwardMessageModalProps = {
attachments?: Array<AttachmentDraftType>; attachments?: Array<AttachmentDraftType>;
conversationId: string;
doForwardMessage: ( doForwardMessage: (
selectedContacts: Array<string>, selectedContacts: Array<string>,
messageBody?: string, messageBody?: string,
@ -41,6 +42,7 @@ const mapStateToProps = (
): DataPropsType => { ): DataPropsType => {
const { const {
attachments, attachments,
conversationId,
doForwardMessage, doForwardMessage,
isSticker, isSticker,
messageBody, messageBody,
@ -57,6 +59,7 @@ const mapStateToProps = (
return { return {
attachments, attachments,
candidateConversations, candidateConversations,
conversationId,
doForwardMessage, doForwardMessage,
getPreferredBadge: getPreferredBadgeSelector(state), getPreferredBadge: getPreferredBadgeSelector(state),
i18n: getIntl(state), i18n: getIntl(state),

View file

@ -337,6 +337,7 @@ describe('both/state/ducks/conversations', () => {
totalUnread: 0, totalUnread: 0,
}, },
resetCounter: 0, resetCounter: 0,
scrollToBottomCounter: 0,
scrollToMessageCounter: 0, scrollToMessageCounter: 0,
}; };
} }
@ -839,6 +840,7 @@ describe('both/state/ducks/conversations', () => {
messageIds: [messageId], messageIds: [messageId],
metrics: { totalUnread: 0 }, metrics: { totalUnread: 0 },
resetCounter: 0, resetCounter: 0,
scrollToBottomCounter: 0,
scrollToMessageCounter: 0, scrollToMessageCounter: 0,
}, },
}, },

View file

@ -19,6 +19,7 @@ import { sniffImageMimeType } from '../util/sniffImageMimeType';
import type { ConversationModel } from '../models/conversations'; import type { ConversationModel } from '../models/conversations';
import type { import type {
GroupV2PendingMemberType, GroupV2PendingMemberType,
MessageModelCollectionType,
MessageAttributesType, MessageAttributesType,
ConversationModelCollectionType, ConversationModelCollectionType,
QuotedMessageType, QuotedMessageType,
@ -48,6 +49,7 @@ import {
isOutgoing, isOutgoing,
isTapToView, isTapToView,
} from '../state/selectors/message'; } from '../state/selectors/message';
import { isMessageUnread } from '../util/isMessageUnread';
import { import {
getConversationSelector, getConversationSelector,
getMessagesByConversation, getMessagesByConversation,
@ -136,7 +138,13 @@ const {
upgradeMessageSchema, upgradeMessageSchema,
} = window.Signal.Migrations; } = window.Signal.Migrations;
const { getMessageById, getMessagesBySentAt } = window.Signal.Data; const {
getOlderMessagesByConversation,
getMessageMetricsForConversation,
getMessageById,
getMessagesBySentAt,
getNewerMessagesByConversation,
} = window.Signal.Data;
type MessageActionsType = { type MessageActionsType = {
deleteMessage: (messageId: string) => unknown; deleteMessage: (messageId: string) => unknown;
@ -466,6 +474,107 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
this.scrollToMessage(message.id); this.scrollToMessage(message.id);
}; };
const loadOlderMessages = async (oldestMessageId: string) => {
const { messagesAdded, setMessagesLoading, repairOldestMessage } =
window.reduxActions.conversations;
const conversationId = this.model.id;
setMessagesLoading(conversationId, true);
const finish = this.setInProgressFetch();
try {
const message = await getMessageById(oldestMessageId, {
Message: Whisper.Message,
});
if (!message) {
throw new Error(
`loadOlderMessages: failed to load message ${oldestMessageId}`
);
}
const receivedAt = message.get('received_at');
const sentAt = message.get('sent_at');
const models = await getOlderMessagesByConversation(conversationId, {
receivedAt,
sentAt,
messageId: oldestMessageId,
limit: 30,
MessageCollection: Whisper.MessageCollection,
});
if (models.length < 1) {
log.warn('loadOlderMessages: requested, but loaded no messages');
repairOldestMessage(conversationId);
return;
}
const cleaned = await this.cleanModels(models);
const isNewMessage = false;
messagesAdded(
this.model.id,
cleaned.map((messageModel: MessageModel) => ({
...messageModel.attributes,
})),
isNewMessage,
window.isActive()
);
} catch (error) {
setMessagesLoading(conversationId, true);
throw error;
} finally {
finish();
}
};
const loadNewerMessages = async (newestMessageId: string) => {
const { messagesAdded, setMessagesLoading, repairNewestMessage } =
window.reduxActions.conversations;
const conversationId = this.model.id;
setMessagesLoading(conversationId, true);
const finish = this.setInProgressFetch();
try {
const message = await getMessageById(newestMessageId, {
Message: Whisper.Message,
});
if (!message) {
throw new Error(
`loadNewerMessages: failed to load message ${newestMessageId}`
);
}
const receivedAt = message.get('received_at');
const sentAt = message.get('sent_at');
const models = await getNewerMessagesByConversation(conversationId, {
receivedAt,
sentAt,
limit: 30,
MessageCollection: Whisper.MessageCollection,
});
if (models.length < 1) {
log.warn('loadNewerMessages: requested, but loaded no messages');
repairNewestMessage(conversationId);
return;
}
const cleaned = await this.cleanModels(models);
const isNewMessage = false;
messagesAdded(
conversationId,
cleaned.map((messageModel: MessageModel) => ({
...messageModel.attributes,
})),
isNewMessage,
window.isActive()
);
} catch (error) {
setMessagesLoading(conversationId, false);
throw error;
} finally {
finish();
}
};
const markMessageRead = async (messageId: string) => { const markMessageRead = async (messageId: string) => {
if (!window.isActive()) { if (!window.isActive()) {
return; return;
@ -506,10 +615,10 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
}, },
contactSupport, contactSupport,
learnMoreAboutDeliveryIssue, learnMoreAboutDeliveryIssue,
loadNewerMessages: this.model.loadNewerMessages.bind(this.model), loadNewerMessages,
loadNewestMessages: this.model.loadNewestMessages.bind(this.model), loadNewestMessages: this.loadNewestMessages.bind(this),
loadAndScroll: this.model.loadAndScroll.bind(this.model), loadAndScroll: this.loadAndScroll.bind(this),
loadOlderMessages: this.model.loadOlderMessages.bind(this.model), loadOlderMessages,
markMessageRead, markMessageRead,
onBlock: createMessageRequestResponseHandler( onBlock: createMessageRequestResponseHandler(
'onBlock', 'onBlock',
@ -882,6 +991,38 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
}; };
} }
async cleanModels(
collection: MessageModelCollectionType | Array<MessageModel>
): Promise<Array<MessageModel>> {
const result = collection
.filter((message: MessageModel) => Boolean(message.id))
.map((message: MessageModel) =>
window.MessageController.register(message.id, message)
);
const eliminated = collection.length - result.length;
if (eliminated > 0) {
log.warn(`cleanModels: Eliminated ${eliminated} messages without an id`);
}
for (let max = result.length, i = 0; i < max; i += 1) {
const message = result[i];
const { attributes } = message;
const { schemaVersion } = attributes;
if (schemaVersion < Message.VERSION_NEEDED_FOR_DISPLAY) {
// Yep, we really do want to wait for each of these
// eslint-disable-next-line no-await-in-loop
const upgradedMessage = await upgradeMessageSchema(attributes);
message.set(upgradedMessage);
// eslint-disable-next-line no-await-in-loop
await window.Signal.Data.saveMessage(upgradedMessage);
}
}
return result;
}
async scrollToMessage(messageId: string): Promise<void> { async scrollToMessage(messageId: string): Promise<void> {
const message = await getMessageById(messageId, { const message = await getMessageById(messageId, {
Message: Whisper.Message, Message: Whisper.Message,
@ -912,7 +1053,162 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
return; return;
} }
this.model.loadAndScroll(messageId); this.loadAndScroll(messageId);
}
setInProgressFetch(): () => unknown {
let resolvePromise: (value?: unknown) => void;
this.model.inProgressFetch = new Promise(resolve => {
resolvePromise = resolve;
});
const finish = () => {
resolvePromise();
this.model.inProgressFetch = undefined;
};
return finish;
}
async loadAndScroll(
messageId: string,
options?: { disableScroll?: boolean }
): Promise<void> {
const { messagesReset, setMessagesLoading } =
window.reduxActions.conversations;
const conversationId = this.model.id;
setMessagesLoading(conversationId, true);
const finish = this.setInProgressFetch();
try {
const message = await getMessageById(messageId, {
Message: Whisper.Message,
});
if (!message) {
throw new Error(
`loadMoreAndScroll: failed to load message ${messageId}`
);
}
const receivedAt = message.get('received_at');
const sentAt = message.get('sent_at');
const older = await getOlderMessagesByConversation(conversationId, {
limit: 30,
receivedAt,
sentAt,
messageId,
MessageCollection: Whisper.MessageCollection,
});
const newer = await getNewerMessagesByConversation(conversationId, {
limit: 30,
receivedAt,
sentAt,
MessageCollection: Whisper.MessageCollection,
});
const metrics = await getMessageMetricsForConversation(conversationId);
const all = [...older.models, message, ...newer.models];
const cleaned: Array<MessageModel> = await this.cleanModels(all);
const scrollToMessageId =
options && options.disableScroll ? undefined : messageId;
messagesReset(
conversationId,
cleaned.map((messageModel: MessageModel) => ({
...messageModel.attributes,
})),
metrics,
scrollToMessageId
);
} catch (error) {
setMessagesLoading(conversationId, false);
throw error;
} finally {
finish();
}
}
async loadNewestMessages(
newestMessageId: string | undefined,
setFocus: boolean | undefined
): Promise<void> {
const { messagesReset, setMessagesLoading } =
window.reduxActions.conversations;
const conversationId = this.model.id;
setMessagesLoading(conversationId, true);
const finish = this.setInProgressFetch();
try {
let scrollToLatestUnread = true;
if (newestMessageId) {
const newestInMemoryMessage = await getMessageById(newestMessageId, {
Message: Whisper.Message,
});
if (newestInMemoryMessage) {
// If newest in-memory message is unread, scrolling down would mean going to
// the very bottom, not the oldest unread.
if (isMessageUnread(newestInMemoryMessage.attributes)) {
scrollToLatestUnread = false;
}
} else {
log.warn(
`loadNewestMessages: did not find message ${newestMessageId}`
);
}
}
const metrics = await getMessageMetricsForConversation(conversationId);
// If this is a message request that has not yet been accepted, we always show the
// oldest messages, to ensure that the ConversationHero is shown. We don't want to
// scroll directly to the oldest message, because that could scroll the hero off
// the screen.
if (!newestMessageId && !this.model.getAccepted() && metrics.oldest) {
this.loadAndScroll(metrics.oldest.id, { disableScroll: true });
return;
}
if (scrollToLatestUnread && metrics.oldestUnread) {
this.loadAndScroll(metrics.oldestUnread.id, {
disableScroll: !setFocus,
});
return;
}
const messages = await getOlderMessagesByConversation(conversationId, {
limit: 30,
MessageCollection: Whisper.MessageCollection,
});
const cleaned: Array<MessageModel> = await this.cleanModels(messages);
const scrollToMessageId =
setFocus && metrics.newest ? metrics.newest.id : undefined;
// Because our `getOlderMessages` fetch above didn't specify a receivedAt, we got
// the most recent 30 messages in the conversation. If it has a conflict with
// metrics, fetched a bit before, that's likely a race condition. So we tell our
// reducer to trust the message set we just fetched for determining if we have
// the newest message loaded.
const unboundedFetch = true;
messagesReset(
conversationId,
cleaned.map((messageModel: MessageModel) => ({
...messageModel.attributes,
})),
metrics,
scrollToMessageId,
unboundedFetch
);
} catch (error) {
setMessagesLoading(conversationId, false);
throw error;
} finally {
finish();
}
} }
async startMigrationToGV2(): Promise<void> { async startMigrationToGV2(): Promise<void> {
@ -1206,7 +1502,7 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
}); });
if (message) { if (message) {
this.model.loadAndScroll(messageId); this.loadAndScroll(messageId);
return; return;
} }
@ -1218,7 +1514,7 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
await retryPlaceholders.findByConversationAndMarkOpened(this.model.id); await retryPlaceholders.findByConversationAndMarkOpened(this.model.id);
} }
this.model.loadNewestMessages(undefined, undefined); this.loadNewestMessages(undefined, undefined);
this.model.updateLastMessage(); this.model.updateLastMessage();
this.focusMessageField(); this.focusMessageField();
@ -1286,6 +1582,7 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
window.reduxStore, window.reduxStore,
{ {
attachments: draftAttachments, attachments: draftAttachments,
conversationId: this.model.id,
doForwardMessage: async ( doForwardMessage: async (
conversationIds: Array<string>, conversationIds: Array<string>,
messageBody?: string, messageBody?: string,