ConversationView: Move setQuotedMessage/scrollToMessage to redux

This commit is contained in:
Scott Nonnenberg 2022-12-09 11:11:14 -08:00 committed by GitHub
parent 7c68f9ef1a
commit 07f7fa93d6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 183 additions and 159 deletions

View file

@ -1631,7 +1631,16 @@ export async function startApp(): Promise<void> {
) {
const { selectedMessage } = state.conversations;
conversation.trigger('toggle-reply', selectedMessage);
const composerState = window.reduxStore
? window.reduxStore.getState().composer
: undefined;
const quote = composerState?.quotedMessage?.quote;
window.reduxActions.composer.setQuoteByMessageId(
conversation.id,
quote ? undefined : selectedMessage
);
event.preventDefault();
event.stopPropagation();
return;

View file

@ -45,12 +45,13 @@ type PropsType = {
executeMenuRole: ExecuteMenuRoleType;
executeMenuAction: (action: MenuActionType) => void;
hideToast: () => unknown;
titleBarDoubleClick: () => void;
toast?: {
toastType: ToastType;
parameters?: ReplacementValuesType;
};
hideToast: () => unknown;
scrollToMessage: (conversationId: string, messageId: string) => unknown;
toggleStoriesView: () => unknown;
viewStory: ViewStoryActionCreatorType;
} & ComponentProps<typeof Inbox>;
@ -79,6 +80,7 @@ export function App({
renderStories,
renderStoryViewer,
requestVerification,
scrollToMessage,
selectedConversationId,
selectedMessage,
selectedMessageSource,
@ -116,6 +118,7 @@ export function App({
renderCustomizingPreferredReactionsModal
}
renderLeftPane={renderLeftPane}
scrollToMessage={scrollToMessage}
selectedConversationId={selectedConversationId}
selectedMessage={selectedMessage}
selectedMessageSource={selectedMessageSource}

View file

@ -43,6 +43,7 @@ const useProps = (overrideProps: Partial<Props> = {}): Props => ({
removeAttachment: action('removeAttachment'),
theme: React.useContext(StorybookThemeContext),
setComposerFocus: action('setComposerFocus'),
setQuoteByMessageId: action('setQuoteByMessageId'),
// AttachmentList
draftAttachments: overrideProps.draftAttachments || [],
@ -63,8 +64,7 @@ const useProps = (overrideProps: Partial<Props> = {}): Props => ({
onCloseLinkPreview: action('onCloseLinkPreview'),
// Quote
quotedMessageProps: overrideProps.quotedMessageProps,
onClickQuotedMessage: action('onClickQuotedMessage'),
setQuotedMessage: action('setQuotedMessage'),
scrollToMessage: action('scrollToMessage'),
// MediaEditor
imageToBlurHash: async () => 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
// MediaQualitySelector

View file

@ -97,7 +97,6 @@ export type OwnProps = Readonly<{
linkPreviewResult?: LinkPreviewType;
messageRequestsEnabled?: boolean;
onClearAttachments(): unknown;
onClickQuotedMessage(): unknown;
onCloseLinkPreview(): unknown;
processAttachments: (options: {
conversationId: string;
@ -119,13 +118,18 @@ export type OwnProps = Readonly<{
}
): unknown;
openConversation(conversationId: string): unknown;
quotedMessageId?: string;
quotedMessageProps?: Omit<
QuoteProps,
'i18n' | 'onClick' | 'onClose' | 'withContentAbove'
>;
removeAttachment: (conversationId: string, filePath: string) => unknown;
scrollToMessage: (conversationId: string, messageId: string) => unknown;
setComposerFocus: (conversationId: string) => unknown;
setQuotedMessage(message: undefined): unknown;
setQuoteByMessageId(
conversationId: string,
messageId: string | undefined
): unknown;
shouldSendHighQualityAttachments: boolean;
startRecording: () => unknown;
theme: ThemeType;
@ -179,6 +183,7 @@ export function CompositionArea({
removeAttachment,
sendMultiMediaMessage,
setComposerFocus,
setQuoteByMessageId,
theme,
// AttachmentList
@ -196,9 +201,9 @@ export function CompositionArea({
linkPreviewResult,
onCloseLinkPreview,
// Quote
quotedMessageId,
quotedMessageProps,
onClickQuotedMessage,
setQuotedMessage,
scrollToMessage,
// MediaQualitySelector
onSelectMediaQuality,
shouldSendHighQualityAttachments,
@ -639,18 +644,15 @@ export function CompositionArea({
'CompositionArea__row--column'
)}
>
{quotedMessageProps && (
{quotedMessageId && quotedMessageProps && (
<div className="quote-wrapper">
<Quote
isCompose
{...quotedMessageProps}
i18n={i18n}
onClick={onClickQuotedMessage}
onClick={() => scrollToMessage(conversationId, quotedMessageId)}
onClose={() => {
// This one is for redux...
setQuotedMessage(undefined);
// and this is for conversation_view.
clearQuotedMessage?.();
setQuoteByMessageId(conversationId, undefined);
}}
/>
</div>

View file

@ -23,6 +23,7 @@ export type PropsType = {
isCustomizingPreferredReactions: boolean;
renderCustomizingPreferredReactionsModal: () => JSX.Element;
renderLeftPane: () => JSX.Element;
scrollToMessage: (conversationId: string, messageId: string) => unknown;
selectedConversationId?: string;
selectedMessage?: string;
selectedMessageSource?: SelectedMessageSource;
@ -36,6 +37,7 @@ export function Inbox({
isCustomizingPreferredReactions,
renderCustomizingPreferredReactionsModal,
renderLeftPane,
scrollToMessage,
selectedConversationId,
selectedMessage,
selectedMessageSource,
@ -93,10 +95,11 @@ export function Inbox({
selectedMessage &&
selectedMessageSource !== SelectedMessageSource.Focus
) {
conversation.trigger('scroll-to-message', selectedMessage);
scrollToMessage(conversation.id, selectedMessage);
}
}, [
prevConversation,
scrollToMessage,
selectedConversationId,
selectedMessage,
selectedMessageSource,

View file

@ -122,7 +122,7 @@ const defaultMessageProps: TimelineMessagesProps = {
renderEmojiPicker: () => <div />,
renderReactionPicker: () => <div />,
renderAudioAttachment: () => <div>*AudioAttachment*</div>,
replyToMessage: action('default--replyToMessage'),
setQuoteByMessageId: action('default--setQuoteByMessageId'),
retrySend: action('default--retrySend'),
retryDeleteForEveryone: action('default--retryDeleteForEveryone'),
scrollToQuotedMessage: action('default--scrollToQuotedMessage'),

View file

@ -276,7 +276,7 @@ const actions = () => ({
updateSharedGroups: action('updateSharedGroups'),
reactToMessage: action('reactToMessage'),
replyToMessage: action('replyToMessage'),
setQuoteByMessageId: action('setQuoteByMessageId'),
retryDeleteForEveryone: action('retryDeleteForEveryone'),
retrySend: action('retrySend'),
deleteMessage: action('deleteMessage'),

View file

@ -239,7 +239,6 @@ const getActions = createSelector(
'doubleCheckMissingQuoteReference',
'checkForAccount',
'reactToMessage',
'replyToMessage',
'retryDeleteForEveryone',
'retrySend',
'toggleForwardMessageModal',
@ -248,6 +247,7 @@ const getActions = createSelector(
'showMessageDetail',
'openConversation',
'openGiftBadge',
'setQuoteByMessageId',
'showContactDetail',
'showContactModal',
'kickOffAttachmentDownload',

View file

@ -66,7 +66,7 @@ const getDefaultProps = () => ({
checkForAccount: action('checkForAccount'),
clearSelectedMessage: action('clearSelectedMessage'),
contactSupport: action('contactSupport'),
replyToMessage: action('replyToMessage'),
setQuoteByMessageId: action('setQuoteByMessageId'),
retryDeleteForEveryone: action('retryDeleteForEveryone'),
retrySend: action('retrySend'),
blockGroupLinkRequests: action('blockGroupLinkRequests'),

View file

@ -294,7 +294,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
renderEmojiPicker,
renderReactionPicker,
renderAudioAttachment,
replyToMessage: action('replyToMessage'),
setQuoteByMessageId: action('setQuoteByMessageId'),
retrySend: action('retrySend'),
retryDeleteForEveryone: action('retryDeleteForEveryone'),
scrollToQuotedMessage: action('scrollToQuotedMessage'),

View file

@ -49,8 +49,7 @@ export type PropsActions = {
) => void;
retrySend: (id: string) => void;
retryDeleteForEveryone: (id: string) => void;
replyToMessage: (id: string) => void;
setQuoteByMessageId: (conversationId: string, messageId: string) => void;
} & MessagePropsActions;
export type Props = PropsData &
@ -83,6 +82,7 @@ export function TimelineMessage(props: Props): JSX.Element {
canRetryDeleteForEveryone,
contact,
payment,
conversationId,
containerElementRef,
containerWidthBreakpoint,
deletedForEveryone,
@ -94,7 +94,7 @@ export function TimelineMessage(props: Props): JSX.Element {
isSticker,
isTapToView,
reactToMessage,
replyToMessage,
setQuoteByMessageId,
renderReactionPicker,
renderEmojiPicker,
retrySend,
@ -234,7 +234,9 @@ export function TimelineMessage(props: Props): JSX.Element {
? openGenericAttachment
: undefined;
const handleReplyToMessage = canReply ? () => replyToMessage(id) : undefined;
const handleReplyToMessage = canReply
? () => setQuoteByMessageId(conversationId, id)
: undefined;
const handleReact = canReact ? () => toggleReactionPicker() : undefined;

View file

@ -61,6 +61,9 @@ import { resolveAttachmentDraftData } from '../../util/resolveAttachmentDraftDat
import { resolveDraftAttachmentOnDisk } from '../../util/resolveDraftAttachmentOnDisk';
import { shouldShowInvalidMessageToast } from '../../util/shouldShowInvalidMessageToast';
import { writeDraftAttachment } from '../../util/writeDraftAttachment';
import { getMessageById } from '../../messages/getMessageById';
import { canReply } from '../selectors/message';
import { getConversationSelector } from '../selectors/conversations';
// State
@ -143,6 +146,7 @@ export const actions = {
sendStickerMessage,
setComposerDisabledState,
setComposerFocus,
setQuoteByMessageId,
setMediaQualitySetting,
setQuotedMessage,
};
@ -267,7 +271,11 @@ function sendMultiMediaMessage(
conversation.setMarkedUnread(false);
resetLinkPreview();
clearConversationDraftAttachments(conversationId, draftAttachments);
dispatch(setQuotedMessage(undefined));
setQuoteByMessageId(conversationId, undefined)(
dispatch,
getState,
undefined
);
dispatch(resetComposer());
},
}
@ -349,6 +357,77 @@ function getAttachmentsFromConversationModel(
return conversation?.get('draftAttachments') || [];
}
function setQuoteByMessageId(
conversationId: string,
messageId: string | undefined
): ThunkAction<
void,
RootStateType,
unknown,
SetComposerDisabledStateActionType | SetQuotedMessageActionType
> {
return async (dispatch, getState) => {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error('sendStickerMessage: No conversation found');
}
const message = messageId ? await getMessageById(messageId) : undefined;
const state = getState();
if (
message &&
!canReply(
message.attributes,
window.ConversationController.getOurConversationIdOrThrow(),
getConversationSelector(state)
)
) {
return;
}
if (message && !message.isNormalBubble()) {
return;
}
const existing = conversation.get('quotedMessageId');
if (existing !== messageId) {
const now = Date.now();
let activeAt = conversation.get('active_at');
let timestamp = conversation.get('timestamp');
if (!activeAt && messageId) {
activeAt = now;
timestamp = now;
}
conversation.set({
active_at: activeAt,
draftChanged: true,
quotedMessageId: messageId,
timestamp,
});
window.Signal.Data.updateConversation(conversation.attributes);
}
if (message) {
const quote = await conversation.makeQuote(message);
dispatch(
setQuotedMessage({
conversationId,
quote,
})
);
dispatch(setComposerFocus(conversation.id));
dispatch(setComposerDisabledState(false));
} else {
dispatch(setQuotedMessage(undefined));
}
};
}
function addAttachment(
conversationId: string,
attachment: InMemoryAttachmentDraftType

View file

@ -70,6 +70,7 @@ import {
getConversationUuidsStoppingSend,
getConversationIdsStoppedForVerification,
getMe,
getMessagesByConversation,
} from '../selectors/conversations';
import type { AvatarDataType, AvatarUpdateType } from '../../types/Avatar';
import { getDefaultAvatars } from '../../types/Avatar';
@ -113,6 +114,7 @@ import {
buildPromotePendingAdminApprovalMemberChange,
initiateMigrationToGroupV2 as doInitiateMigrationToGroupV2,
} from '../../groups';
import { getMessageById } from '../../messages/getMessageById';
// State
@ -2480,16 +2482,55 @@ function closeMaximumGroupSizeModal(): CloseMaximumGroupSizeModalActionType {
function closeRecommendedGroupSizeModal(): CloseRecommendedGroupSizeModalActionType {
return { type: 'CLOSE_RECOMMENDED_GROUP_SIZE_MODAL' };
}
function scrollToMessage(
conversationId: string,
messageId: string
): ScrollToMessageActionType {
return {
type: 'SCROLL_TO_MESSAGE',
payload: {
conversationId,
messageId,
},
): ThunkAction<void, RootStateType, unknown, ScrollToMessageActionType> {
return async (dispatch, getState) => {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error('scrollToMessage: No conversation found');
}
const message = await getMessageById(messageId);
if (!message) {
throw new Error(`scrollToMessage: failed to load message ${messageId}`);
}
if (message.get('conversationId') !== conversationId) {
throw new Error(
`scrollToMessage: ${messageId} didn't have conversationId ${conversationId}`
);
}
const state = getState();
let isInMemory = true;
if (!window.MessageController.getById(messageId)) {
isInMemory = false;
}
// Message might be in memory, but not in the redux anymore because
// we call `messageReset()` in `loadAndScroll()`.
const messagesByConversation =
getMessagesByConversation(state)[conversationId];
if (!messagesByConversation?.messageIds.includes(messageId)) {
isInMemory = false;
}
if (isInMemory) {
dispatch({
type: 'SCROLL_TO_MESSAGE',
payload: {
conversationId,
messageId,
},
});
return;
}
conversation.loadAndScroll(messageId);
};
}

View file

@ -33,13 +33,12 @@ import { isSignalConversation } from '../../util/isSignalConversation';
type ExternalProps = {
id: string;
handleClickQuotedMessage: (id: string) => unknown;
};
export type CompositionAreaPropsType = ExternalProps & ComponentPropsType;
const mapStateToProps = (state: StateType, props: ExternalProps) => {
const { id, handleClickQuotedMessage } = props;
const { id } = props;
const conversationSelector = getConversationSelector(state);
const conversation = conversationSelector(id);
@ -108,18 +107,13 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
linkPreviewLoading,
linkPreviewResult,
// Quote
quotedMessageId: quotedMessage?.quote?.messageId,
quotedMessageProps: quotedMessage
? getPropsForQuote(quotedMessage, {
conversationSelector,
ourConversationId: getUserConversationId(state),
})
: undefined,
onClickQuotedMessage: () => {
const messageId = quotedMessage?.quote?.messageId;
if (messageId) {
handleClickQuotedMessage(messageId);
}
},
// Emojis
recentEmojis,
skinTone: getEmojiSkinTone(state),

View file

@ -17,9 +17,6 @@ export type PropsType = {
conversationId: string;
compositionAreaProps: Pick<
CompositionAreaPropsType,
| 'clearQuotedMessage'
| 'getQuotedMessage'
| 'handleClickQuotedMessage'
| 'id'
| 'onCancelJoinRequest'
| 'onClearAttachments'

View file

@ -81,7 +81,6 @@ export type TimelinePropsType = ExternalProps &
| 'openLink'
| 'reactToMessage'
| 'removeMember'
| 'replyToMessage'
| 'retryDeleteForEveryone'
| 'retrySend'
| 'scrollToQuotedMessage'

View file

@ -15,6 +15,7 @@ import { fakeDraftAttachment } from '../../helpers/fakeAttachment';
describe('both/state/ducks/composer', () => {
const QUOTED_MESSAGE = {
conversationId: '123',
id: 'quoted-message-id',
quote: {
attachments: [],
id: 456,

View file

@ -21,18 +21,13 @@ import { strictAssert } from '../util/assert';
import { enqueueReactionForSend } from '../reactions/enqueueReactionForSend';
import type { GroupNameCollisionsWithIdsByTitle } from '../util/groupMemberNameCollisions';
import { isGroup } from '../util/whatTypeOfConversation';
import { findAndFormatContact } from '../util/findAndFormatContact';
import { getPreferredBadgeSelector } from '../state/selectors/badges';
import {
canReply,
isIncoming,
isOutgoing,
isTapToView,
} from '../state/selectors/message';
import {
getConversationSelector,
getMessagesByConversation,
} from '../state/selectors/conversations';
import { getConversationSelector } from '../state/selectors/conversations';
import { getActiveCallState } from '../state/selectors/calling';
import { getTheme } from '../state/selectors/user';
import { ReactWrapperView } from './ReactWrapperView';
@ -113,7 +108,6 @@ type MessageActionsType = {
messageId: string,
reaction: { emoji: string; remove: boolean }
) => unknown;
replyToMessage: (messageId: string) => unknown;
retrySend: (messageId: string) => unknown;
retryDeleteForEveryone: (messageId: string) => unknown;
showContactDetail: (options: {
@ -171,7 +165,6 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
// These are triggered by InboxView
this.listenTo(this.model, 'opened', this.onOpened);
this.listenTo(this.model, 'scroll-to-message', this.scrollToMessage);
this.listenTo(this.model, 'unload', (reason: string) =>
this.unload(`model trigger - ${reason}`)
);
@ -180,18 +173,6 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
this.listenTo(this.model, 'open-all-media', this.showAllMedia);
this.listenTo(this.model, 'escape-pressed', this.resetPanel);
this.listenTo(this.model, 'show-message-details', this.showMessageDetail);
this.listenTo(
this.model,
'toggle-reply',
(messageId: string | undefined) => {
const composerState = window.reduxStore
? window.reduxStore.getState().composer
: undefined;
const quote = composerState?.quotedMessage?.quote;
this.setQuoteMessage(quote ? undefined : messageId);
}
);
this.listenTo(
this.model,
'save-attachment',
@ -332,7 +313,10 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
return;
}
this.scrollToMessage(message.id);
window.reduxActions.conversations.scrollToMessage(
conversationId,
message.id
);
};
const markMessageRead = async (messageId: string) => {
@ -396,8 +380,6 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
id: this.model.id,
onClickAddPack: () => this.showStickerManager(),
onTextTooLong: () => showToast(ToastMessageBodyTooLong),
getQuotedMessage: () => this.model.get('quotedMessageId'),
clearQuotedMessage: () => this.setQuoteMessage(undefined),
onCancelJoinRequest: async () => {
await window.showConfirmationDialog({
dialogName: 'GroupV2CancelRequestToJoin',
@ -421,8 +403,6 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
window.reduxActions.composer.setMediaQualitySetting(isHQ);
},
handleClickQuotedMessage: (id: string) => this.scrollToMessage(id),
onCloseLinkPreview: () => {
suspendLinkPreviews();
removeLinkPreview();
@ -461,9 +441,6 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
showToast(ToastReactionFailed);
}
};
const replyToMessage = (messageId: string) => {
this.setQuoteMessage(messageId);
};
const retrySend = retryMessageSend;
const deleteMessage = (messageId: string) => {
this.deleteMessage(messageId);
@ -555,7 +532,6 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
openGiftBadge,
openLink,
reactToMessage,
replyToMessage,
retrySend,
retryDeleteForEveryone,
showContactDetail,
@ -567,37 +543,6 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
};
}
async scrollToMessage(messageId: string): Promise<void> {
const message = await getMessageById(messageId);
if (!message) {
throw new Error(`scrollToMessage: failed to load message ${messageId}`);
}
const state = window.reduxStore.getState();
let isInMemory = true;
if (!window.MessageController.getById(messageId)) {
isInMemory = false;
}
// Message might be in memory, but not in the redux anymore because
// we call `messageReset()` in `loadAndScroll()`.
const messagesByConversation =
getMessagesByConversation(state)[this.model.id];
if (!messagesByConversation?.messageIds.includes(messageId)) {
isInMemory = false;
}
if (isInMemory) {
const { scrollToMessage } = window.reduxActions.conversations;
scrollToMessage(this.model.id, messageId);
return;
}
this.model.loadAndScroll(messageId);
}
unload(reason: string): void {
log.info(
'unloading conversation',
@ -697,7 +642,10 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
const quotedMessageId = this.model.get('quotedMessageId');
if (quotedMessageId) {
this.setQuoteMessage(quotedMessageId);
window.reduxActions.composer.setQuoteByMessageId(
this.model.id,
quotedMessageId
);
}
this.model.fetchLatestGroupV2Data();
@ -1532,60 +1480,6 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
);
}
async setQuoteMessage(messageId: string | undefined): Promise<void> {
const { model } = this;
const message = messageId ? await getMessageById(messageId) : undefined;
if (
message &&
!canReply(
message.attributes,
window.ConversationController.getOurConversationIdOrThrow(),
findAndFormatContact
)
) {
return;
}
if (message && !message.isNormalBubble()) {
return;
}
const existing = model.get('quotedMessageId');
if (existing !== messageId) {
const now = Date.now();
let active_at = this.model.get('active_at');
let timestamp = this.model.get('timestamp');
if (!active_at && messageId) {
active_at = now;
timestamp = now;
}
this.model.set({
active_at,
draftChanged: true,
quotedMessageId: messageId,
timestamp,
});
await this.saveModel();
}
if (message) {
const quote = await model.makeQuote(message);
window.reduxActions.composer.setQuotedMessage({
conversationId: model.id,
quote,
});
window.reduxActions.composer.setComposerFocus(this.model.id);
window.reduxActions.composer.setComposerDisabledState(false);
} else {
window.reduxActions.composer.setQuotedMessage(undefined);
}
}
async clearAttachments(): Promise<void> {
const draftAttachments = this.model.get('draftAttachments') || [];
this.model.set({