ConversationView in React

This commit is contained in:
Josh Perez 2021-10-05 12:47:06 -04:00 committed by GitHub
parent dddb3129cc
commit 5fdfa1c632
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 703 additions and 786 deletions

View file

@ -51,13 +51,15 @@ import { Quote, Props as QuoteProps } from './conversation/Quote';
import { StagedLinkPreview } from './conversation/StagedLinkPreview';
import { countStickers } from './stickers/lib';
export type CompositionAPIType = {
focusInput: () => void;
isDirty: () => boolean;
setDisabled: (disabled: boolean) => void;
reset: InputApi['reset'];
resetEmojiResults: InputApi['resetEmojiResults'];
};
export type CompositionAPIType =
| {
focusInput: () => void;
isDirty: () => boolean;
setDisabled: (disabled: boolean) => void;
reset: InputApi['reset'];
resetEmojiResults: InputApi['resetEmojiResults'];
}
| undefined;
export type OwnProps = Readonly<{
acceptedMessageRequest?: boolean;
@ -96,7 +98,7 @@ export type OwnProps = Readonly<{
linkPreviewResult?: LinkPreviewWithDomain;
messageRequestsEnabled?: boolean;
onClearAttachments(): unknown;
onClickAttachment(): unknown;
onClickAttachment(att: AttachmentType): unknown;
onClickQuotedMessage(): unknown;
onCloseLinkPreview(): unknown;
processAttachments: (options: HandleAttachmentsProcessingArgsType) => unknown;
@ -325,7 +327,7 @@ export const CompositionArea = ({
setLarge(l => !l);
}, [setLarge]);
const shouldShowMicrophone = !draftAttachments.length && !draftText;
const shouldShowMicrophone = !large && !draftAttachments.length && !draftText;
const showMediaQualitySelector = draftAttachments.some(isImageAttachment);
@ -373,14 +375,12 @@ export const CompositionArea = ({
const attButton = (
<div className="CompositionArea__button-cell">
<div className="choose-file">
<button
type="button"
className="paperclip thumbnail"
onClick={launchAttachmentPicker}
aria-label={i18n('CompositionArea--attach-file')}
/>
</div>
<button
type="button"
className="CompositionArea__attach-file"
onClick={launchAttachmentPicker}
aria-label={i18n('CompositionArea--attach-file')}
/>
</div>
);

View file

@ -0,0 +1,34 @@
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
export type PropsType = {
renderCompositionArea: () => JSX.Element;
renderConversationHeader: () => JSX.Element;
renderTimeline: () => JSX.Element;
};
export const ConversationView = ({
renderCompositionArea,
renderConversationHeader,
renderTimeline,
}: PropsType): JSX.Element => {
return (
<div className="ConversationView">
<div className="ConversationView__header">
{renderConversationHeader()}
</div>
<div className="ConversationView__pane main panel">
<div className="ConversationView__timeline--container">
<div aria-live="polite" className="ConversationView__timeline">
{renderTimeline()}
</div>
</div>
<div className="ConversationView__composition-area">
{renderCompositionArea()}
</div>
</div>
</div>
);
};

View file

@ -157,7 +157,7 @@ export type PropsActionsType = {
clearSelectedMessage: () => unknown;
unblurAvatar: () => void;
updateSharedGroups: () => unknown;
} & MessageActionsType &
} & Omit<MessageActionsType, 'onHeightChange'> &
SafetyNumberActionsType &
UnsupportedMessageActionsType &
ChatSessionRefreshedNotificationActionsType;
@ -251,7 +251,6 @@ const getActions = createSelector(
'updateSharedGroups',
'doubleCheckMissingQuoteReference',
'onHeightChange',
'checkForAccount',
'reactToMessage',
'replyToMessage',

View file

@ -1,24 +0,0 @@
// Copyright 2019-2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { Provider } from 'react-redux';
import { Store } from 'redux';
import { SmartCompositionArea } from '../smart/CompositionArea';
// Workaround: A react component's required properties are filtering up through connect()
// https://github.com/DefinitelyTyped/DefinitelyTyped/issues/31363
/* eslint-disable @typescript-eslint/no-explicit-any */
const FilteredCompositionArea = SmartCompositionArea as any;
/* eslint-enable @typescript-eslint/no-explicit-any */
export const createCompositionArea = (
store: Store,
props: Record<string, unknown>
): React.ReactElement => (
<Provider store={store}>
<FilteredCompositionArea {...props} />
</Provider>
);

View file

@ -1,17 +0,0 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { Store } from 'redux';
import { Provider } from 'react-redux';
import { SmartConversationHeader, OwnProps } from '../smart/ConversationHeader';
export const createConversationHeader = (
store: Store,
props: OwnProps
): React.ReactElement => (
<Provider store={store}>
<SmartConversationHeader {...props} />
</Provider>
);

View file

@ -1,4 +1,4 @@
// Copyright 2019-2020 Signal Messenger, LLC
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
@ -6,19 +6,19 @@ import { Provider } from 'react-redux';
import { Store } from 'redux';
import { SmartTimeline } from '../smart/Timeline';
import { SmartConversationView, PropsType } from '../smart/ConversationView';
// Workaround: A react component's required properties are filtering up through connect()
// https://github.com/DefinitelyTyped/DefinitelyTyped/issues/31363
/* eslint-disable @typescript-eslint/no-explicit-any */
const FilteredTimeline = SmartTimeline as any;
const FilteredConversationView = SmartConversationView as any;
/* eslint-disable @typescript-eslint/no-explicit-any */
export const createTimeline = (
export const createConversationView = (
store: Store,
props: Record<string, unknown>
props: PropsType
): React.ReactElement => (
<Provider store={store}>
<FilteredTimeline {...props} />
<FilteredConversationView {...props} />
</Provider>
);

View file

@ -4,7 +4,10 @@
import { connect } from 'react-redux';
import { get } from 'lodash';
import { mapDispatchToProps } from '../actions';
import { CompositionArea } from '../../components/CompositionArea';
import {
CompositionArea,
Props as ComponentPropsType,
} from '../../components/CompositionArea';
import { StateType } from '../reducer';
import { isConversationSMSOnly } from '../../util/isConversationSMSOnly';
import { dropNull } from '../../util/dropNull';
@ -29,11 +32,13 @@ import {
type ExternalProps = {
id: string;
onClickQuotedMessage: (id: string) => unknown;
handleClickQuotedMessage: (id: string) => unknown;
};
export type CompositionAreaPropsType = ExternalProps & ComponentPropsType;
const mapStateToProps = (state: StateType, props: ExternalProps) => {
const { id, onClickQuotedMessage } = props;
const { id, handleClickQuotedMessage } = props;
const conversationSelector = getConversationSelector(state);
const conversation = conversationSelector(id);
@ -101,7 +106,7 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
onClickQuotedMessage: () => {
const messageId = quotedMessage?.quote?.messageId;
if (messageId) {
onClickQuotedMessage(messageId);
handleClickQuotedMessage(messageId);
}
},
// Emojis

View file

@ -26,8 +26,11 @@ import { isConversationSMSOnly } from '../../util/isConversationSMSOnly';
export type OwnProps = {
id: string;
onArchive: () => void;
onDeleteMessages: () => void;
onGoBack: () => void;
onMarkUnread: () => void;
onMoveToInbox: () => void;
onOutgoingAudioCallInConversation: () => void;
onOutgoingVideoCallInConversation: () => void;
onResetSession: () => void;
@ -38,13 +41,9 @@ export type OwnProps = {
onShowAllMedia: () => void;
onShowChatColorEditor: () => void;
onShowContactModal: (contactId: string) => void;
onShowGroupMembers: () => void;
onArchive: () => void;
onMarkUnread: () => void;
onMoveToInbox: () => void;
onShowSafetyNumber: () => void;
onShowConversationDetails: () => void;
onShowGroupMembers: () => void;
onShowSafetyNumber: () => void;
};
const getOutgoingCallButtonStyle = (

View file

@ -0,0 +1,69 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { connect } from 'react-redux';
import { mapDispatchToProps } from '../actions';
import { ConversationView } from '../../components/conversation/ConversationView';
import { StateType } from '../reducer';
import {
SmartCompositionArea,
CompositionAreaPropsType,
} from './CompositionArea';
import {
SmartConversationHeader,
OwnProps as ConversationHeaderPropsType,
} from './ConversationHeader';
import { SmartTimeline, TimelinePropsType } from './Timeline';
export type PropsType = {
compositionAreaProps: Pick<
CompositionAreaPropsType,
| 'clearQuotedMessage'
| 'compositionApi'
| 'getQuotedMessage'
| 'handleClickQuotedMessage'
| 'id'
| 'onAccept'
| 'onBlock'
| 'onBlockAndReportSpam'
| 'onCancelJoinRequest'
| 'onClearAttachments'
| 'onClickAddPack'
| 'onClickAttachment'
| 'onCloseLinkPreview'
| 'onDelete'
| 'onEditorStateChange'
| 'onPickSticker'
| 'onSelectMediaQuality'
| 'onSendMessage'
| 'onStartGroupMigration'
| 'onTextTooLong'
| 'onUnblock'
| 'openConversation'
>;
conversationHeaderProps: ConversationHeaderPropsType;
timelineProps: TimelinePropsType;
};
const mapStateToProps = (_state: StateType, props: PropsType) => {
const {
compositionAreaProps,
conversationHeaderProps,
timelineProps,
} = props;
return {
renderCompositionArea: () => (
<SmartCompositionArea {...compositionAreaProps} />
),
renderConversationHeader: () => (
<SmartConversationHeader {...conversationHeaderProps} />
),
renderTimeline: () => <SmartTimeline {...timelineProps} />,
};
};
const smart = connect(mapStateToProps, mapDispatchToProps);
export const SmartConversationView = smart(ConversationView);

View file

@ -12,6 +12,7 @@ import {
ContactSpoofingReviewPropType,
Timeline,
WarningType as TimelineWarningType,
PropsType as ComponentPropsType,
} from '../../components/conversation/Timeline';
import { StateType } from '../reducer';
import { ConversationType } from '../ducks/conversations';
@ -53,6 +54,48 @@ type ExternalProps = {
// are provided by ConversationView in setupTimeline().
};
export type TimelinePropsType = ExternalProps &
Pick<
ComponentPropsType,
| 'acknowledgeGroupMemberNameCollisions'
| 'contactSupport'
| 'deleteMessage'
| 'deleteMessageForEveryone'
| 'displayTapToViewMessage'
| 'downloadAttachment'
| 'downloadNewVersion'
| 'kickOffAttachmentDownload'
| 'learnMoreAboutDeliveryIssue'
| 'loadAndScroll'
| 'loadNewerMessages'
| 'loadNewestMessages'
| 'loadOlderMessages'
| 'markAttachmentAsCorrupted'
| 'markMessageRead'
| 'markViewed'
| 'onBlock'
| 'onBlockAndReportSpam'
| 'onDelete'
| 'onUnblock'
| 'openConversation'
| 'openLink'
| 'reactToMessage'
| 'removeMember'
| 'replyToMessage'
| 'retrySend'
| 'scrollToQuotedMessage'
| 'showContactDetail'
| 'showContactModal'
| 'showExpiredIncomingTapToViewToast'
| 'showExpiredOutgoingTapToViewToast'
| 'showForwardMessageModal'
| 'showIdentity'
| 'showMessageDetail'
| 'showVisualAttachment'
| 'unblurAvatar'
| 'updateSharedGroups'
>;
const createBoundOnHeightChange = memoizee(
(
onHeightChange: (messageId: string) => unknown,

View file

@ -13544,20 +13544,6 @@
"reasonCategory": "usageTrusted",
"updated": "2021-09-15T21:07:50.995Z"
},
{
"rule": "jQuery-$(",
"path": "ts/views/inbox_view.js",
"line": " if (e && this.$(e.target).closest('.capture-audio').length > 0) {",
"reasonCategory": "usageTrusted",
"updated": "2021-09-15T21:07:50.995Z"
},
{
"rule": "jQuery-$(",
"path": "ts/views/inbox_view.js",
"line": " this.$('.conversation:first .recorder').trigger('close');",
"reasonCategory": "usageTrusted",
"updated": "2021-09-15T21:07:50.995Z"
},
{
"rule": "jQuery-append(",
"path": "ts/views/inbox_view.js",
@ -13642,20 +13628,6 @@
"reasonCategory": "usageTrusted",
"updated": "2021-09-15T21:07:50.995Z"
},
{
"rule": "jQuery-$(",
"path": "ts/views/inbox_view.ts",
"line": " if (e && this.$(e.target).closest('.capture-audio').length > 0) {",
"reasonCategory": "usageTrusted",
"updated": "2021-09-15T21:07:50.995Z"
},
{
"rule": "jQuery-$(",
"path": "ts/views/inbox_view.ts",
"line": " this.$('.conversation:first .recorder').trigger('close');",
"reasonCategory": "usageTrusted",
"updated": "2021-09-15T21:07:50.995Z"
},
{
"rule": "jQuery-append(",
"path": "ts/views/inbox_view.ts",
@ -14328,4 +14300,4 @@
"reasonCategory": "usageTrusted",
"updated": "2021-09-17T21:02:59.414Z"
}
]
]

View file

@ -69,6 +69,7 @@ import * as VisualAttachment from '../types/VisualAttachment';
import * as log from '../logging/log';
import type { AnyViewClass, BasicReactWrapperViewClass } from '../window.d';
import type { EmbeddedContactType } from '../types/EmbeddedContact';
import { createConversationView } from '../state/roots/createConversationView';
import { AttachmentToastType } from '../types/AttachmentToastType';
import { CompositionAPIType } from '../components/CompositionArea';
import { ReadStatus } from '../messages/MessageReadStatus';
@ -225,7 +226,7 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
// Composing messages
private compositionApi: {
current?: CompositionAPIType;
current: CompositionAPIType;
} = { current: undefined };
private sendStart?: number;
@ -242,14 +243,12 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
// Sub-views
private captionEditorView?: Backbone.View;
private compositionAreaView?: Backbone.View;
private contactModalView?: Backbone.View;
private conversationView?: BasicReactWrapperViewClass;
private forwardMessageModal?: Backbone.View;
private lightboxView?: BasicReactWrapperViewClass;
private migrationDialog?: Backbone.View;
private stickerPreviewModalView?: Backbone.View;
private timelineView?: Backbone.View;
private titleView?: Backbone.View;
// Panel support
private panels: Array<AnyViewClass> = [];
@ -314,17 +313,13 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
this.render();
this.setupHeader();
this.setupTimeline();
this.setupCompositionArea();
this.setupConversationView();
this.updateAttachmentsView();
}
// eslint-disable-next-line class-methods-use-this
events(): Record<string, string> {
return {
'change input.file-input': 'onChoseAttachment',
drop: 'onDrop',
paste: 'onPaste',
};
@ -382,407 +377,116 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
}
}
setupHeader(): void {
this.titleView = new Whisper.ReactWrapperView({
className: 'title-wrapper',
JSX: window.Signal.State.Roots.createConversationHeader(
window.reduxStore,
{
id: this.model.id,
onShowContactModal: this.showContactModal.bind(this),
onSetDisappearingMessages: (seconds: number) =>
this.setDisappearingMessages(seconds),
onDeleteMessages: () => this.destroyMessages(),
onResetSession: () => this.endSession(),
onSearchInConversation: () => {
const { searchInConversation } = window.reduxActions.search;
const name = isMe(this.model.attributes)
? window.i18n('noteToSelf')
: this.model.getTitle();
searchInConversation(this.model.id, name);
},
onSetMuteNotifications: this.setMuteExpiration.bind(this),
onSetPin: this.setPin.bind(this),
// These are view only and don't update the Conversation model, so they
// need a manual update call.
onOutgoingAudioCallInConversation: async () => {
log.info(
'onOutgoingAudioCallInConversation: about to start an audio call'
);
const isVideoCall = false;
if (await this.isCallSafe()) {
log.info(
'onOutgoingAudioCallInConversation: call is deemed "safe". Making call'
);
await window.Signal.Services.calling.startCallingLobby(
this.model.id,
isVideoCall
);
log.info('onOutgoingAudioCallInConversation: started the call');
} else {
log.info(
'onOutgoingAudioCallInConversation: call is deemed "unsafe". Stopping'
);
}
},
onOutgoingVideoCallInConversation: async () => {
log.info(
'onOutgoingVideoCallInConversation: about to start a video call'
);
const isVideoCall = true;
if (
this.model.get('announcementsOnly') &&
!this.model.areWeAdmin()
) {
showToast(ToastCannotStartGroupCall);
return;
}
if (await this.isCallSafe()) {
log.info(
'onOutgoingVideoCallInConversation: call is deemed "safe". Making call'
);
await window.Signal.Services.calling.startCallingLobby(
this.model.id,
isVideoCall
);
log.info('onOutgoingVideoCallInConversation: started the call');
} else {
log.info(
'onOutgoingVideoCallInConversation: call is deemed "unsafe". Stopping'
);
}
},
onShowChatColorEditor: () => {
this.showChatColorEditor();
},
onShowConversationDetails: () => {
this.showConversationDetails();
},
onShowSafetyNumber: () => {
this.showSafetyNumber();
},
onShowAllMedia: () => {
this.showAllMedia();
},
onShowGroupMembers: () => {
this.showGV1Members();
},
onGoBack: () => {
this.resetPanel();
},
onArchive: () => {
this.model.setArchived(true);
this.model.trigger('unload', 'archive');
showToast(ToastConversationArchived);
},
onMarkUnread: () => {
this.model.setMarkedUnread(true);
showToast(ToastConversationMarkedUnread);
},
onMoveToInbox: () => {
this.model.setArchived(false);
showToast(ToastConversationUnarchived);
},
}
),
});
this.$('.conversation-header').append(this.titleView.el);
window.reduxActions.conversations.setSelectedConversationHeaderTitle();
}
setupCompositionArea(): void {
window.reduxActions.composer.resetComposer();
const messageRequestEnum = Proto.SyncMessage.MessageRequestResponse.Type;
const props = {
setupConversationView(): void {
// setupHeader
const conversationHeaderProps = {
id: this.model.id,
compositionApi: this.compositionApi,
onClickAddPack: () => this.showStickerManager(),
onPickSticker: (packId: string, stickerId: number) =>
this.sendStickerMessage({ packId, stickerId }),
onEditorStateChange: (
msg: string,
bodyRanges: Array<BodyRangeType>,
caretLocation?: number
) => this.onEditorStateChange(msg, bodyRanges, caretLocation),
onTextTooLong: () => showToast(ToastMessageBodyTooLong),
getQuotedMessage: () => this.model.get('quotedMessageId'),
clearQuotedMessage: () => this.setQuoteMessage(null),
onAccept: () => {
this.syncMessageRequestResponse(
'onAccept',
this.model,
messageRequestEnum.ACCEPT
onShowContactModal: this.showContactModal.bind(this),
onSetDisappearingMessages: (seconds: number) =>
this.setDisappearingMessages(seconds),
onDeleteMessages: () => this.destroyMessages(),
onResetSession: () => this.endSession(),
onSearchInConversation: () => {
const { searchInConversation } = window.reduxActions.search;
const name = isMe(this.model.attributes)
? window.i18n('noteToSelf')
: this.model.getTitle();
searchInConversation(this.model.id, name);
},
onSetMuteNotifications: this.setMuteExpiration.bind(this),
onSetPin: this.setPin.bind(this),
// These are view only and don't update the Conversation model, so they
// need a manual update call.
onOutgoingAudioCallInConversation: async () => {
log.info(
'onOutgoingAudioCallInConversation: about to start an audio call'
);
const isVideoCall = false;
if (await this.isCallSafe()) {
log.info(
'onOutgoingAudioCallInConversation: call is deemed "safe". Making call'
);
await window.Signal.Services.calling.startCallingLobby(
this.model.id,
isVideoCall
);
log.info('onOutgoingAudioCallInConversation: started the call');
} else {
log.info(
'onOutgoingAudioCallInConversation: call is deemed "unsafe". Stopping'
);
}
},
onBlock: () => {
this.syncMessageRequestResponse(
'onBlock',
this.model,
messageRequestEnum.BLOCK
onOutgoingVideoCallInConversation: async () => {
log.info(
'onOutgoingVideoCallInConversation: about to start a video call'
);
},
onUnblock: () => {
this.syncMessageRequestResponse(
'onUnblock',
this.model,
messageRequestEnum.ACCEPT
);
},
onDelete: () => {
this.syncMessageRequestResponse(
'onDelete',
this.model,
messageRequestEnum.DELETE
);
},
onBlockAndReportSpam: () => {
this.blockAndReportSpam(this.model);
},
onStartGroupMigration: () => this.startMigrationToGV2(),
onCancelJoinRequest: async () => {
await window.showConfirmationDialog({
message: window.i18n(
'GroupV2--join--cancel-request-to-join--confirmation'
),
okText: window.i18n('GroupV2--join--cancel-request-to-join--yes'),
cancelText: window.i18n('GroupV2--join--cancel-request-to-join--no'),
resolve: () => {
this.longRunningTaskWrapper({
name: 'onCancelJoinRequest',
task: async () => this.model.cancelJoinRequest(),
});
},
});
const isVideoCall = true;
if (this.model.get('announcementsOnly') && !this.model.areWeAdmin()) {
showToast(ToastCannotStartGroupCall);
return;
}
if (await this.isCallSafe()) {
log.info(
'onOutgoingVideoCallInConversation: call is deemed "safe". Making call'
);
await window.Signal.Services.calling.startCallingLobby(
this.model.id,
isVideoCall
);
log.info('onOutgoingVideoCallInConversation: started the call');
} else {
log.info(
'onOutgoingVideoCallInConversation: call is deemed "unsafe". Stopping'
);
}
},
onClickAttachment: this.onClickAttachment.bind(this),
onClearAttachments: this.clearAttachments.bind(this),
onSelectMediaQuality: (isHQ: boolean) => {
window.reduxActions.composer.setMediaQualitySetting(isHQ);
onShowChatColorEditor: () => {
this.showChatColorEditor();
},
onShowConversationDetails: () => {
this.showConversationDetails();
},
onShowSafetyNumber: () => {
this.showSafetyNumber();
},
onShowAllMedia: () => {
this.showAllMedia();
},
onShowGroupMembers: () => {
this.showGV1Members();
},
onGoBack: () => {
this.resetPanel();
},
onClickQuotedMessage: (id: string) => this.scrollToMessage(id),
onArchive: () => {
this.model.setArchived(true);
this.model.trigger('unload', 'archive');
onCloseLinkPreview: () => {
this.disableLinkPreviews = true;
this.removeLinkPreview();
showToast(ToastConversationArchived);
},
onMarkUnread: () => {
this.model.setMarkedUnread(true);
openConversation: this.openConversation.bind(this),
showToast(ToastConversationMarkedUnread);
},
onMoveToInbox: () => {
this.model.setArchived(false);
onSendMessage: ({
draftAttachments,
mentions = [],
message = '',
timestamp,
voiceNoteAttachment,
}: {
draftAttachments?: ReadonlyArray<AttachmentType>;
mentions?: BodyRangesType;
message?: string;
timestamp?: number;
voiceNoteAttachment?: AttachmentType;
}): void => {
this.sendMessage(message, mentions, {
draftAttachments,
timestamp,
voiceNoteAttachment,
});
showToast(ToastConversationUnarchived);
},
};
window.reduxActions.conversations.setSelectedConversationHeaderTitle();
this.compositionAreaView = new Whisper.ReactWrapperView({
className: 'composition-area-wrapper',
JSX: window.Signal.State.Roots.createCompositionArea(
window.reduxStore,
props
),
});
// Finally, add it to the DOM
this.$('.CompositionArea__placeholder').append(this.compositionAreaView.el);
}
async longRunningTaskWrapper<T>({
name,
task,
}: {
name: string;
task: () => Promise<T>;
}): Promise<T> {
const idForLogging = this.model.idForLogging();
return window.Signal.Util.longRunningTaskWrapper({
name,
idForLogging,
task,
});
}
getMessageActions(): MessageActionsType {
const reactToMessage = (
messageId: string,
reaction: { emoji: string; remove: boolean }
) => {
this.sendReactionMessage(messageId, reaction);
};
const replyToMessage = (messageId: string) => {
this.setQuoteMessage(messageId);
};
const retrySend = retryMessageSend;
const deleteMessage = (messageId: string) => {
this.deleteMessage(messageId);
};
const deleteMessageForEveryone = (messageId: string) => {
this.deleteMessageForEveryone(messageId);
};
const showMessageDetail = (messageId: string) => {
this.showMessageDetail(messageId);
};
const showContactModal = (contactId: string) => {
this.showContactModal(contactId);
};
const openConversation = (conversationId: string, messageId?: string) => {
this.openConversation(conversationId, messageId);
};
const showContactDetail = (options: {
contact: EmbeddedContactType;
signalAccount?: string;
}) => {
this.showContactDetail(options);
};
const kickOffAttachmentDownload = async (
options: Readonly<{ messageId: string }>
) => {
const message = window.MessageController.getById(options.messageId);
if (!message) {
throw new Error(
`kickOffAttachmentDownload: Message ${options.messageId} missing!`
);
}
await message.queueAttachmentDownloads();
};
const markAttachmentAsCorrupted = (options: AttachmentOptions) => {
const message = window.MessageController.getById(options.messageId);
if (!message) {
throw new Error(
`markAttachmentAsCorrupted: Message ${options.messageId} missing!`
);
}
message.markAttachmentAsCorrupted(options.attachment);
};
const onMarkViewed = (messageId: string): void => {
const message = window.MessageController.getById(messageId);
if (!message) {
throw new Error(`onMarkViewed: Message ${messageId} missing!`);
}
if (message.get('readStatus') === ReadStatus.Viewed) {
return;
}
const senderE164 = message.get('source');
const senderUuid = message.get('sourceUuid');
const timestamp = message.get('sent_at');
message.set(markViewed(message.attributes, Date.now()));
viewedReceiptsJobQueue.add({
viewedReceipt: {
messageId,
senderE164,
senderUuid,
timestamp,
},
});
viewSyncJobQueue.add({
viewSyncs: [
{
messageId,
senderE164,
senderUuid,
timestamp,
},
],
});
};
const showVisualAttachment = (options: {
attachment: AttachmentType;
messageId: string;
showSingle?: boolean;
}) => {
this.showLightbox(options);
};
const downloadAttachment = (options: {
attachment: AttachmentType;
timestamp: number;
isDangerous: boolean;
}) => {
this.downloadAttachment(options);
};
const displayTapToViewMessage = (messageId: string) =>
this.displayTapToViewMessage(messageId);
const showIdentity = (conversationId: string) => {
this.showSafetyNumber(conversationId);
};
const openLink = openLinkInWebBrowser;
const downloadNewVersion = () => {
openLinkInWebBrowser('https://signal.org/download');
};
const showSafetyNumber = (contactId: string) => {
this.showSafetyNumber(contactId);
};
const showExpiredIncomingTapToViewToast = () => {
log.info('Showing expired tap-to-view toast for an incoming message');
showToast(ToastTapToViewExpiredIncoming);
};
const showExpiredOutgoingTapToViewToast = () => {
log.info('Showing expired tap-to-view toast for an outgoing message');
showToast(ToastTapToViewExpiredOutgoing);
};
const showForwardMessageModal = this.showForwardMessageModal.bind(this);
return {
deleteMessage,
deleteMessageForEveryone,
displayTapToViewMessage,
downloadAttachment,
downloadNewVersion,
kickOffAttachmentDownload,
markAttachmentAsCorrupted,
markViewed: onMarkViewed,
openConversation,
openLink,
reactToMessage,
replyToMessage,
retrySend,
showContactDetail,
showContactModal,
showSafetyNumber,
showExpiredIncomingTapToViewToast,
showExpiredOutgoingTapToViewToast,
showForwardMessageModal,
showIdentity,
showMessageDetail,
showVisualAttachment,
};
}
setupTimeline(): void {
// setupTimeline
const messageRequestEnum = Proto.SyncMessage.MessageRequestResponse.Type;
const contactSupport = () => {
@ -965,65 +669,335 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
this.syncMessageRequestResponse(name, conversation, enumValue);
};
this.timelineView = new Whisper.ReactWrapperView({
className: 'timeline-wrapper',
JSX: window.Signal.State.Roots.createTimeline(window.reduxStore, {
id: this.model.id,
const timelineProps = {
id: this.model.id,
...this.getMessageActions(),
...this.getMessageActions(),
acknowledgeGroupMemberNameCollisions: (
groupNameCollisions: Readonly<GroupNameCollisionsWithIdsByTitle>
): void => {
this.model.acknowledgeGroupMemberNameCollisions(groupNameCollisions);
},
contactSupport,
learnMoreAboutDeliveryIssue,
loadNewerMessages,
loadNewestMessages: this.loadNewestMessages.bind(this),
loadAndScroll: this.loadAndScroll.bind(this),
loadOlderMessages,
markMessageRead,
onBlock: createMessageRequestResponseHandler(
'onBlock',
messageRequestEnum.BLOCK
),
onBlockAndReportSpam: (conversationId: string) => {
const conversation = window.ConversationController.get(
conversationId
acknowledgeGroupMemberNameCollisions: (
groupNameCollisions: Readonly<GroupNameCollisionsWithIdsByTitle>
): void => {
this.model.acknowledgeGroupMemberNameCollisions(groupNameCollisions);
},
contactSupport,
learnMoreAboutDeliveryIssue,
loadNewerMessages,
loadNewestMessages: this.loadNewestMessages.bind(this),
loadAndScroll: this.loadAndScroll.bind(this),
loadOlderMessages,
markMessageRead,
onBlock: createMessageRequestResponseHandler(
'onBlock',
messageRequestEnum.BLOCK
),
onBlockAndReportSpam: (conversationId: string) => {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
log.error(
`onBlockAndReportSpam: Expected a conversation to be found for ${conversationId}. Doing nothing.`
);
if (!conversation) {
log.error(
`onBlockAndReportSpam: Expected a conversation to be found for ${conversationId}. Doing nothing.`
);
return;
}
this.blockAndReportSpam(conversation);
},
onDelete: createMessageRequestResponseHandler(
'onDelete',
messageRequestEnum.DELETE
),
onUnblock: createMessageRequestResponseHandler(
'onUnblock',
return;
}
this.blockAndReportSpam(conversation);
},
onDelete: createMessageRequestResponseHandler(
'onDelete',
messageRequestEnum.DELETE
),
onUnblock: createMessageRequestResponseHandler(
'onUnblock',
messageRequestEnum.ACCEPT
),
removeMember: (conversationId: string) => {
this.longRunningTaskWrapper({
name: 'removeMember',
task: () => this.model.removeFromGroupV2(conversationId),
});
},
scrollToQuotedMessage,
unblurAvatar: () => {
this.model.unblurAvatar();
},
updateSharedGroups: () => this.model.throttledUpdateSharedGroups?.(),
};
// setupCompositionArea
window.reduxActions.composer.resetComposer();
const compositionAreaProps = {
id: this.model.id,
compositionApi: this.compositionApi,
onClickAddPack: () => this.showStickerManager(),
onPickSticker: (packId: string, stickerId: number) =>
this.sendStickerMessage({ packId, stickerId }),
onEditorStateChange: (
msg: string,
bodyRanges: Array<BodyRangeType>,
caretLocation?: number
) => this.onEditorStateChange(msg, bodyRanges, caretLocation),
onTextTooLong: () => showToast(ToastMessageBodyTooLong),
getQuotedMessage: () => this.model.get('quotedMessageId'),
clearQuotedMessage: () => this.setQuoteMessage(null),
onAccept: () => {
this.syncMessageRequestResponse(
'onAccept',
this.model,
messageRequestEnum.ACCEPT
),
onShowContactModal: this.showContactModal.bind(this),
removeMember: (conversationId: string) => {
this.longRunningTaskWrapper({
name: 'removeMember',
task: () => this.model.removeFromGroupV2(conversationId),
});
},
scrollToQuotedMessage,
unblurAvatar: () => {
this.model.unblurAvatar();
},
updateSharedGroups: this.model.throttledUpdateSharedGroups,
}),
);
},
onBlock: () => {
this.syncMessageRequestResponse(
'onBlock',
this.model,
messageRequestEnum.BLOCK
);
},
onUnblock: () => {
this.syncMessageRequestResponse(
'onUnblock',
this.model,
messageRequestEnum.ACCEPT
);
},
onDelete: () => {
this.syncMessageRequestResponse(
'onDelete',
this.model,
messageRequestEnum.DELETE
);
},
onBlockAndReportSpam: () => {
this.blockAndReportSpam(this.model);
},
onStartGroupMigration: () => this.startMigrationToGV2(),
onCancelJoinRequest: async () => {
await window.showConfirmationDialog({
message: window.i18n(
'GroupV2--join--cancel-request-to-join--confirmation'
),
okText: window.i18n('GroupV2--join--cancel-request-to-join--yes'),
cancelText: window.i18n('GroupV2--join--cancel-request-to-join--no'),
resolve: () => {
this.longRunningTaskWrapper({
name: 'onCancelJoinRequest',
task: async () => this.model.cancelJoinRequest(),
});
},
});
},
onClickAttachment: this.onClickAttachment.bind(this),
onClearAttachments: this.clearAttachments.bind(this),
onSelectMediaQuality: (isHQ: boolean) => {
window.reduxActions.composer.setMediaQualitySetting(isHQ);
},
handleClickQuotedMessage: (id: string) => this.scrollToMessage(id),
onCloseLinkPreview: () => {
this.disableLinkPreviews = true;
this.removeLinkPreview();
},
openConversation: this.openConversation.bind(this),
onSendMessage: ({
draftAttachments,
mentions = [],
message = '',
timestamp,
voiceNoteAttachment,
}: {
draftAttachments?: ReadonlyArray<AttachmentType>;
mentions?: BodyRangesType;
message?: string;
timestamp?: number;
voiceNoteAttachment?: AttachmentType;
}): void => {
this.sendMessage(message, mentions, {
draftAttachments,
timestamp,
voiceNoteAttachment,
});
},
};
// createConversationView root
const JSX = createConversationView(window.reduxStore, {
compositionAreaProps,
conversationHeaderProps,
timelineProps,
});
this.$('.timeline-placeholder').append(this.timelineView.el);
this.conversationView = new Whisper.ReactWrapperView({ JSX });
this.$('.ConversationView__template').append(this.conversationView.el);
}
async longRunningTaskWrapper<T>({
name,
task,
}: {
name: string;
task: () => Promise<T>;
}): Promise<T> {
const idForLogging = this.model.idForLogging();
return window.Signal.Util.longRunningTaskWrapper({
name,
idForLogging,
task,
});
}
getMessageActions(): MessageActionsType {
const reactToMessage = (
messageId: string,
reaction: { emoji: string; remove: boolean }
) => {
this.sendReactionMessage(messageId, reaction);
};
const replyToMessage = (messageId: string) => {
this.setQuoteMessage(messageId);
};
const retrySend = retryMessageSend;
const deleteMessage = (messageId: string) => {
this.deleteMessage(messageId);
};
const deleteMessageForEveryone = (messageId: string) => {
this.deleteMessageForEveryone(messageId);
};
const showMessageDetail = (messageId: string) => {
this.showMessageDetail(messageId);
};
const showContactModal = (contactId: string) => {
this.showContactModal(contactId);
};
const openConversation = (conversationId: string, messageId?: string) => {
this.openConversation(conversationId, messageId);
};
const showContactDetail = (options: {
contact: EmbeddedContactType;
signalAccount?: string;
}) => {
this.showContactDetail(options);
};
const kickOffAttachmentDownload = async (
options: Readonly<{ messageId: string }>
) => {
const message = window.MessageController.getById(options.messageId);
if (!message) {
throw new Error(
`kickOffAttachmentDownload: Message ${options.messageId} missing!`
);
}
await message.queueAttachmentDownloads();
};
const markAttachmentAsCorrupted = (options: AttachmentOptions) => {
const message = window.MessageController.getById(options.messageId);
if (!message) {
throw new Error(
`markAttachmentAsCorrupted: Message ${options.messageId} missing!`
);
}
message.markAttachmentAsCorrupted(options.attachment);
};
const onMarkViewed = (messageId: string): void => {
const message = window.MessageController.getById(messageId);
if (!message) {
throw new Error(`onMarkViewed: Message ${messageId} missing!`);
}
if (message.get('readStatus') === ReadStatus.Viewed) {
return;
}
const senderE164 = message.get('source');
const senderUuid = message.get('sourceUuid');
const timestamp = message.get('sent_at');
message.set(markViewed(message.attributes, Date.now()));
viewedReceiptsJobQueue.add({
viewedReceipt: {
messageId,
senderE164,
senderUuid,
timestamp,
},
});
viewSyncJobQueue.add({
viewSyncs: [
{
messageId,
senderE164,
senderUuid,
timestamp,
},
],
});
};
const showVisualAttachment = (options: {
attachment: AttachmentType;
messageId: string;
showSingle?: boolean;
}) => {
this.showLightbox(options);
};
const downloadAttachment = (options: {
attachment: AttachmentType;
timestamp: number;
isDangerous: boolean;
}) => {
this.downloadAttachment(options);
};
const displayTapToViewMessage = (messageId: string) =>
this.displayTapToViewMessage(messageId);
const showIdentity = (conversationId: string) => {
this.showSafetyNumber(conversationId);
};
const openLink = openLinkInWebBrowser;
const downloadNewVersion = () => {
openLinkInWebBrowser('https://signal.org/download');
};
const showSafetyNumber = (contactId: string) => {
this.showSafetyNumber(contactId);
};
const showExpiredIncomingTapToViewToast = () => {
log.info('Showing expired tap-to-view toast for an incoming message');
showToast(ToastTapToViewExpiredIncoming);
};
const showExpiredOutgoingTapToViewToast = () => {
log.info('Showing expired tap-to-view toast for an outgoing message');
showToast(ToastTapToViewExpiredOutgoing);
};
const showForwardMessageModal = this.showForwardMessageModal.bind(this);
return {
deleteMessage,
deleteMessageForEveryone,
displayTapToViewMessage,
downloadAttachment,
downloadNewVersion,
kickOffAttachmentDownload,
markAttachmentAsCorrupted,
markViewed: onMarkViewed,
openConversation,
openLink,
reactToMessage,
replyToMessage,
retrySend,
showContactDetail,
showContactModal,
showSafetyNumber,
showExpiredIncomingTapToViewToast,
showExpiredOutgoingTapToViewToast,
showForwardMessageModal,
showIdentity,
showMessageDetail,
showVisualAttachment,
};
}
// eslint-disable-next-line class-methods-use-this
@ -1397,9 +1371,7 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
this.model.updateLastMessage();
}
this.titleView?.remove();
this.timelineView?.remove();
this.compositionAreaView?.remove();
this.conversationView?.remove();
if (this.captionEditorView) {
this.captionEditorView.remove();

View file

@ -216,13 +216,4 @@ Whisper.InboxView = Whisper.View.extend({
searchInput?.focus?.();
}
},
closeRecording(e: MouseEvent) {
if (e && this.$(e.target).closest('.capture-audio').length > 0) {
return;
}
this.$('.conversation:first .recorder').trigger('close');
},
onClick(e: MouseEvent) {
this.closeRecording(e);
},
});

6
ts/window.d.ts vendored
View file

@ -40,9 +40,7 @@ import { ReduxActions } from './state/types';
import { createStore } from './state/createStore';
import { createApp } from './state/roots/createApp';
import { createChatColorPicker } from './state/roots/createChatColorPicker';
import { createCompositionArea } from './state/roots/createCompositionArea';
import { createConversationDetails } from './state/roots/createConversationDetails';
import { createConversationHeader } from './state/roots/createConversationHeader';
import { createForwardMessageModal } from './state/roots/createForwardMessageModal';
import { createGroupLinkManagement } from './state/roots/createGroupLinkManagement';
import { createGroupV1MigrationModal } from './state/roots/createGroupV1MigrationModal';
@ -56,7 +54,6 @@ import { createSafetyNumberViewer } from './state/roots/createSafetyNumberViewer
import { createShortcutGuideModal } from './state/roots/createShortcutGuideModal';
import { createStickerManager } from './state/roots/createStickerManager';
import { createStickerPreviewModal } from './state/roots/createStickerPreviewModal';
import { createTimeline } from './state/roots/createTimeline';
import * as appDuck from './state/ducks/app';
import * as callingDuck from './state/ducks/calling';
import * as conversationsDuck from './state/ducks/conversations';
@ -423,9 +420,7 @@ declare global {
Roots: {
createApp: typeof createApp;
createChatColorPicker: typeof createChatColorPicker;
createCompositionArea: typeof createCompositionArea;
createConversationDetails: typeof createConversationDetails;
createConversationHeader: typeof createConversationHeader;
createForwardMessageModal: typeof createForwardMessageModal;
createGroupLinkManagement: typeof createGroupLinkManagement;
createGroupV1MigrationModal: typeof createGroupV1MigrationModal;
@ -439,7 +434,6 @@ declare global {
createShortcutGuideModal: typeof createShortcutGuideModal;
createStickerManager: typeof createStickerManager;
createStickerPreviewModal: typeof createStickerPreviewModal;
createTimeline: typeof createTimeline;
};
Ducks: {
app: typeof appDuck;