Moves message details into React pane land

This commit is contained in:
Josh Perez 2022-12-21 15:44:23 -05:00 committed by GitHub
parent 3def746014
commit a80c6d89a8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 501 additions and 558 deletions

View file

@ -1460,9 +1460,7 @@ export async function startApp(): Promise<void> {
// Send Escape to active conversation so it can close panels // Send Escape to active conversation so it can close panels
if (conversation && key === 'Escape') { if (conversation && key === 'Escape') {
window.reduxActions.conversations.popPanelForConversation( window.reduxActions.conversations.popPanelForConversation();
conversation.id
);
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
return; return;
@ -1536,12 +1534,9 @@ export async function startApp(): Promise<void> {
shiftKey && shiftKey &&
(key === 'm' || key === 'M') (key === 'm' || key === 'M')
) { ) {
window.reduxActions.conversations.pushPanelForConversation( window.reduxActions.conversations.pushPanelForConversation({
conversation.id, type: PanelType.AllMedia,
{ });
type: PanelType.AllMedia,
}
);
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
return; return;
@ -1634,14 +1629,12 @@ export async function startApp(): Promise<void> {
return; return;
} }
window.reduxActions.conversations.pushPanelForConversation( window.reduxActions.conversations.pushPanelForConversation({
conversation.id, type: PanelType.MessageDetails,
{ args: {
type: PanelType.MessageDetails, messageId: selectedMessage,
args: { messageId: selectedMessage }, },
} });
);
return; return;
} }

View file

@ -462,7 +462,7 @@ export function CompositionArea({
recentStickers={recentStickers} recentStickers={recentStickers}
clearInstalledStickerPack={clearInstalledStickerPack} clearInstalledStickerPack={clearInstalledStickerPack}
onClickAddPack={() => onClickAddPack={() =>
pushPanelForConversation(conversationId, { pushPanelForConversation({
type: PanelType.StickerManager, type: PanelType.StickerManager,
}) })
} }

View file

@ -154,12 +154,12 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
} }
private renderBackButton(): ReactNode { private renderBackButton(): ReactNode {
const { i18n, id, popPanelForConversation, showBackButton } = this.props; const { i18n, popPanelForConversation, showBackButton } = this.props;
return ( return (
<button <button
type="button" type="button"
onClick={() => popPanelForConversation(id)} onClick={popPanelForConversation}
className={classNames( className={classNames(
'module-ConversationHeader__back-icon', 'module-ConversationHeader__back-icon',
showBackButton ? 'module-ConversationHeader__back-icon--show' : null showBackButton ? 'module-ConversationHeader__back-icon--show' : null
@ -474,7 +474,7 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
{!isGroup || hasGV2AdminEnabled ? ( {!isGroup || hasGV2AdminEnabled ? (
<MenuItem <MenuItem
onClick={() => onClick={() =>
pushPanelForConversation(id, { pushPanelForConversation({
type: PanelType.ConversationDetails, type: PanelType.ConversationDetails,
}) })
} }
@ -487,16 +487,14 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
{isGroup && !hasGV2AdminEnabled ? ( {isGroup && !hasGV2AdminEnabled ? (
<MenuItem <MenuItem
onClick={() => onClick={() =>
pushPanelForConversation(id, { type: PanelType.GroupV1Members }) pushPanelForConversation({ type: PanelType.GroupV1Members })
} }
> >
{i18n('showMembers')} {i18n('showMembers')}
</MenuItem> </MenuItem>
) : null} ) : null}
<MenuItem <MenuItem
onClick={() => onClick={() => pushPanelForConversation({ type: PanelType.AllMedia })}
pushPanelForConversation(id, { type: PanelType.AllMedia })
}
> >
{i18n('viewRecentMedia')} {i18n('viewRecentMedia')}
</MenuItem> </MenuItem>
@ -565,13 +563,8 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
} }
private renderHeader(): ReactNode { private renderHeader(): ReactNode {
const { const { conversationTitle, groupVersion, pushPanelForConversation, type } =
conversationTitle, this.props;
id,
groupVersion,
pushPanelForConversation,
type,
} = this.props;
if (conversationTitle !== undefined) { if (conversationTitle !== undefined) {
return ( return (
@ -589,14 +582,14 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
switch (type) { switch (type) {
case 'direct': case 'direct':
onClick = () => { onClick = () => {
pushPanelForConversation(id, { type: PanelType.ConversationDetails }); pushPanelForConversation({ type: PanelType.ConversationDetails });
}; };
break; break;
case 'group': { case 'group': {
const hasGV2AdminEnabled = groupVersion === 2; const hasGV2AdminEnabled = groupVersion === 2;
onClick = hasGV2AdminEnabled onClick = hasGV2AdminEnabled
? () => { ? () => {
pushPanelForConversation(id, { pushPanelForConversation({
type: PanelType.ConversationDetails, type: PanelType.ConversationDetails,
}); });
} }

View file

@ -167,6 +167,7 @@ export type AudioAttachmentProps = {
id: string; id: string;
conversationId: string; conversationId: string;
played: boolean; played: boolean;
pushPanelForConversation: PushPanelForConversationActionType;
status?: MessageStatusType; status?: MessageStatusType;
textPending?: boolean; textPending?: boolean;
timestamp: number; timestamp: number;
@ -750,27 +751,25 @@ export class Message extends React.PureComponent<Props, State> {
} }
const { const {
conversationId,
deletedForEveryone, deletedForEveryone,
direction, direction,
expirationLength, expirationLength,
expirationTimestamp, expirationTimestamp,
i18n,
id,
isSticker, isSticker,
isTapToViewExpired, isTapToViewExpired,
status,
i18n,
pushPanelForConversation, pushPanelForConversation,
status,
text, text,
textAttachment, textAttachment,
timestamp, timestamp,
id,
} = this.props; } = this.props;
const isStickerLike = isSticker || this.canRenderStickerLikeEmoji(); const isStickerLike = isSticker || this.canRenderStickerLikeEmoji();
return ( return (
<MessageMetadata <MessageMetadata
conversationId={conversationId}
deletedForEveryone={deletedForEveryone} deletedForEveryone={deletedForEveryone}
direction={direction} direction={direction}
expirationLength={expirationLength} expirationLength={expirationLength}
@ -827,23 +826,24 @@ export class Message extends React.PureComponent<Props, State> {
public renderAttachment(): JSX.Element | null { public renderAttachment(): JSX.Element | null {
const { const {
attachments, attachments,
conversationId,
direction, direction,
expirationLength, expirationLength,
expirationTimestamp, expirationTimestamp,
i18n, i18n,
id, id,
conversationId,
isSticker, isSticker,
kickOffAttachmentDownload, kickOffAttachmentDownload,
markAttachmentAsCorrupted, markAttachmentAsCorrupted,
pushPanelForConversation,
quote, quote,
readStatus, readStatus,
reducedMotion, reducedMotion,
renderAudioAttachment, renderAudioAttachment,
renderingContext, renderingContext,
showLightbox,
shouldCollapseAbove, shouldCollapseAbove,
shouldCollapseBelow, shouldCollapseBelow,
showLightbox,
status, status,
text, text,
textAttachment, textAttachment,
@ -966,6 +966,7 @@ export class Message extends React.PureComponent<Props, State> {
id, id,
conversationId, conversationId,
played, played,
pushPanelForConversation,
status, status,
textPending: textAttachment?.pending, textPending: textAttachment?.pending,
timestamp, timestamp,
@ -1562,7 +1563,6 @@ export class Message extends React.PureComponent<Props, State> {
public renderEmbeddedContact(): JSX.Element | null { public renderEmbeddedContact(): JSX.Element | null {
const { const {
contact, contact,
conversationId,
conversationType, conversationType,
direction, direction,
i18n, i18n,
@ -1598,7 +1598,7 @@ export class Message extends React.PureComponent<Props, State> {
} }
: undefined; : undefined;
pushPanelForConversation(conversationId, { pushPanelForConversation({
type: PanelType.ContactDetails, type: PanelType.ContactDetails,
args: { args: {
contact, contact,
@ -2243,7 +2243,6 @@ export class Message extends React.PureComponent<Props, State> {
const { const {
attachments, attachments,
contact, contact,
conversationId,
showLightboxForViewOnceMedia, showLightboxForViewOnceMedia,
direction, direction,
giftBadge, giftBadge,
@ -2381,7 +2380,7 @@ export class Message extends React.PureComponent<Props, State> {
uuid: contact.uuid, uuid: contact.uuid,
} }
: undefined; : undefined;
pushPanelForConversation(conversationId, { pushPanelForConversation({
type: PanelType.ContactDetails, type: PanelType.ContactDetails,
args: { args: {
contact, contact,

View file

@ -9,6 +9,7 @@ import { animated, useSpring } from '@react-spring/web';
import type { LocalizerType } from '../../types/Util'; import type { LocalizerType } from '../../types/Util';
import type { AttachmentType } from '../../types/Attachment'; import type { AttachmentType } from '../../types/Attachment';
import type { PushPanelForConversationActionType } from '../../state/ducks/conversations';
import { isDownloaded } from '../../types/Attachment'; import { isDownloaded } from '../../types/Attachment';
import type { DirectionType, MessageStatusType } from './Message'; import type { DirectionType, MessageStatusType } from './Message';
@ -16,7 +17,6 @@ import type { ComputePeaksResult } from '../GlobalAudioContext';
import { MessageMetadata } from './MessageMetadata'; import { MessageMetadata } from './MessageMetadata';
import * as log from '../../logging/log'; import * as log from '../../logging/log';
import type { ActiveAudioPlayerStateType } from '../../state/ducks/audioPlayer'; import type { ActiveAudioPlayerStateType } from '../../state/ducks/audioPlayer';
import type { PushPanelForConversationActionType } from '../../state/ducks/conversations';
export type OwnProps = Readonly<{ export type OwnProps = Readonly<{
active: ActiveAudioPlayerStateType | undefined; active: ActiveAudioPlayerStateType | undefined;
@ -592,7 +592,6 @@ export function MessageAudio(props: Props): JSX.Element {
{!withContentBelow && !collapseMetadata && ( {!withContentBelow && !collapseMetadata && (
<MessageMetadata <MessageMetadata
conversationId={conversationId}
direction={direction} direction={direction}
expirationLength={expirationLength} expirationLength={expirationLength}
expirationTimestamp={expirationTimestamp} expirationTimestamp={expirationTimestamp}

View file

@ -8,14 +8,13 @@ import Measure from 'react-measure';
import type { LocalizerType } from '../../types/Util'; import type { LocalizerType } from '../../types/Util';
import type { DirectionType, MessageStatusType } from './Message'; import type { DirectionType, MessageStatusType } from './Message';
import type { PushPanelForConversationActionType } from '../../state/ducks/conversations';
import { ExpireTimer } from './ExpireTimer'; import { ExpireTimer } from './ExpireTimer';
import { MessageTimestamp } from './MessageTimestamp'; import { MessageTimestamp } from './MessageTimestamp';
import { Spinner } from '../Spinner';
import type { PushPanelForConversationActionType } from '../../state/ducks/conversations';
import { PanelType } from '../../types/Panels'; import { PanelType } from '../../types/Panels';
import { Spinner } from '../Spinner';
type PropsType = { type PropsType = {
conversationId: string;
deletedForEveryone?: boolean; deletedForEveryone?: boolean;
direction: DirectionType; direction: DirectionType;
expirationLength?: number; expirationLength?: number;
@ -35,7 +34,6 @@ type PropsType = {
}; };
export function MessageMetadata({ export function MessageMetadata({
conversationId,
deletedForEveryone, deletedForEveryone,
direction, direction,
expirationLength, expirationLength,
@ -80,7 +78,7 @@ export function MessageMetadata({
event.stopPropagation(); event.stopPropagation();
event.preventDefault(); event.preventDefault();
pushPanelForConversation(conversationId, { pushPanelForConversation({
type: PanelType.MessageDetails, type: PanelType.MessageDetails,
args: { messageId: id }, args: { messageId: id },
}); });

View file

@ -23,12 +23,12 @@ import type {
PropsData as MessagePropsData, PropsData as MessagePropsData,
PropsHousekeeping, PropsHousekeeping,
} from './Message'; } from './Message';
import type { PushPanelForConversationActionType } from '../../state/ducks/conversations';
import { doesMessageBodyOverflow } from './MessageBodyReadMore'; import { doesMessageBodyOverflow } from './MessageBodyReadMore';
import type { Props as ReactionPickerProps } from './ReactionPicker'; import type { Props as ReactionPickerProps } from './ReactionPicker';
import { ConfirmationDialog } from '../ConfirmationDialog'; import { ConfirmationDialog } from '../ConfirmationDialog';
import { useToggleReactionPicker } from '../../hooks/useKeyboardShortcuts'; import { useToggleReactionPicker } from '../../hooks/useKeyboardShortcuts';
import { PanelType } from '../../types/Panels'; import { PanelType } from '../../types/Panels';
import type { PushPanelForConversationActionType } from '../../state/ducks/conversations';
export type PropsData = { export type PropsData = {
canDownload: boolean; canDownload: boolean;
@ -46,8 +46,8 @@ export type PropsActions = {
messageId: string; messageId: string;
}) => void; }) => void;
deleteMessageForEveryone: (id: string) => void; deleteMessageForEveryone: (id: string) => void;
toggleForwardMessageModal: (id: string) => void;
pushPanelForConversation: PushPanelForConversationActionType; pushPanelForConversation: PushPanelForConversationActionType;
toggleForwardMessageModal: (id: string) => void;
reactToMessage: ( reactToMessage: (
id: string, id: string,
{ emoji, remove }: { emoji: string; remove: boolean } { emoji, remove }: { emoji: string; remove: boolean }
@ -75,42 +75,42 @@ type Trigger = {
*/ */
export function TimelineMessage(props: Props): JSX.Element { export function TimelineMessage(props: Props): JSX.Element {
const { const {
i18n,
id,
author,
attachments, attachments,
author,
canDeleteForEveryone,
canDownload, canDownload,
canReact, canReact,
canReply, canReply,
canRetry, canRetry,
canDeleteForEveryone,
canRetryDeleteForEveryone, canRetryDeleteForEveryone,
contact, contact,
payment,
conversationId,
containerElementRef, containerElementRef,
containerWidthBreakpoint, containerWidthBreakpoint,
deletedForEveryone, conversationId,
deleteMessage, deleteMessage,
deleteMessageForEveryone, deleteMessageForEveryone,
deletedForEveryone,
direction, direction,
giftBadge, giftBadge,
i18n,
id,
isSelected, isSelected,
isSticker, isSticker,
isTapToView, isTapToView,
kickOffAttachmentDownload,
payment,
pushPanelForConversation, pushPanelForConversation,
reactToMessage, reactToMessage,
setQuoteByMessageId,
renderReactionPicker,
renderEmojiPicker, renderEmojiPicker,
retryMessageSend, renderReactionPicker,
retryDeleteForEveryone, retryDeleteForEveryone,
retryMessageSend,
saveAttachment,
selectedReaction, selectedReaction,
toggleForwardMessageModal, setQuoteByMessageId,
text, text,
timestamp, timestamp,
kickOffAttachmentDownload, toggleForwardMessageModal,
saveAttachment,
} = props; } = props;
const [reactionPickerRoot, setReactionPickerRoot] = useState< const [reactionPickerRoot, setReactionPickerRoot] = useState<
@ -410,7 +410,7 @@ export function TimelineMessage(props: Props): JSX.Element {
canDeleteForEveryone ? () => setHasDOEConfirmation(true) : undefined canDeleteForEveryone ? () => setHasDOEConfirmation(true) : undefined
} }
onMoreInfo={() => onMoreInfo={() =>
pushPanelForConversation(conversationId, { pushPanelForConversation({
type: PanelType.MessageDetails, type: PanelType.MessageDetails,
args: { messageId: id }, args: { messageId: id },
}) })

View file

@ -441,7 +441,7 @@ export function ConversationDetails({
} }
label={i18n('showChatColorEditor')} label={i18n('showChatColorEditor')}
onClick={() => { onClick={() => {
pushPanelForConversation(conversation.id, { pushPanelForConversation({
type: PanelType.ChatColorEditor, type: PanelType.ChatColorEditor,
}); });
}} }}
@ -464,7 +464,7 @@ export function ConversationDetails({
} }
label={i18n('ConversationDetails--notifications')} label={i18n('ConversationDetails--notifications')}
onClick={() => onClick={() =>
pushPanelForConversation(conversation.id, { pushPanelForConversation({
type: PanelType.NotificationSettings, type: PanelType.NotificationSettings,
}) })
} }
@ -520,7 +520,7 @@ export function ConversationDetails({
} }
label={i18n('ConversationDetails--group-link')} label={i18n('ConversationDetails--group-link')}
onClick={() => onClick={() =>
pushPanelForConversation(conversation.id, { pushPanelForConversation({
type: PanelType.GroupLinkManagement, type: PanelType.GroupLinkManagement,
}) })
} }
@ -536,7 +536,7 @@ export function ConversationDetails({
} }
label={i18n('ConversationDetails--requests-and-invites')} label={i18n('ConversationDetails--requests-and-invites')}
onClick={() => onClick={() =>
pushPanelForConversation(conversation.id, { pushPanelForConversation({
type: PanelType.GroupInvites, type: PanelType.GroupInvites,
}) })
} }
@ -552,7 +552,7 @@ export function ConversationDetails({
} }
label={i18n('permissions')} label={i18n('permissions')}
onClick={() => onClick={() =>
pushPanelForConversation(conversation.id, { pushPanelForConversation({
type: PanelType.GroupPermissions, type: PanelType.GroupPermissions,
}) })
} }
@ -566,7 +566,7 @@ export function ConversationDetails({
i18n={i18n} i18n={i18n}
loadRecentMediaItems={loadRecentMediaItems} loadRecentMediaItems={loadRecentMediaItems}
showAllMedia={() => showAllMedia={() =>
pushPanelForConversation(conversation.id, { pushPanelForConversation({
type: PanelType.AllMedia, type: PanelType.AllMedia,
}) })
} }

View file

@ -2,7 +2,6 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import { import {
groupBy,
difference, difference,
isEmpty, isEmpty,
isEqual, isEqual,
@ -14,7 +13,6 @@ import {
omit, omit,
partition, partition,
pick, pick,
reject,
union, union,
without, without,
} from 'lodash'; } from 'lodash';
@ -43,7 +41,6 @@ import { drop } from '../util/drop';
import { dropNull } from '../util/dropNull'; import { dropNull } from '../util/dropNull';
import { incrementMessageCounter } from '../util/incrementMessageCounter'; import { incrementMessageCounter } from '../util/incrementMessageCounter';
import type { ConversationModel } from './conversations'; import type { ConversationModel } from './conversations';
import type { Contact as SmartMessageDetailContact } from '../state/smart/MessageDetail';
import { getCallingNotificationText } from '../util/callingNotification'; import { getCallingNotificationText } from '../util/callingNotification';
import type { import type {
ProcessedDataMessage, ProcessedDataMessage,
@ -51,7 +48,6 @@ import type {
ProcessedUnidentifiedDeliveryStatus, ProcessedUnidentifiedDeliveryStatus,
CallbackResultType, CallbackResultType,
} from '../textsecure/Types.d'; } from '../textsecure/Types.d';
import type { Props as PropsForMessageDetails } from '../components/conversation/MessageDetail';
import { SendMessageProtoError } from '../textsecure/Errors'; import { SendMessageProtoError } from '../textsecure/Errors';
import * as expirationTimer from '../util/expirationTimer'; import * as expirationTimer from '../util/expirationTimer';
import { getUserLanguages } from '../util/userLanguages'; import { getUserLanguages } from '../util/userLanguages';
@ -76,7 +72,6 @@ import type { SendStateByConversationId } from '../messages/MessageSendState';
import { import {
SendActionType, SendActionType,
SendStatus, SendStatus,
isMessageJustForMe,
isSent, isSent,
sendStateReducer, sendStateReducer,
someSendStatus, someSendStatus,
@ -100,7 +95,6 @@ import {
getAttachmentsForMessage, getAttachmentsForMessage,
getMessagePropStatus, getMessagePropStatus,
getPropsForCallHistory, getPropsForCallHistory,
getPropsForMessage,
hasErrors, hasErrors,
isCallHistory, isCallHistory,
isChatSessionRefreshed, isChatSessionRefreshed,
@ -129,8 +123,6 @@ import {
getCallSelector, getCallSelector,
getActiveCall, getActiveCall,
} from '../state/selectors/calling'; } from '../state/selectors/calling';
import { getAccountSelector } from '../state/selectors/accounts';
import { getContactNameColorSelector } from '../state/selectors/conversations';
import { import {
MessageReceipts, MessageReceipts,
MessageReceiptType, MessageReceiptType,
@ -263,11 +255,6 @@ async function shouldReplyNotifyUser(
/* eslint-disable more/no-then */ /* eslint-disable more/no-then */
export type MinimalPropsForMessageDetails = Pick<
PropsForMessageDetails,
'sentAt' | 'receivedAt' | 'message' | 'errors' | 'contacts'
>;
window.Whisper = window.Whisper || {}; window.Whisper = window.Whisper || {};
const { Message: TypedMessage } = window.Signal.Types; const { Message: TypedMessage } = window.Signal.Types;
@ -474,137 +461,6 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
}); });
} }
getPropsForMessageDetail(
ourConversationId: string
): MinimalPropsForMessageDetails {
const newIdentity = window.i18n('newIdentity');
const OUTGOING_KEY_ERROR = 'OutgoingIdentityKeyError';
const sendStateByConversationId =
this.get('sendStateByConversationId') || {};
const unidentifiedDeliveries = this.get('unidentifiedDeliveries') || [];
const unidentifiedDeliveriesSet = new Set(
map(
unidentifiedDeliveries,
identifier =>
window.ConversationController.getConversationId(identifier) as string
)
);
let conversationIds: Array<string>;
/* eslint-disable @typescript-eslint/no-non-null-assertion */
if (isIncoming(this.attributes)) {
conversationIds = [getContactId(this.attributes)!];
} else if (!isEmpty(sendStateByConversationId)) {
if (isMessageJustForMe(sendStateByConversationId, ourConversationId)) {
conversationIds = [ourConversationId];
} else {
conversationIds = Object.keys(sendStateByConversationId).filter(
id => id !== ourConversationId
);
}
} else {
// Older messages don't have the recipients included on the message, so we fall back
// to the conversation's current recipients
conversationIds = (this.getConversation()?.getRecipients() || []).map(
(id: string) => window.ConversationController.getConversationId(id)!
);
}
/* eslint-enable @typescript-eslint/no-non-null-assertion */
// This will make the error message for outgoing key errors a bit nicer
const allErrors = (this.get('errors') || []).map(error => {
if (error.name === OUTGOING_KEY_ERROR) {
// eslint-disable-next-line no-param-reassign
error.message = newIdentity;
}
return error;
});
// If an error has a specific number it's associated with, we'll show it next to
// that contact. Otherwise, it will be a standalone entry.
const errors = reject(allErrors, error =>
Boolean(error.identifier || error.number)
);
const errorsGroupedById = groupBy(allErrors, error => {
const identifier = error.identifier || error.number;
if (!identifier) {
return null;
}
return window.ConversationController.getConversationId(identifier);
});
const contacts: ReadonlyArray<SmartMessageDetailContact> =
conversationIds.map(id => {
const errorsForContact = getOwn(errorsGroupedById, id);
const isOutgoingKeyError = Boolean(
errorsForContact?.some(error => error.name === OUTGOING_KEY_ERROR)
);
const isUnidentifiedDelivery =
window.storage.get('unidentifiedDeliveryIndicators', false) &&
this.isUnidentifiedDelivery(id, unidentifiedDeliveriesSet);
const sendState = getOwn(sendStateByConversationId, id);
let status = sendState?.status;
// If a message was only sent to yourself (Note to Self or a lonely group), it
// is shown read.
if (id === ourConversationId && status && isSent(status)) {
status = SendStatus.Read;
}
const statusTimestamp = sendState?.updatedAt;
return {
...findAndFormatContact(id),
status,
statusTimestamp:
statusTimestamp === this.get('sent_at')
? undefined
: statusTimestamp,
errors: errorsForContact,
isOutgoingKeyError,
isUnidentifiedDelivery,
};
});
return {
sentAt: this.get('sent_at'),
receivedAt: this.getReceivedAt(),
message: getPropsForMessage(this.attributes, {
conversationSelector: findAndFormatContact,
ourConversationId,
ourNumber: window.textsecure.storage.user.getNumber(),
ourACI: window.textsecure.storage.user
.getCheckedUuid(UUIDKind.ACI)
.toString(),
ourPNI: window.textsecure.storage.user
.getCheckedUuid(UUIDKind.PNI)
.toString(),
regionCode: window.storage.get('regionCode', 'ZZ'),
accountSelector: (identifier?: string) => {
const state = window.reduxStore.getState();
const accountSelector = getAccountSelector(state);
return accountSelector(identifier);
},
contactNameColorSelector: (
conversationId: string,
contactId: string | undefined
) => {
const state = window.reduxStore.getState();
const contactNameColorSelector = getContactNameColorSelector(state);
return contactNameColorSelector(conversationId, contactId);
},
}),
errors,
contacts,
};
}
// Dependencies of prop-generation functions // Dependencies of prop-generation functions
getConversation(): ConversationModel | undefined { getConversation(): ConversationModel | undefined {
return window.ConversationController.get(this.get('conversationId')); return window.ConversationController.get(this.get('conversationId'));

View file

@ -26,7 +26,6 @@ import { DisappearingTimeDialog } from './components/DisappearingTimeDialog';
// State // State
import { createApp } from './state/roots/createApp'; import { createApp } from './state/roots/createApp';
import { createGroupV2JoinModal } from './state/roots/createGroupV2JoinModal'; import { createGroupV2JoinModal } from './state/roots/createGroupV2JoinModal';
import { createMessageDetail } from './state/roots/createMessageDetail';
import { createSafetyNumberViewer } from './state/roots/createSafetyNumberViewer'; import { createSafetyNumberViewer } from './state/roots/createSafetyNumberViewer';
import { createShortcutGuideModal } from './state/roots/createShortcutGuideModal'; import { createShortcutGuideModal } from './state/roots/createShortcutGuideModal';
@ -395,7 +394,6 @@ export const setup = (options: {
const Roots = { const Roots = {
createApp, createApp,
createGroupV2JoinModal, createGroupV2JoinModal,
createMessageDetail,
createSafetyNumberViewer, createSafetyNumberViewer,
createShortcutGuideModal, createShortcutGuideModal,
}; };

View file

@ -6,9 +6,11 @@ import type { ThunkAction } from 'redux-thunk';
import * as Errors from '../../types/errors'; import * as Errors from '../../types/errors';
import * as log from '../../logging/log'; import * as log from '../../logging/log';
import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions';
import type { StateType as RootStateType } from '../reducer'; import type { StateType as RootStateType } from '../reducer';
import type { UUIDStringType } from '../../types/UUID'; import type { UUIDStringType } from '../../types/UUID';
import { getUuidsForE164s } from '../../util/getUuidsForE164s'; import { getUuidsForE164s } from '../../util/getUuidsForE164s';
import { useBoundActions } from '../../hooks/useBoundActions';
import type { NoopActionType } from './noop'; import type { NoopActionType } from './noop';
@ -36,6 +38,10 @@ export const actions = {
checkForAccount, checkForAccount,
}; };
export const useAccountsActions = (): BoundActionCreatorsMapObject<
typeof actions
> => useBoundActions(actions);
function checkForAccount( function checkForAccount(
phoneNumber: string phoneNumber: string
): ThunkAction< ): ThunkAction<

View file

@ -125,11 +125,12 @@ import {
initiateMigrationToGroupV2 as doInitiateMigrationToGroupV2, initiateMigrationToGroupV2 as doInitiateMigrationToGroupV2,
} from '../../groups'; } from '../../groups';
import { getMessageById } from '../../messages/getMessageById'; import { getMessageById } from '../../messages/getMessageById';
import type { PanelRenderType } from '../../types/Panels'; import type { PanelRenderType, PanelRequestType } from '../../types/Panels';
import type { ConversationQueueJobData } from '../../jobs/conversationJobQueue'; import type { ConversationQueueJobData } from '../../jobs/conversationJobQueue';
import { isOlderThan } from '../../util/timestamp'; import { isOlderThan } from '../../util/timestamp';
import { DAY } from '../../util/durations'; import { DAY } from '../../util/durations';
import { isNotNil } from '../../util/isNotNil'; import { isNotNil } from '../../util/isNotNil';
import { PanelType } from '../../types/Panels';
import { startConversation } from '../../util/startConversation'; import { startConversation } from '../../util/startConversation';
// State // State
@ -407,6 +408,7 @@ export type ConversationsStateType = {
selectedMessageCounter: number; selectedMessageCounter: number;
selectedMessageSource: SelectedMessageSource | undefined; selectedMessageSource: SelectedMessageSource | undefined;
selectedConversationPanels: Array<PanelRenderType>; selectedConversationPanels: Array<PanelRenderType>;
selectedMessageForDetails?: MessageAttributesType;
showArchived: boolean; showArchived: boolean;
composer?: ComposerStateType; composer?: ComposerStateType;
contactSpoofingReview?: ContactSpoofingReviewStateType; contactSpoofingReview?: ContactSpoofingReviewStateType;
@ -817,11 +819,11 @@ type ReplaceAvatarsActionType = {
export type ConversationActionType = export type ConversationActionType =
| CancelVerificationDataByConversationActionType | CancelVerificationDataByConversationActionType
| ClearCancelledVerificationActionType | ClearCancelledVerificationActionType
| ClearVerificationDataByConversationActionType
| ClearGroupCreationErrorActionType | ClearGroupCreationErrorActionType
| ClearInvitedUuidsForNewlyCreatedGroupActionType | ClearInvitedUuidsForNewlyCreatedGroupActionType
| ClearSelectedMessageActionType | ClearSelectedMessageActionType
| ClearUnreadMetricsActionType | ClearUnreadMetricsActionType
| ClearVerificationDataByConversationActionType
| CloseContactSpoofingReviewActionType | CloseContactSpoofingReviewActionType
| CloseMaximumGroupSizeModalActionType | CloseMaximumGroupSizeModalActionType
| CloseRecommendedGroupSizeModalActionType | CloseRecommendedGroupSizeModalActionType
@ -843,6 +845,7 @@ export type ConversationActionType =
| MessageChangedActionType | MessageChangedActionType
| MessageDeletedActionType | MessageDeletedActionType
| MessageExpandedActionType | MessageExpandedActionType
| MessageExpiredActionType
| MessageSelectedActionType | MessageSelectedActionType
| MessagesAddedActionType | MessagesAddedActionType
| MessagesResetActionType | MessagesResetActionType
@ -871,8 +874,8 @@ export type ConversationActionType =
| ShowSendAnywayDialogActionType | ShowSendAnywayDialogActionType
| StartComposingActionType | StartComposingActionType
| StartSettingGroupMetadataActionType | StartSettingGroupMetadataActionType
| ToggleConversationInChooseMembersActionType | ToggleComposeEditingAvatarActionType
| ToggleComposeEditingAvatarActionType; | ToggleConversationInChooseMembersActionType;
// Action Creators // Action Creators
@ -1515,7 +1518,7 @@ function deleteMessage({
} else { } else {
conversation.decrementMessageCount(); conversation.decrementMessageCount();
} }
popPanelForConversation(conversationId)(dispatch, getState, undefined); popPanelForConversation()(dispatch, getState, undefined);
dispatch({ dispatch({
type: 'NOOP', type: 'NOOP',
@ -2536,44 +2539,50 @@ function setIsFetchingUUID(
} }
export type PushPanelForConversationActionType = ( export type PushPanelForConversationActionType = (
conversationId: string, panel: PanelRequestType
panel: PanelRenderType
) => unknown; ) => unknown;
function pushPanelForConversation( function pushPanelForConversation(
conversationId: string, panel: PanelRequestType
panel: PanelRenderType ): ThunkAction<void, RootStateType, unknown, PushPanelActionType> {
): PushPanelActionType { return async dispatch => {
const conversation = window.ConversationController.get(conversationId); if (panel.type === PanelType.MessageDetails) {
if (!conversation) { const { messageId } = panel.args;
throw new Error(
`addPanelToConversation: No conversation found for conversation ${conversationId}`
);
}
conversation.trigger('pushPanel', panel); const message = await getMessageById(messageId);
if (!message) {
throw new Error(
'pushPanelForConversation: could not find message for MessageDetails'
);
}
dispatch({
type: PUSH_PANEL,
payload: {
type: PanelType.MessageDetails,
args: {
message: message.attributes,
},
},
});
return;
}
return { dispatch({
type: PUSH_PANEL, type: PUSH_PANEL,
payload: panel, payload: panel,
});
}; };
} }
export type PopPanelForConversationActionType = ( export type PopPanelForConversationActionType = () => unknown;
conversationId: string
) => unknown;
function popPanelForConversation( function popPanelForConversation(): ThunkAction<
conversationId: string void,
): ThunkAction<void, RootStateType, unknown, PopPanelActionType> { RootStateType,
unknown,
PopPanelActionType
> {
return (dispatch, getState) => { return (dispatch, getState) => {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error(
`addPanelToConversation: No conversation found for conversation ${conversationId}`
);
}
const { conversations } = getState(); const { conversations } = getState();
const { selectedConversationPanels } = conversations; const { selectedConversationPanels } = conversations;
@ -2581,14 +2590,6 @@ function popPanelForConversation(
return; return;
} }
const panel = [...selectedConversationPanels].pop();
if (!panel) {
return;
}
conversation.trigger('popPanel', panel);
dispatch({ dispatch({
type: POP_PANEL, type: POP_PANEL,
payload: null, payload: null,
@ -3718,6 +3719,30 @@ function visitListsInVerificationData(
return result; return result;
} }
function maybeUpdateSelectedMessageForDetails(
{
messageId,
selectedMessageForDetails,
}: {
messageId: string;
selectedMessageForDetails: MessageAttributesType | undefined;
},
state: ConversationsStateType
): ConversationsStateType {
if (!state.selectedMessageForDetails) {
return state;
}
if (state.selectedMessageForDetails.id !== messageId) {
return state;
}
return {
...state,
selectedMessageForDetails,
};
}
export function reducer( export function reducer(
state: Readonly<ConversationsStateType> = getEmptyState(), state: Readonly<ConversationsStateType> = getEmptyState(),
action: Readonly<ConversationActionType | StoryDistributionListsActionType> action: Readonly<ConversationActionType | StoryDistributionListsActionType>
@ -4242,18 +4267,26 @@ export function reducer(
verificationDataByConversation, verificationDataByConversation,
}; };
} }
if (action.type === 'MESSAGE_CHANGED') { if (action.type === 'MESSAGE_CHANGED') {
const { id, conversationId, data } = action.payload; const { id, conversationId, data } = action.payload;
const existingConversation = state.messagesByConversation[conversationId]; const existingConversation = state.messagesByConversation[conversationId];
// We don't keep track of messages unless their conversation is loaded... // We don't keep track of messages unless their conversation is loaded...
if (!existingConversation) { if (!existingConversation) {
return state; return maybeUpdateSelectedMessageForDetails(
{ messageId: id, selectedMessageForDetails: data },
state
);
} }
// ...and we've already loaded that message once // ...and we've already loaded that message once
const existingMessage = getOwn(state.messagesLookup, id); const existingMessage = getOwn(state.messagesLookup, id);
if (!existingMessage) { if (!existingMessage) {
return state; return maybeUpdateSelectedMessageForDetails(
{ messageId: id, selectedMessageForDetails: data },
state
);
} }
const conversationAttrs = state.conversationLookup[conversationId]; const conversationAttrs = state.conversationLookup[conversationId];
@ -4265,7 +4298,13 @@ export function reducer(
const toIncrement = data.reactions?.length ? 1 : 0; const toIncrement = data.reactions?.length ? 1 : 0;
return { return {
...state, ...maybeUpdateSelectedMessageForDetails(
{
messageId: id,
selectedMessageForDetails: data,
},
state
),
messagesByConversation: { messagesByConversation: {
...state.messagesByConversation, ...state.messagesByConversation,
[conversationId]: { [conversationId]: {
@ -4283,6 +4322,14 @@ export function reducer(
}, },
}; };
} }
if (action.type === MESSAGE_EXPIRED) {
return maybeUpdateSelectedMessageForDetails(
{ messageId: action.payload.id, selectedMessageForDetails: undefined },
state
);
}
if (action.type === 'MESSAGE_EXPANDED') { if (action.type === 'MESSAGE_EXPANDED') {
const { id, displayLimit } = action.payload; const { id, displayLimit } = action.payload;
@ -4459,7 +4506,10 @@ export function reducer(
const existingConversation = messagesByConversation[conversationId]; const existingConversation = messagesByConversation[conversationId];
if (!existingConversation) { if (!existingConversation) {
return state; return maybeUpdateSelectedMessageForDetails(
{ messageId: id, selectedMessageForDetails: undefined },
state
);
} }
// Assuming that we always have contiguous groups of messages in memory, the removal // Assuming that we always have contiguous groups of messages in memory, the removal
@ -4503,7 +4553,10 @@ export function reducer(
} }
return { return {
...state, ...maybeUpdateSelectedMessageForDetails(
{ messageId: id, selectedMessageForDetails: undefined },
state
),
messagesLookup: omit(messagesLookup, id), messagesLookup: omit(messagesLookup, id),
messagesByConversation: { messagesByConversation: {
[conversationId]: { [conversationId]: {
@ -4792,6 +4845,17 @@ export function reducer(
} }
if (action.type === PUSH_PANEL) { if (action.type === PUSH_PANEL) {
if (action.payload.type === PanelType.MessageDetails) {
return {
...state,
selectedConversationPanels: [
...state.selectedConversationPanels,
action.payload,
],
selectedMessageForDetails: action.payload.args.message,
};
}
return { return {
...state, ...state,
selectedConversationPanels: [ selectedConversationPanels: [
@ -4804,7 +4868,19 @@ export function reducer(
if (action.type === POP_PANEL) { if (action.type === POP_PANEL) {
const { selectedConversationPanels } = state; const { selectedConversationPanels } = state;
const nextPanels = [...selectedConversationPanels]; const nextPanels = [...selectedConversationPanels];
nextPanels.pop(); const panel = nextPanels.pop();
if (!panel) {
return state;
}
if (panel.type === PanelType.MessageDetails) {
return {
...state,
selectedConversationPanels: nextPanels,
selectedMessageForDetails: undefined,
};
}
return { return {
...state, ...state,

View file

@ -1,19 +0,0 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ReactElement } from 'react';
import React from 'react';
import { Provider } from 'react-redux';
import type { Store } from 'redux';
import { SmartMessageDetail } from '../smart/MessageDetail';
export const createMessageDetail = (
store: Store,
props: Parameters<typeof SmartMessageDetail>[0]
): ReactElement => (
<Provider store={store}>
<SmartMessageDetail {...props} />
</Provider>
);

View file

@ -65,8 +65,7 @@ import { TimelineMessageLoadingState } from '../../util/timelineUtil';
import { isSignalConversation } from '../../util/isSignalConversation'; import { isSignalConversation } from '../../util/isSignalConversation';
import { reduce } from '../../util/iterables'; import { reduce } from '../../util/iterables';
import { getConversationTitleForPanelType } from '../../util/getConversationTitleForPanelType'; import { getConversationTitleForPanelType } from '../../util/getConversationTitleForPanelType';
import type { ReactPanelRenderType, PanelRenderType } from '../../types/Panels'; import type { PanelRenderType } from '../../types/Panels';
import { isPanelHandledByReact } from '../../types/Panels';
let placeholderContact: ConversationType; let placeholderContact: ConversationType;
export const getPlaceholderContact = (): ConversationType => { export const getPlaceholderContact = (): ConversationType => {
@ -1135,7 +1134,7 @@ export const getHideStoryConversationIds = createSelector(
) )
); );
const getTopPanel = createSelector( export const getTopPanel = createSelector(
getConversations, getConversations,
(conversations): PanelRenderType | undefined => (conversations): PanelRenderType | undefined =>
conversations.selectedConversationPanels[ conversations.selectedConversationPanels[
@ -1143,22 +1142,6 @@ const getTopPanel = createSelector(
] ]
); );
export const getTopPanelRenderableByReact = createSelector(
getConversations,
(conversations): ReactPanelRenderType | undefined => {
const topPanel =
conversations.selectedConversationPanels[
conversations.selectedConversationPanels.length - 1
];
if (!isPanelHandledByReact(topPanel)) {
return;
}
return topPanel;
}
);
export const getConversationTitle = createSelector( export const getConversationTitle = createSelector(
getIntl, getIntl,
getTopPanel, getTopPanel,

View file

@ -1,19 +1,35 @@
// Copyright 2021-2022 Signal Messenger, LLC // Copyright 2021-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import { identity, isEqual, isNumber, isObject, map, omit, pick } from 'lodash'; import {
groupBy,
identity,
isEmpty,
isEqual,
isNumber,
isObject,
map,
omit,
pick,
} from 'lodash';
import { createSelector, createSelectorCreator } from 'reselect'; import { createSelector, createSelectorCreator } from 'reselect';
import filesize from 'filesize'; import filesize from 'filesize';
import getDirection from 'direction'; import getDirection from 'direction';
import emojiRegex from 'emoji-regex'; import emojiRegex from 'emoji-regex';
import LinkifyIt from 'linkify-it'; import LinkifyIt from 'linkify-it';
import type { StateType } from '../reducer';
import type { import type {
LastMessageStatus, LastMessageStatus,
MessageAttributesType,
MessageReactionType, MessageReactionType,
ShallowChallengeError, ShallowChallengeError,
} from '../../model-types.d'; } from '../../model-types.d';
import type {
Contact as SmartMessageDetailContact,
OwnProps as SmartMessageDetailPropsType,
} from '../smart/MessageDetail';
import type { TimelineItemType } from '../../components/conversation/TimelineItem'; import type { TimelineItemType } from '../../components/conversation/TimelineItem';
import type { PropsData } from '../../components/conversation/Message'; import type { PropsData } from '../../components/conversation/Message';
import type { PropsData as TimelineMessagePropsData } from '../../components/conversation/TimelineMessage'; import type { PropsData as TimelineMessagePropsData } from '../../components/conversation/TimelineMessage';
@ -52,6 +68,8 @@ import { ReadStatus } from '../../messages/MessageReadStatus';
import type { CallingNotificationType } from '../../util/callingNotification'; import type { CallingNotificationType } from '../../util/callingNotification';
import { memoizeByRoot } from '../../util/memoizeByRoot'; import { memoizeByRoot } from '../../util/memoizeByRoot';
import { missingCaseError } from '../../util/missingCaseError'; import { missingCaseError } from '../../util/missingCaseError';
import { getRecipients } from '../../util/getRecipients';
import { getOwn } from '../../util/getOwn';
import { isNotNil } from '../../util/isNotNil'; import { isNotNil } from '../../util/isNotNil';
import { isMoreRecentThan } from '../../util/timestamp'; import { isMoreRecentThan } from '../../util/timestamp';
import * as iterables from '../../util/iterables'; import * as iterables from '../../util/iterables';
@ -65,10 +83,11 @@ import {
isMissingRequiredProfileSharing, isMissingRequiredProfileSharing,
} from './conversations'; } from './conversations';
import { import {
getIntl,
getRegionCode, getRegionCode,
getUserACI,
getUserConversationId, getUserConversationId,
getUserNumber, getUserNumber,
getUserACI,
getUserPNI, getUserPNI,
} from './user'; } from './user';
@ -1937,3 +1956,172 @@ export function getLastChallengeError(
return challengeErrors.pop(); return challengeErrors.pop();
} }
const getSelectedMessageForDetails = (
state: StateType
): MessageAttributesType | undefined =>
state.conversations.selectedMessageForDetails;
const OUTGOING_KEY_ERROR = 'OutgoingIdentityKeyError';
export const getMessageDetails = createSelector(
getAccountSelector,
getContactNameColorSelector,
getConversationSelector,
getIntl,
getRegionCode,
getSelectedMessageForDetails,
getUserACI,
getUserPNI,
getUserConversationId,
getUserNumber,
(
accountSelector,
contactNameColorSelector,
conversationSelector,
i18n,
regionCode,
message,
ourACI,
ourPNI,
ourConversationId,
ourNumber
): SmartMessageDetailPropsType | undefined => {
if (!message || !ourConversationId) {
return;
}
const {
errors: messageErrors = [],
sendStateByConversationId = {},
unidentifiedDeliveries = [],
unidentifiedDeliveryReceived,
} = message;
const unidentifiedDeliveriesSet = new Set(
map(
unidentifiedDeliveries,
identifier =>
window.ConversationController.getConversationId(identifier) as string
)
);
let conversationIds: Array<string>;
if (isIncoming(message)) {
conversationIds = [
getContactId(message, {
conversationSelector,
ourConversationId,
ourNumber,
ourACI,
}),
].filter(isNotNil);
} else if (!isEmpty(sendStateByConversationId)) {
if (isMessageJustForMe(sendStateByConversationId, ourConversationId)) {
conversationIds = [ourConversationId];
} else {
conversationIds = Object.keys(sendStateByConversationId).filter(
id => id !== ourConversationId
);
}
} else {
const messageConversation = window.ConversationController.get(
message.conversationId
);
const conversationRecipients = messageConversation
? getRecipients(messageConversation.attributes) || []
: [];
// Older messages don't have the recipients included on the message, so we fall back
// to the conversation's current recipients
conversationIds = conversationRecipients
.map((id: string) =>
window.ConversationController.getConversationId(id)
)
.filter(isNotNil);
}
// This will make the error message for outgoing key errors a bit nicer
const allErrors = messageErrors.map(error => {
if (error.name === OUTGOING_KEY_ERROR) {
return {
...error,
message: i18n('newIdentity'),
};
}
return error;
});
// If an error has a specific number it's associated with, we'll show it next to
// that contact. Otherwise, it will be a standalone entry.
const errors = allErrors.filter(error =>
Boolean(error.identifier || error.number)
);
const errorsGroupedById = groupBy(allErrors, error => {
const identifier = error.identifier || error.number;
if (!identifier) {
return null;
}
return window.ConversationController.getConversationId(identifier);
});
const hasUnidentifiedDeliveryIndicators = window.storage.get(
'unidentifiedDeliveryIndicators',
false
);
const contacts: ReadonlyArray<SmartMessageDetailContact> =
conversationIds.map(id => {
const errorsForContact = getOwn(errorsGroupedById, id);
const isOutgoingKeyError = Boolean(
errorsForContact?.some(error => error.name === OUTGOING_KEY_ERROR)
);
let isUnidentifiedDelivery = false;
if (hasUnidentifiedDeliveryIndicators) {
isUnidentifiedDelivery = isIncoming(message)
? Boolean(unidentifiedDeliveryReceived)
: unidentifiedDeliveriesSet.has(id);
}
const sendState = getOwn(sendStateByConversationId, id);
let status = sendState?.status;
// If a message was only sent to yourself (Note to Self or a lonely group), it
// is shown read.
if (id === ourConversationId && status && isSent(status)) {
status = SendStatus.Read;
}
const statusTimestamp = sendState?.updatedAt;
return {
...conversationSelector(id),
errors: errorsForContact,
isOutgoingKeyError,
isUnidentifiedDelivery,
status,
statusTimestamp:
statusTimestamp === message.timestamp ? undefined : statusTimestamp,
};
});
return {
contacts,
errors,
message: getPropsForMessage(message, {
accountSelector,
contactNameColorSelector,
conversationSelector,
ourACI,
ourConversationId,
ourNumber,
ourPNI,
regionCode,
}),
receivedAt: Number(message.received_at_ms || message.received_at),
};
}
);

View file

@ -3,8 +3,8 @@
import React from 'react'; import React from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import type { PanelRenderType } from '../../types/Panels';
import type { StateType } from '../reducer'; import type { StateType } from '../reducer';
import type { ReactPanelRenderType } from '../../types/Panels';
import * as log from '../../logging/log'; import * as log from '../../logging/log';
import { ContactDetail } from '../../components/conversation/ContactDetail'; import { ContactDetail } from '../../components/conversation/ContactDetail';
import { ConversationView } from '../../components/conversation/ConversationView'; import { ConversationView } from '../../components/conversation/ConversationView';
@ -18,11 +18,12 @@ import { SmartConversationNotificationsSettings } from './ConversationNotificati
import { SmartGV1Members } from './GV1Members'; import { SmartGV1Members } from './GV1Members';
import { SmartGroupLinkManagement } from './GroupLinkManagement'; import { SmartGroupLinkManagement } from './GroupLinkManagement';
import { SmartGroupV2Permissions } from './GroupV2Permissions'; import { SmartGroupV2Permissions } from './GroupV2Permissions';
import { SmartMessageDetail } from './MessageDetail';
import { SmartPendingInvites } from './PendingInvites'; import { SmartPendingInvites } from './PendingInvites';
import { SmartStickerManager } from './StickerManager'; import { SmartStickerManager } from './StickerManager';
import { SmartTimeline } from './Timeline'; import { SmartTimeline } from './Timeline';
import { getIntl } from '../selectors/user'; import { getIntl } from '../selectors/user';
import { getTopPanelRenderableByReact } from '../selectors/conversations'; import { getTopPanel } from '../selectors/conversations';
import { useComposerActions } from '../ducks/composer'; import { useComposerActions } from '../ducks/composer';
import { useConversationsActions } from '../ducks/conversations'; import { useConversationsActions } from '../ducks/conversations';
@ -33,10 +34,10 @@ export type PropsType = {
export function SmartConversationView({ export function SmartConversationView({
conversationId, conversationId,
}: PropsType): JSX.Element { }: PropsType): JSX.Element {
const { startConversation } = useConversationsActions(); const topPanel = useSelector<StateType, PanelRenderType | undefined>(
const topPanel = useSelector<StateType, ReactPanelRenderType | undefined>( getTopPanel
getTopPanelRenderableByReact
); );
const { startConversation } = useConversationsActions();
const { processAttachments } = useComposerActions(); const { processAttachments } = useComposerActions();
const i18n = useSelector(getIntl); const i18n = useSelector(getIntl);
@ -136,6 +137,14 @@ export function SmartConversationView({
); );
} }
if (topPanel.type === PanelType.MessageDetails) {
return (
<div className="panel message-detail-wrapper">
<SmartMessageDetail />
</div>
);
}
if (topPanel.type === PanelType.NotificationSettings) { if (topPanel.type === PanelType.NotificationSettings) {
return ( return (
<div className="panel"> <div className="panel">

View file

@ -1,63 +1,101 @@
// Copyright 2021 Signal Messenger, LLC // Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import { connect } from 'react-redux'; import React, { useEffect } from 'react';
import { useSelector } from 'react-redux';
import type { Props as MessageDetailProps } from '../../components/conversation/MessageDetail'; import type { Props as MessageDetailProps } from '../../components/conversation/MessageDetail';
import { MessageDetail } from '../../components/conversation/MessageDetail'; import { MessageDetail } from '../../components/conversation/MessageDetail';
import { mapDispatchToProps } from '../actions';
import type { StateType } from '../reducer';
import { getPreferredBadgeSelector } from '../selectors/badges';
import { getIntl, getInteractionMode, getTheme } from '../selectors/user';
import { renderAudioAttachment } from './renderAudioAttachment';
import { getContactNameColorSelector } from '../selectors/conversations'; import { getContactNameColorSelector } from '../selectors/conversations';
import type { MinimalPropsForMessageDetails } from '../../models/messages'; import { getIntl, getInteractionMode, getTheme } from '../selectors/user';
import { getMessageDetails } from '../selectors/message';
import { getPreferredBadgeSelector } from '../selectors/badges';
import { renderAudioAttachment } from './renderAudioAttachment';
import { useAccountsActions } from '../ducks/accounts';
import { useConversationsActions } from '../ducks/conversations';
import { useGlobalModalActions } from '../ducks/globalModals';
import { useLightboxActions } from '../ducks/lightbox';
import { useStoriesActions } from '../ducks/stories';
export { Contact } from '../../components/conversation/MessageDetail'; export { Contact } from '../../components/conversation/MessageDetail';
export type PropsWithExtraFunctions = MinimalPropsForMessageDetails & export type OwnProps = Pick<
Pick< MessageDetailProps,
MessageDetailProps, 'contacts' | 'errors' | 'message' | 'receivedAt'
| 'contactNameColor' >;
| 'getPreferredBadge'
| 'i18n'
| 'interactionMode'
| 'renderAudioAttachment'
| 'theme'
>;
const mapStateToProps = ( export function SmartMessageDetail(): JSX.Element | null {
state: StateType, const getContactNameColor = useSelector(getContactNameColorSelector);
props: MinimalPropsForMessageDetails const getPreferredBadge = useSelector(getPreferredBadgeSelector);
): PropsWithExtraFunctions => { const i18n = useSelector(getIntl);
const { contacts, errors, message, receivedAt, sentAt } = props; const interactionMode = useSelector(getInteractionMode);
const messageDetails = useSelector(getMessageDetails);
const theme = useSelector(getTheme);
const { checkForAccount } = useAccountsActions();
const {
clearSelectedMessage,
doubleCheckMissingQuoteReference,
kickOffAttachmentDownload,
markAttachmentAsCorrupted,
openGiftBadge,
popPanelForConversation,
pushPanelForConversation,
saveAttachment,
showConversation,
showExpiredIncomingTapToViewToast,
showExpiredOutgoingTapToViewToast,
startConversation,
} = useConversationsActions();
const { showContactModal, toggleSafetyNumberModal } = useGlobalModalActions();
const { showLightbox, showLightboxForViewOnceMedia } = useLightboxActions();
const { viewStory } = useStoriesActions();
useEffect(() => {
if (!messageDetails) {
popPanelForConversation();
}
}, [messageDetails, popPanelForConversation]);
if (!messageDetails) {
return null;
}
const { contacts, errors, message, receivedAt } = messageDetails;
const contactNameColor = const contactNameColor =
message.conversationType === 'group' message.conversationType === 'group'
? getContactNameColorSelector(state)( ? getContactNameColor(message.conversationId, message.author.id)
message.conversationId,
message.author.id
)
: undefined; : undefined;
const getPreferredBadge = getPreferredBadgeSelector(state); return (
<MessageDetail
return { checkForAccount={checkForAccount}
contacts, clearSelectedMessage={clearSelectedMessage}
contactNameColor, contactNameColor={contactNameColor}
errors, contacts={contacts}
message, doubleCheckMissingQuoteReference={doubleCheckMissingQuoteReference}
receivedAt, errors={errors}
sentAt, getPreferredBadge={getPreferredBadge}
i18n={i18n}
getPreferredBadge, interactionMode={interactionMode}
i18n: getIntl(state), kickOffAttachmentDownload={kickOffAttachmentDownload}
interactionMode: getInteractionMode(state), markAttachmentAsCorrupted={markAttachmentAsCorrupted}
theme: getTheme(state), message={message}
openGiftBadge={openGiftBadge}
renderAudioAttachment, pushPanelForConversation={pushPanelForConversation}
}; receivedAt={receivedAt}
}; renderAudioAttachment={renderAudioAttachment}
saveAttachment={saveAttachment}
const smart = connect(mapStateToProps, mapDispatchToProps); sentAt={message.timestamp}
export const SmartMessageDetail = smart(MessageDetail); showContactModal={showContactModal}
showConversation={showConversation}
showExpiredIncomingTapToViewToast={showExpiredIncomingTapToViewToast}
showExpiredOutgoingTapToViewToast={showExpiredOutgoingTapToViewToast}
showLightbox={showLightbox}
showLightboxForViewOnceMedia={showLightboxForViewOnceMedia}
startConversation={startConversation}
theme={theme}
toggleSafetyNumberModal={toggleSafetyNumberModal}
viewStory={viewStory}
/>
);
}

View file

@ -2,6 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import type { EmbeddedContactType } from './EmbeddedContact'; import type { EmbeddedContactType } from './EmbeddedContact';
import type { MessageAttributesType } from '../model-types.d';
import type { UUIDStringType } from './UUID'; import type { UUIDStringType } from './UUID';
export enum PanelType { export enum PanelType {
@ -18,7 +19,7 @@ export enum PanelType {
StickerManager = 'StickerManager', StickerManager = 'StickerManager',
} }
export type ReactPanelRenderType = export type PanelRequestType =
| { type: PanelType.AllMedia } | { type: PanelType.AllMedia }
| { type: PanelType.ChatColorEditor } | { type: PanelType.ChatColorEditor }
| { | {
@ -36,33 +37,28 @@ export type ReactPanelRenderType =
| { type: PanelType.GroupLinkManagement } | { type: PanelType.GroupLinkManagement }
| { type: PanelType.GroupPermissions } | { type: PanelType.GroupPermissions }
| { type: PanelType.GroupV1Members } | { type: PanelType.GroupV1Members }
| { type: PanelType.MessageDetails; args: { messageId: string } }
| { type: PanelType.NotificationSettings } | { type: PanelType.NotificationSettings }
| { type: PanelType.StickerManager }; | { type: PanelType.StickerManager };
export type BackbonePanelRenderType = { export type PanelRenderType =
type: PanelType.MessageDetails; | { type: PanelType.AllMedia }
args: { messageId: string }; | { type: PanelType.ChatColorEditor }
}; | {
type: PanelType.ContactDetails;
export type PanelRenderType = ReactPanelRenderType | BackbonePanelRenderType; args: {
contact: EmbeddedContactType;
export function isPanelHandledByReact( signalAccount?: {
panel: PanelRenderType phoneNumber: string;
): panel is ReactPanelRenderType { uuid: UUIDStringType;
if (!panel) { };
return false; };
} }
| { type: PanelType.ConversationDetails }
return ( | { type: PanelType.GroupInvites }
panel.type === PanelType.AllMedia || | { type: PanelType.GroupLinkManagement }
panel.type === PanelType.ChatColorEditor || | { type: PanelType.GroupPermissions }
panel.type === PanelType.ContactDetails || | { type: PanelType.GroupV1Members }
panel.type === PanelType.ConversationDetails || | { type: PanelType.MessageDetails; args: { message: MessageAttributesType } }
panel.type === PanelType.GroupInvites || | { type: PanelType.NotificationSettings }
panel.type === PanelType.GroupLinkManagement || | { type: PanelType.StickerManager };
panel.type === PanelType.GroupPermissions ||
panel.type === PanelType.GroupV1Members ||
panel.type === PanelType.NotificationSettings ||
panel.type === PanelType.StickerManager
);
}

View file

@ -17,23 +17,11 @@ import {
removeLinkPreview, removeLinkPreview,
suspendLinkPreviews, suspendLinkPreviews,
} from '../services/LinkPreview'; } from '../services/LinkPreview';
import { SECOND } from '../util/durations';
import type { BackbonePanelRenderType, PanelRenderType } from '../types/Panels';
import { PanelType, isPanelHandledByReact } from '../types/Panels';
import { UUIDKind } from '../types/UUID'; import { UUIDKind } from '../types/UUID';
type BackbonePanelType = { panelType: PanelType; view: Backbone.View };
export class ConversationView extends window.Backbone.View<ConversationModel> { export class ConversationView extends window.Backbone.View<ConversationModel> {
// Sub-views // Sub-views
private contactModalView?: Backbone.View;
private conversationView?: Backbone.View; private conversationView?: Backbone.View;
private lightboxView?: ReactWrapperView;
private stickerPreviewModalView?: Backbone.View;
// Panel support
private panels: Array<BackbonePanelType> = [];
private previousFocus?: HTMLElement;
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
constructor(...args: Array<any>) { constructor(...args: Array<any>) {
@ -48,9 +36,6 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
this.unload(`model trigger - ${reason}`) this.unload(`model trigger - ${reason}`)
); );
this.listenTo(this.model, 'pushPanel', this.pushPanel);
this.listenTo(this.model, 'popPanel', this.popPanel);
this.render(); this.render();
this.setupConversationView(); this.setupConversationView();
@ -139,22 +124,6 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
this.conversationView?.remove(); this.conversationView?.remove();
if (this.contactModalView) {
this.contactModalView.remove();
}
if (this.stickerPreviewModalView) {
this.stickerPreviewModalView.remove();
}
if (this.lightboxView) {
this.lightboxView.remove();
}
if (this.panels && this.panels.length) {
for (let i = 0, max = this.panels.length; i < max; i += 1) {
const panel = this.panels[i];
panel.view.remove();
}
}
removeLinkPreview(); removeLinkPreview();
suspendLinkPreviews(); suspendLinkPreviews();
@ -226,143 +195,6 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
void this.model.updateVerified(); void this.model.updateVerified();
} }
getMessageDetail({
messageId,
}: {
messageId: string;
}): Backbone.View | undefined {
const message = window.MessageController.getById(messageId);
if (!message) {
throw new Error(`getMessageDetail: Message ${messageId} missing!`);
}
if (!message.isNormalBubble()) {
return;
}
const getProps = () => ({
...message.getPropsForMessageDetail(
window.ConversationController.getOurConversationIdOrThrow()
),
});
const onClose = () => {
this.stopListening(message, 'change', update);
window.reduxActions.conversations.popPanelForConversation(this.model.id);
};
const view = new ReactWrapperView({
className: 'panel message-detail-wrapper',
JSX: window.Signal.State.Roots.createMessageDetail(
window.reduxStore,
getProps()
),
onClose,
});
const update = () =>
view.update(
window.Signal.State.Roots.createMessageDetail(
window.reduxStore,
getProps()
)
);
this.listenTo(message, 'change', update);
this.listenTo(message, 'expired', onClose);
// We could listen to all involved contacts, but we'll call that overkill
view.render();
return view;
}
pushPanel(panel: PanelRenderType): void {
if (isPanelHandledByReact(panel)) {
return;
}
this.panels = this.panels || [];
if (this.panels.length === 0) {
this.previousFocus = document.activeElement as HTMLElement;
}
const { type } = panel as BackbonePanelRenderType;
let view: Backbone.View | undefined;
if (panel.type === PanelType.MessageDetails) {
view = this.getMessageDetail(panel.args);
}
if (!view) {
return;
}
this.panels.push({
panelType: type,
view,
});
view.$el.insertAfter(this.$('.panel').last());
view.$el.one('animationend', () => {
if (view) {
view.$el.addClass('panel--static');
}
});
}
popPanel(poppedPanel: PanelRenderType): void {
if (!this.panels || !this.panels.length) {
return;
}
if (
this.panels.length === 0 &&
this.previousFocus &&
this.previousFocus.focus
) {
this.previousFocus.focus();
this.previousFocus = undefined;
}
const panel = this.panels[this.panels.length - 1];
if (!panel) {
return;
}
if (isPanelHandledByReact(poppedPanel)) {
return;
}
this.panels.pop();
if (panel.panelType !== poppedPanel.type) {
log.warn('popPanel: last panel was not of same type');
return;
}
if (this.panels.length > 0) {
this.panels[this.panels.length - 1].view.$el.fadeIn(250);
}
let timeout: ReturnType<typeof setTimeout> | undefined;
const removePanel = () => {
if (!timeout) {
return;
}
clearTimeout(timeout);
timeout = undefined;
panel.view.remove();
};
panel.view.$el.addClass('panel--remove').one('transitionend', removePanel);
// Backup, in case things go wrong with the transitionend event
timeout = setTimeout(removePanel, SECOND);
}
} }
window.Whisper.ConversationView = ConversationView; window.Whisper.ConversationView = ConversationView;

2
ts/window.d.ts vendored
View file

@ -38,7 +38,6 @@ import type { ReduxActions } from './state/types';
import type { createStore } from './state/createStore'; import type { createStore } from './state/createStore';
import type { createApp } from './state/roots/createApp'; import type { createApp } from './state/roots/createApp';
import type { createGroupV2JoinModal } from './state/roots/createGroupV2JoinModal'; import type { createGroupV2JoinModal } from './state/roots/createGroupV2JoinModal';
import type { createMessageDetail } from './state/roots/createMessageDetail';
import type { createSafetyNumberViewer } from './state/roots/createSafetyNumberViewer'; import type { createSafetyNumberViewer } from './state/roots/createSafetyNumberViewer';
import type { createShortcutGuideModal } from './state/roots/createShortcutGuideModal'; import type { createShortcutGuideModal } from './state/roots/createShortcutGuideModal';
import type * as appDuck from './state/ducks/app'; import type * as appDuck from './state/ducks/app';
@ -161,7 +160,6 @@ export type SignalCoreType = {
Roots: { Roots: {
createApp: typeof createApp; createApp: typeof createApp;
createGroupV2JoinModal: typeof createGroupV2JoinModal; createGroupV2JoinModal: typeof createGroupV2JoinModal;
createMessageDetail: typeof createMessageDetail;
createSafetyNumberViewer: typeof createSafetyNumberViewer; createSafetyNumberViewer: typeof createSafetyNumberViewer;
createShortcutGuideModal: typeof createShortcutGuideModal; createShortcutGuideModal: typeof createShortcutGuideModal;
}; };