A hybrid panel system for React & Backbone

This commit is contained in:
Josh Perez 2022-12-14 13:41:04 -05:00 committed by GitHub
parent 624adca360
commit ebeb6a7a6d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 474 additions and 157 deletions

View file

@ -12,6 +12,7 @@ export type PropsType = {
renderCompositionArea: () => JSX.Element; renderCompositionArea: () => JSX.Element;
renderConversationHeader: () => JSX.Element; renderConversationHeader: () => JSX.Element;
renderTimeline: () => JSX.Element; renderTimeline: () => JSX.Element;
renderPanel: () => JSX.Element | undefined;
}; };
export function ConversationView({ export function ConversationView({
@ -20,6 +21,7 @@ export function ConversationView({
renderCompositionArea, renderCompositionArea,
renderConversationHeader, renderConversationHeader,
renderTimeline, renderTimeline,
renderPanel,
}: PropsType): JSX.Element { }: PropsType): JSX.Element {
const onDrop = React.useCallback( const onDrop = React.useCallback(
(event: React.DragEvent<HTMLDivElement>) => { (event: React.DragEvent<HTMLDivElement>) => {
@ -93,6 +95,7 @@ export function ConversationView({
{renderCompositionArea()} {renderCompositionArea()}
</div> </div>
</div> </div>
{renderPanel()}
</div> </div>
); );
} }

View file

@ -80,7 +80,7 @@ const createProps = (
setDisappearingMessages: action('setDisappearingMessages'), setDisappearingMessages: action('setDisappearingMessages'),
showAllMedia: action('showAllMedia'), showAllMedia: action('showAllMedia'),
showContactModal: action('showContactModal'), showContactModal: action('showContactModal'),
showChatColorEditor: action('showChatColorEditor'), pushPanelForConversation: action('pushPanelForConversation'),
showGroupLinkManagement: action('showGroupLinkManagement'), showGroupLinkManagement: action('showGroupLinkManagement'),
showGroupV2Permissions: action('showGroupV2Permissions'), showGroupV2Permissions: action('showGroupV2Permissions'),
showConversationNotificationsSettings: action( showConversationNotificationsSettings: action(

View file

@ -8,6 +8,7 @@ import { Button, ButtonIconType, ButtonVariant } from '../../Button';
import { Tooltip } from '../../Tooltip'; import { Tooltip } from '../../Tooltip';
import type { import type {
ConversationType, ConversationType,
PushPanelForConversationActionType,
ShowConversationType, ShowConversationType,
} from '../../../state/ducks/conversations'; } from '../../../state/ducks/conversations';
import type { PreferredBadgeSelectorType } from '../../../state/selectors/badges'; import type { PreferredBadgeSelectorType } from '../../../state/selectors/badges';
@ -50,6 +51,7 @@ import type {
} from '../../../types/Avatar'; } from '../../../types/Avatar';
import { isConversationMuted } from '../../../util/isConversationMuted'; import { isConversationMuted } from '../../../util/isConversationMuted';
import { ConversationDetailsGroups } from './ConversationDetailsGroups'; import { ConversationDetailsGroups } from './ConversationDetailsGroups';
import { PanelType } from '../../../types/Panels';
enum ModalState { enum ModalState {
NothingOpen, NothingOpen,
@ -80,7 +82,6 @@ export type StateProps = {
pendingApprovalMemberships: ReadonlyArray<GroupV2RequestingMembership>; pendingApprovalMemberships: ReadonlyArray<GroupV2RequestingMembership>;
pendingMemberships: ReadonlyArray<GroupV2PendingMembership>; pendingMemberships: ReadonlyArray<GroupV2PendingMembership>;
showAllMedia: () => void; showAllMedia: () => void;
showChatColorEditor: () => void;
showGroupLinkManagement: () => void; showGroupLinkManagement: () => void;
showGroupV2Permissions: () => void; showGroupV2Permissions: () => void;
showPendingInvites: () => void; showPendingInvites: () => void;
@ -110,6 +111,7 @@ type ActionProps = {
loadRecentMediaItems: (id: string, limit: number) => void; loadRecentMediaItems: (id: string, limit: number) => void;
onOutgoingAudioCallInConversation: (conversationId: string) => unknown; onOutgoingAudioCallInConversation: (conversationId: string) => unknown;
onOutgoingVideoCallInConversation: (conversationId: string) => unknown; onOutgoingVideoCallInConversation: (conversationId: string) => unknown;
pushPanelForConversation: PushPanelForConversationActionType;
replaceAvatar: ReplaceAvatarActionType; replaceAvatar: ReplaceAvatarActionType;
saveAvatarToDisk: SaveAvatarToDiskActionType; saveAvatarToDisk: SaveAvatarToDiskActionType;
searchInConversation: (id: string) => unknown; searchInConversation: (id: string) => unknown;
@ -149,6 +151,7 @@ export function ConversationDetails({
onOutgoingVideoCallInConversation, onOutgoingVideoCallInConversation,
pendingApprovalMemberships, pendingApprovalMemberships,
pendingMemberships, pendingMemberships,
pushPanelForConversation,
renderChooseGroupMembersModal, renderChooseGroupMembersModal,
renderConfirmAdditionsModal, renderConfirmAdditionsModal,
replaceAvatar, replaceAvatar,
@ -157,7 +160,6 @@ export function ConversationDetails({
setDisappearingMessages, setDisappearingMessages,
setMuteExpiration, setMuteExpiration,
showAllMedia, showAllMedia,
showChatColorEditor,
showContactModal, showContactModal,
showConversationNotificationsSettings, showConversationNotificationsSettings,
showConversation, showConversation,
@ -426,7 +428,11 @@ export function ConversationDetails({
/> />
} }
label={i18n('showChatColorEditor')} label={i18n('showChatColorEditor')}
onClick={showChatColorEditor} onClick={() => {
pushPanelForConversation(conversation.id, {
type: PanelType.ChatColorEditor,
});
}}
right={ right={
<div <div
className={`ConversationDetails__chat-color ConversationDetails__chat-color--${conversation.conversationColor}`} className={`ConversationDetails__chat-color ConversationDetails__chat-color--${conversation.conversationColor}`}

View file

@ -24,7 +24,6 @@ import { StagedLinkPreview } from './components/conversation/StagedLinkPreview';
import { DisappearingTimeDialog } from './components/DisappearingTimeDialog'; import { DisappearingTimeDialog } from './components/DisappearingTimeDialog';
// State // State
import { createChatColorPicker } from './state/roots/createChatColorPicker';
import { createConversationDetails } from './state/roots/createConversationDetails'; import { createConversationDetails } from './state/roots/createConversationDetails';
import { createApp } from './state/roots/createApp'; import { createApp } from './state/roots/createApp';
import { createGroupLinkManagement } from './state/roots/createGroupLinkManagement'; import { createGroupLinkManagement } from './state/roots/createGroupLinkManagement';
@ -401,7 +400,6 @@ export const setup = (options: {
const Roots = { const Roots = {
createApp, createApp,
createChatColorPicker,
createConversationDetails, createConversationDetails,
createGroupLinkManagement, createGroupLinkManagement,
createGroupV2JoinModal, createGroupV2JoinModal,

View file

@ -117,6 +117,7 @@ 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';
// State // State
@ -392,8 +393,7 @@ export type ConversationsStateType = {
selectedMessage: string | undefined; selectedMessage: string | undefined;
selectedMessageCounter: number; selectedMessageCounter: number;
selectedMessageSource: SelectedMessageSource | undefined; selectedMessageSource: SelectedMessageSource | undefined;
selectedConversationTitle?: string; selectedConversationPanels: Array<PanelRenderType>;
selectedConversationPanelDepth: number;
showArchived: boolean; showArchived: boolean;
composer?: ComposerStateType; composer?: ComposerStateType;
contactSpoofingReview?: ContactSpoofingReviewStateType; contactSpoofingReview?: ContactSpoofingReviewStateType;
@ -457,7 +457,8 @@ const DISCARD_MESSAGES = 'conversations/DISCARD_MESSAGES';
const REPLACE_AVATARS = 'conversations/REPLACE_AVATARS'; const REPLACE_AVATARS = 'conversations/REPLACE_AVATARS';
export const SELECTED_CONVERSATION_CHANGED = export const SELECTED_CONVERSATION_CHANGED =
'conversations/SELECTED_CONVERSATION_CHANGED'; 'conversations/SELECTED_CONVERSATION_CHANGED';
const PUSH_PANEL = 'conversations/PUSH_PANEL';
const POP_PANEL = 'conversations/POP_PANEL';
export const SET_VOICE_NOTE_PLAYBACK_RATE = export const SET_VOICE_NOTE_PLAYBACK_RATE =
'conversations/SET_VOICE_NOTE_PLAYBACK_RATE'; 'conversations/SET_VOICE_NOTE_PLAYBACK_RATE';
@ -678,14 +679,6 @@ export type SetIsNearBottomActionType = {
isNearBottom: boolean; isNearBottom: boolean;
}; };
}; };
export type SetConversationHeaderTitleActionType = {
type: 'SET_CONVERSATION_HEADER_TITLE';
payload: { title?: string };
};
export type SetSelectedConversationPanelDepthActionType = {
type: 'SET_SELECTED_CONVERSATION_PANEL_DEPTH';
payload: { panelDepth: number };
};
export type ScrollToMessageActionType = { export type ScrollToMessageActionType = {
type: 'SCROLL_TO_MESSAGE'; type: 'SCROLL_TO_MESSAGE';
payload: { payload: {
@ -781,6 +774,14 @@ export type ToggleConversationInChooseMembersActionType = {
maxGroupSize: number; maxGroupSize: number;
}; };
}; };
type PushPanelActionType = {
type: typeof PUSH_PANEL;
payload: PanelRenderType;
};
type PopPanelActionType = {
type: typeof POP_PANEL;
payload: null;
};
type ReplaceAvatarsActionType = { type ReplaceAvatarsActionType = {
type: typeof REPLACE_AVATARS; type: typeof REPLACE_AVATARS;
@ -822,6 +823,8 @@ export type ConversationActionType =
| MessageSelectedActionType | MessageSelectedActionType
| MessagesAddedActionType | MessagesAddedActionType
| MessagesResetActionType | MessagesResetActionType
| PopPanelActionType
| PushPanelActionType
| RemoveAllConversationsActionType | RemoveAllConversationsActionType
| RepairNewestMessageActionType | RepairNewestMessageActionType
| RepairOldestMessageActionType | RepairOldestMessageActionType
@ -834,13 +837,11 @@ export type ConversationActionType =
| SetComposeGroupExpireTimerActionType | SetComposeGroupExpireTimerActionType
| SetComposeGroupNameActionType | SetComposeGroupNameActionType
| SetComposeSearchTermActionType | SetComposeSearchTermActionType
| SetConversationHeaderTitleActionType
| SetIsFetchingUUIDActionType | SetIsFetchingUUIDActionType
| SetIsNearBottomActionType | SetIsNearBottomActionType
| SetMessageLoadingStateActionType | SetMessageLoadingStateActionType
| SetPreJoinConversationActionType | SetPreJoinConversationActionType
| SetRecentMediaItemsActionType | SetRecentMediaItemsActionType
| SetSelectedConversationPanelDepthActionType
| ShowArchivedConversationsActionType | ShowArchivedConversationsActionType
| ShowChooseGroupMembersActionType | ShowChooseGroupMembersActionType
| ShowInboxActionType | ShowInboxActionType
@ -885,14 +886,16 @@ export const actions = {
discardMessages, discardMessages,
doubleCheckMissingQuoteReference, doubleCheckMissingQuoteReference,
generateNewGroupLink, generateNewGroupLink,
loadRecentMediaItems,
initiateMigrationToGroupV2, initiateMigrationToGroupV2,
loadRecentMediaItems,
messageChanged, messageChanged,
messageDeleted, messageDeleted,
messageExpanded, messageExpanded,
messagesAdded, messagesAdded,
messagesReset, messagesReset,
myProfileChanged, myProfileChanged,
popPanelForConversation,
pushPanelForConversation,
removeAllConversations, removeAllConversations,
removeCustomColorOnConversations, removeCustomColorOnConversations,
removeMemberFromGroup, removeMemberFromGroup,
@ -924,8 +927,6 @@ export const actions = {
setMuteExpiration, setMuteExpiration,
setPinned, setPinned,
setPreJoinConversation, setPreJoinConversation,
setSelectedConversationHeaderTitle,
setSelectedConversationPanelDepth,
setVoiceNotePlaybackRate, setVoiceNotePlaybackRate,
showArchivedConversations, showArchivedConversations,
showChooseGroupMembers, showChooseGroupMembers,
@ -2064,20 +2065,61 @@ function setIsFetchingUUID(
}, },
}; };
} }
function setSelectedConversationHeaderTitle(
title?: string export type PushPanelForConversationActionType = (
): SetConversationHeaderTitleActionType { conversationId: string,
panel: PanelRenderType
) => unknown;
function pushPanelForConversation(
conversationId: string,
panel: PanelRenderType
): PushPanelActionType {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error(
`addPanelToConversation: No conversation found for conversation ${conversationId}`
);
}
conversation.trigger('pushPanel', panel);
return { return {
type: 'SET_CONVERSATION_HEADER_TITLE', type: PUSH_PANEL,
payload: { title }, payload: panel,
}; };
} }
function setSelectedConversationPanelDepth(
panelDepth: number function popPanelForConversation(
): SetSelectedConversationPanelDepthActionType { conversationId: string
return { ): ThunkAction<void, RootStateType, unknown, PopPanelActionType> {
type: 'SET_SELECTED_CONVERSATION_PANEL_DEPTH', return (dispatch, getState) => {
payload: { panelDepth }, const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error(
`addPanelToConversation: No conversation found for conversation ${conversationId}`
);
}
const { conversations } = getState();
const { selectedConversationPanels } = conversations;
if (!selectedConversationPanels.length) {
return;
}
const panel = [...selectedConversationPanels].pop();
if (!panel) {
return;
}
conversation.trigger('popPanel', panel);
dispatch({
type: POP_PANEL,
payload: null,
});
}; };
} }
@ -2869,8 +2911,7 @@ export function getEmptyState(): ConversationsStateType {
selectedMessageCounter: 0, selectedMessageCounter: 0,
selectedMessageSource: undefined, selectedMessageSource: undefined,
showArchived: false, showArchived: false,
selectedConversationTitle: '', selectedConversationPanels: [],
selectedConversationPanelDepth: 0,
}; };
} }
@ -3375,7 +3416,7 @@ export function reducer(
return { return {
...omit(state, 'contactSpoofingReview'), ...omit(state, 'contactSpoofingReview'),
selectedConversationId, selectedConversationId,
selectedConversationPanelDepth: 0, selectedConversationPanels: [],
messagesLookup: omit(state.messagesLookup, messageIds), messagesLookup: omit(state.messagesLookup, messageIds),
messagesByConversation: omit(state.messagesByConversation, [id]), messagesByConversation: omit(state.messagesByConversation, [id]),
}; };
@ -3423,12 +3464,6 @@ export function reducer(
}, },
}; };
} }
if (action.type === 'SET_SELECTED_CONVERSATION_PANEL_DEPTH') {
return {
...state,
selectedConversationPanelDepth: action.payload.panelDepth,
};
}
if (action.type === 'MESSAGE_SELECTED') { if (action.type === 'MESSAGE_SELECTED') {
const { messageId, conversationId } = action.payload; const { messageId, conversationId } = action.payload;
@ -4180,10 +4215,24 @@ export function reducer(
}; };
} }
if (action.type === 'SET_CONVERSATION_HEADER_TITLE') { if (action.type === PUSH_PANEL) {
return { return {
...state, ...state,
selectedConversationTitle: action.payload.title, selectedConversationPanels: [
...state.selectedConversationPanels,
action.payload,
],
};
}
if (action.type === POP_PANEL) {
const { selectedConversationPanels } = state;
const nextPanels = [...selectedConversationPanels];
nextPanels.pop();
return {
...state,
selectedConversationPanels: nextPanels,
}; };
} }

View file

@ -1,19 +0,0 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { Provider } from 'react-redux';
import type { Store } from 'redux';
import type { SmartChatColorPickerProps } from '../smart/ChatColorPicker';
import { SmartChatColorPicker } from '../smart/ChatColorPicker';
export const createChatColorPicker = (
store: Store,
props: SmartChatColorPickerProps
): React.ReactElement => (
<Provider store={store}>
<SmartChatColorPicker {...props} />
</Provider>
);

View file

@ -64,6 +64,9 @@ import * as log from '../../logging/log';
import { TimelineMessageLoadingState } from '../../util/timelineUtil'; 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 type { ReactPanelRenderType, PanelRenderType } from '../../types/Panels';
import { isPanelHandledByReact } from '../../types/Panels';
let placeholderContact: ConversationType; let placeholderContact: ConversationType;
export const getPlaceholderContact = (): ConversationType => { export const getPlaceholderContact = (): ConversationType => {
@ -1131,3 +1134,34 @@ export const getHideStoryConversationIds = createSelector(
conversationId => conversationLookup[conversationId].hideStory conversationId => conversationLookup[conversationId].hideStory
) )
); );
const getTopPanel = createSelector(
getConversations,
(conversations): PanelRenderType | undefined =>
conversations.selectedConversationPanels[
conversations.selectedConversationPanels.length - 1
]
);
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(
getIntl,
getTopPanel,
(i18n, panel): string | undefined =>
getConversationTitleForPanelType(i18n, panel?.type)
);

View file

@ -38,7 +38,6 @@ export type SmartConversationDetailsProps = {
addMembers: (conversationIds: ReadonlyArray<string>) => Promise<void>; addMembers: (conversationIds: ReadonlyArray<string>) => Promise<void>;
conversationId: string; conversationId: string;
showAllMedia: () => void; showAllMedia: () => void;
showChatColorEditor: () => void;
showGroupLinkManagement: () => void; showGroupLinkManagement: () => void;
showGroupV2Permissions: () => void; showGroupV2Permissions: () => void;
showConversationNotificationsSettings: () => void; showConversationNotificationsSettings: () => void;

View file

@ -12,6 +12,7 @@ import {
import { getPreferredBadgeSelector } from '../selectors/badges'; import { getPreferredBadgeSelector } from '../selectors/badges';
import { import {
getConversationSelector, getConversationSelector,
getConversationTitle,
isMissingRequiredProfileSharing, isMissingRequiredProfileSharing,
} from '../selectors/conversations'; } from '../selectors/conversations';
import { CallMode } from '../../types/Calling'; import { CallMode } from '../../types/Calling';
@ -108,14 +109,14 @@ const mapStateToProps = (state: StateType, ownProps: OwnProps) => {
'unblurredAvatarPath', 'unblurredAvatarPath',
]), ]),
badge: getPreferredBadgeSelector(state)(conversation.badges), badge: getPreferredBadgeSelector(state)(conversation.badges),
conversationTitle: state.conversations.selectedConversationTitle, conversationTitle: getConversationTitle(state),
hasStories, hasStories,
isMissingMandatoryProfileSharing: isMissingMandatoryProfileSharing:
isMissingRequiredProfileSharing(conversation), isMissingRequiredProfileSharing(conversation),
isSMSOnly: isConversationSMSOnly(conversation), isSMSOnly: isConversationSMSOnly(conversation),
isSignalConversation: isSignalConversation(conversation), isSignalConversation: isSignalConversation(conversation),
i18n: getIntl(state), i18n: getIntl(state),
showBackButton: state.conversations.selectedConversationPanelDepth > 0, showBackButton: state.conversations.selectedConversationPanels.length > 0,
outgoingCallButtonStyle: getOutgoingCallButtonStyle(conversation, state), outgoingCallButtonStyle: getOutgoingCallButtonStyle(conversation, state),
theme: getTheme(state), theme: getTheme(state),
}; };

View file

@ -3,15 +3,19 @@
import React from 'react'; import React from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { mapDispatchToProps } from '../actions';
import { ConversationView } from '../../components/conversation/ConversationView';
import type { StateType } from '../reducer';
import type { CompositionAreaPropsType } from './CompositionArea'; import type { CompositionAreaPropsType } from './CompositionArea';
import { SmartCompositionArea } from './CompositionArea';
import type { OwnProps as ConversationHeaderPropsType } from './ConversationHeader'; import type { OwnProps as ConversationHeaderPropsType } from './ConversationHeader';
import { SmartConversationHeader } from './ConversationHeader'; import type { StateType } from '../reducer';
import type { TimelinePropsType } from './Timeline'; import type { TimelinePropsType } from './Timeline';
import * as log from '../../logging/log';
import { ConversationView } from '../../components/conversation/ConversationView';
import { PanelType } from '../../types/Panels';
import { SmartChatColorPicker } from './ChatColorPicker';
import { SmartCompositionArea } from './CompositionArea';
import { SmartConversationHeader } from './ConversationHeader';
import { SmartTimeline } from './Timeline'; import { SmartTimeline } from './Timeline';
import { getTopPanelRenderableByReact } from '../selectors/conversations';
import { mapDispatchToProps } from '../actions';
export type PropsType = { export type PropsType = {
conversationId: string; conversationId: string;
@ -31,7 +35,7 @@ export type PropsType = {
timelineProps: TimelinePropsType; timelineProps: TimelinePropsType;
}; };
const mapStateToProps = (_state: StateType, props: PropsType) => { const mapStateToProps = (state: StateType, props: PropsType) => {
const { const {
compositionAreaProps, compositionAreaProps,
conversationHeaderProps, conversationHeaderProps,
@ -39,6 +43,8 @@ const mapStateToProps = (_state: StateType, props: PropsType) => {
timelineProps, timelineProps,
} = props; } = props;
const topPanel = getTopPanelRenderableByReact(state);
return { return {
conversationId, conversationId,
renderCompositionArea: () => ( renderCompositionArea: () => (
@ -48,6 +54,24 @@ const mapStateToProps = (_state: StateType, props: PropsType) => {
<SmartConversationHeader {...conversationHeaderProps} /> <SmartConversationHeader {...conversationHeaderProps} />
), ),
renderTimeline: () => <SmartTimeline {...timelineProps} />, renderTimeline: () => <SmartTimeline {...timelineProps} />,
renderPanel: () => {
if (!topPanel) {
return;
}
if (topPanel.type === PanelType.ChatColorEditor) {
return (
<div className="panel">
<SmartChatColorPicker conversationId={conversationId} />
</div>
);
}
const unknownPanelType: never = topPanel.type;
log.warn(`renderPanel: Got unexpected panel type ${unknownPanelType}`);
return undefined;
},
}; };
}; };

54
ts/types/Panels.ts Normal file
View file

@ -0,0 +1,54 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { EmbeddedContactType } from './EmbeddedContact';
import type { UUIDStringType } from './UUID';
export enum PanelType {
AllMedia = 'AllMedia',
ChatColorEditor = 'ChatColorEditor',
ContactDetails = 'ContactDetails',
ConversationDetails = 'ConversationDetails',
GroupInvites = 'GroupInvites',
GroupLinkManagement = 'GroupLinkManagement',
GroupPermissions = 'GroupPermissions',
GroupV1Members = 'GroupV1Members',
MessageDetails = 'MessageDetails',
NotificationSettings = 'NotificationSettings',
StickerManager = 'StickerManager',
}
export type ReactPanelRenderType = { type: PanelType.ChatColorEditor };
export type BackbonePanelRenderType =
| { type: PanelType.AllMedia }
| {
type: PanelType.ContactDetails;
args: {
contact: EmbeddedContactType;
signalAccount?: {
phoneNumber: string;
uuid: UUIDStringType;
};
};
}
| { type: PanelType.ConversationDetails }
| { type: PanelType.GroupInvites }
| { type: PanelType.GroupLinkManagement }
| { type: PanelType.GroupPermissions }
| { type: PanelType.GroupV1Members }
| { type: PanelType.MessageDetails; args: { messageId: string } }
| { type: PanelType.NotificationSettings }
| { type: PanelType.StickerManager };
export type PanelRenderType = ReactPanelRenderType | BackbonePanelRenderType;
export function isPanelHandledByReact(
panel: PanelRenderType
): panel is ReactPanelRenderType {
if (!panel) {
return false;
}
return panel.type === PanelType.ChatColorEditor;
}

View file

@ -0,0 +1,59 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { LocalizerType } from '../types/Util';
import * as log from '../logging/log';
import { PanelType } from '../types/Panels';
export function getConversationTitleForPanelType(
i18n: LocalizerType,
panelType: PanelType | undefined
): string | undefined {
if (!panelType) {
return undefined;
}
if (panelType === PanelType.AllMedia) {
return i18n('allMedia');
}
if (panelType === PanelType.ChatColorEditor) {
return i18n('ChatColorPicker__menu-title');
}
if (panelType === PanelType.ConversationDetails) {
return '';
}
if (panelType === PanelType.GroupInvites) {
return i18n('ConversationDetails--requests-and-invites');
}
if (panelType === PanelType.GroupLinkManagement) {
return i18n('ConversationDetails--group-link');
}
if (panelType === PanelType.GroupPermissions) {
return i18n('permissions');
}
if (panelType === PanelType.NotificationSettings) {
return i18n('ConversationDetails--notifications');
}
if (
panelType === PanelType.ContactDetails ||
panelType === PanelType.GroupV1Members ||
panelType === PanelType.MessageDetails ||
panelType === PanelType.StickerManager
) {
return undefined;
}
const unknownType: never = panelType;
log.warn(
`getConversationTitleForPanelType: Got unexpected type ${unknownType}`
);
return undefined;
}

View file

@ -56,16 +56,18 @@ import { SECOND } from '../util/durations';
import { startConversation } from '../util/startConversation'; import { startConversation } from '../util/startConversation';
import { longRunningTaskWrapper } from '../util/longRunningTaskWrapper'; import { longRunningTaskWrapper } from '../util/longRunningTaskWrapper';
import { hasDraftAttachments } from '../util/hasDraftAttachments'; import { hasDraftAttachments } from '../util/hasDraftAttachments';
import type { BackbonePanelRenderType, PanelRenderType } from '../types/Panels';
import { PanelType, isPanelHandledByReact } from '../types/Panels';
type AttachmentOptions = { type AttachmentOptions = {
messageId: string; messageId: string;
attachment: AttachmentType; attachment: AttachmentType;
}; };
type PanelType = { view: Backbone.View; headerTitle?: string };
const { Message } = window.Signal.Types; const { Message } = window.Signal.Types;
type BackbonePanelType = { panelType: PanelType; view: Backbone.View };
const { getAbsoluteAttachmentPath, upgradeMessageSchema } = const { getAbsoluteAttachmentPath, upgradeMessageSchema } =
window.Signal.Migrations; window.Signal.Migrations;
@ -125,7 +127,7 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
private stickerPreviewModalView?: Backbone.View; private stickerPreviewModalView?: Backbone.View;
// Panel support // Panel support
private panels: Array<PanelType> = []; private panels: Array<BackbonePanelType> = [];
private previousFocus?: HTMLElement; private previousFocus?: HTMLElement;
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -143,7 +145,9 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
// These are triggered by background.ts for keyboard handling // These are triggered by background.ts for keyboard handling
this.listenTo(this.model, 'open-all-media', this.showAllMedia); this.listenTo(this.model, 'open-all-media', this.showAllMedia);
this.listenTo(this.model, 'escape-pressed', this.resetPanel); this.listenTo(this.model, 'escape-pressed', () => {
window.reduxActions.conversations.popPanelForConversation(this.model.id);
});
this.listenTo(this.model, 'show-message-details', this.showMessageDetail); this.listenTo(this.model, 'show-message-details', this.showMessageDetail);
this.listenTo(this.model, 'delete-message', this.deleteMessage); this.listenTo(this.model, 'delete-message', this.deleteMessage);
this.listenTo(this.model, 'remove-link-review', removeLinkPreview); this.listenTo(this.model, 'remove-link-review', removeLinkPreview);
@ -157,6 +161,9 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
this.setupConversationView(); this.setupConversationView();
this.updateAttachmentsView(); this.updateAttachmentsView();
this.listenTo(this.model, 'pushPanel', this.pushPanel);
this.listenTo(this.model, 'popPanel', this.popPanel);
} }
override events(): Record<string, string> { override events(): Record<string, string> {
@ -212,7 +219,9 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
this.showGV1Members(); this.showGV1Members();
}, },
onGoBack: () => { onGoBack: () => {
this.resetPanel(); window.reduxActions.conversations.popPanelForConversation(
this.model.id
);
}, },
onArchive: () => { onArchive: () => {
@ -237,7 +246,6 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
showToast(ToastConversationUnarchived); showToast(ToastConversationUnarchived);
}, },
}; };
window.reduxActions.conversations.setSelectedConversationHeaderTitle();
// setupTimeline // setupTimeline
@ -544,7 +552,6 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
const panel = this.panels[i]; const panel = this.panels[i];
panel.view.remove(); panel.view.remove();
} }
window.reduxActions.conversations.setSelectedConversationPanelDepth(0);
} }
removeLinkPreview(); removeLinkPreview();
@ -624,6 +631,12 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
} }
showAllMedia(): void { showAllMedia(): void {
window.reduxActions.conversations.pushPanelForConversation(this.model.id, {
type: PanelType.AllMedia,
});
}
getAllMedia(): Backbone.View | undefined {
if (document.querySelectorAll('.module-media-gallery').length) { if (document.querySelectorAll('.module-media-gallery').length) {
return; return;
} }
@ -807,19 +820,24 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
unsubscribe(); unsubscribe();
}, },
}); });
const headerTitle = window.i18n('allMedia');
const update = async () => { const update = async () => {
const props = await getProps(); const props = await getProps();
view.update(<MediaGallery i18n={window.i18n} {...props} />); view.update(<MediaGallery i18n={window.i18n} {...props} />);
}; };
this.addPanel({ view, headerTitle });
update(); update();
return view;
} }
showGV1Members(): void { showGV1Members(): void {
window.reduxActions.conversations.pushPanelForConversation(this.model.id, {
type: PanelType.GroupV1Members,
});
}
getGV1Members(): Backbone.View {
const { contactCollection, id } = this.model; const { contactCollection, id } = this.model;
const memberships = const memberships =
@ -855,8 +873,9 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
), ),
}); });
this.addPanel({ view });
view.render(); view.render();
return view;
} }
deleteMessage(messageId: string): void { deleteMessage(messageId: string): void {
@ -877,12 +896,20 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
} else { } else {
this.model.decrementMessageCount(); this.model.decrementMessageCount();
} }
this.resetPanel(); window.reduxActions.conversations.popPanelForConversation(
this.model.id
);
}, },
}); });
} }
showGroupLinkManagement(): void { showGroupLinkManagement(): void {
window.reduxActions.conversations.pushPanelForConversation(this.model.id, {
type: PanelType.GroupLinkManagement,
});
}
getGroupLinkManagement(): Backbone.View {
const view = new ReactWrapperView({ const view = new ReactWrapperView({
className: 'panel', className: 'panel',
JSX: window.Signal.State.Roots.createGroupLinkManagement( JSX: window.Signal.State.Roots.createGroupLinkManagement(
@ -892,13 +919,19 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
} }
), ),
}); });
const headerTitle = window.i18n('ConversationDetails--group-link');
this.addPanel({ view, headerTitle });
view.render(); view.render();
return view;
} }
showGroupV2Permissions(): void { showGroupV2Permissions(): void {
window.reduxActions.conversations.pushPanelForConversation(this.model.id, {
type: PanelType.GroupPermissions,
});
}
getGroupV2Permissions(): Backbone.View {
const view = new ReactWrapperView({ const view = new ReactWrapperView({
className: 'panel', className: 'panel',
JSX: window.Signal.State.Roots.createGroupV2Permissions( JSX: window.Signal.State.Roots.createGroupV2Permissions(
@ -908,13 +941,19 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
} }
), ),
}); });
const headerTitle = window.i18n('permissions');
this.addPanel({ view, headerTitle });
view.render(); view.render();
return view;
} }
showPendingInvites(): void { showPendingInvites(): void {
window.reduxActions.conversations.pushPanelForConversation(this.model.id, {
type: PanelType.GroupInvites,
});
}
getPendingInvites(): Backbone.View {
const view = new ReactWrapperView({ const view = new ReactWrapperView({
className: 'panel', className: 'panel',
JSX: window.Signal.State.Roots.createPendingInvites(window.reduxStore, { JSX: window.Signal.State.Roots.createPendingInvites(window.reduxStore, {
@ -922,15 +961,19 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(), ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(),
}), }),
}); });
const headerTitle = window.i18n(
'ConversationDetails--requests-and-invites'
);
this.addPanel({ view, headerTitle });
view.render(); view.render();
return view;
} }
showConversationNotificationsSettings(): void { showConversationNotificationsSettings(): void {
window.reduxActions.conversations.pushPanelForConversation(this.model.id, {
type: PanelType.NotificationSettings,
});
}
getConversationNotificationsSettings(): Backbone.View {
const view = new ReactWrapperView({ const view = new ReactWrapperView({
className: 'panel', className: 'panel',
JSX: window.Signal.State.Roots.createConversationNotificationsSettings( JSX: window.Signal.State.Roots.createConversationNotificationsSettings(
@ -940,26 +983,19 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
} }
), ),
}); });
const headerTitle = window.i18n('ConversationDetails--notifications');
this.addPanel({ view, headerTitle });
view.render(); view.render();
}
showChatColorEditor(): void { return view;
const view = new ReactWrapperView({
className: 'panel',
JSX: window.Signal.State.Roots.createChatColorPicker(window.reduxStore, {
conversationId: this.model.get('id'),
}),
});
const headerTitle = window.i18n('ChatColorPicker__menu-title');
this.addPanel({ view, headerTitle });
view.render();
} }
showConversationDetails(): void { showConversationDetails(): void {
window.reduxActions.conversations.pushPanelForConversation(this.model.id, {
type: PanelType.ConversationDetails,
});
}
getConversationDetails(): Backbone.View {
// Run a getProfiles in case member's capabilities have changed // Run a getProfiles in case member's capabilities have changed
// Redux should cover us on the return here so no need to await this. // Redux should cover us on the return here so no need to await this.
if (this.model.throttledGetProfiles) { if (this.model.throttledGetProfiles) {
@ -981,7 +1017,6 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
addMembers: this.model.addMembersV2.bind(this.model), addMembers: this.model.addMembersV2.bind(this.model),
conversationId: this.model.get('id'), conversationId: this.model.get('id'),
showAllMedia: this.showAllMedia.bind(this), showAllMedia: this.showAllMedia.bind(this),
showChatColorEditor: this.showChatColorEditor.bind(this),
showGroupLinkManagement: this.showGroupLinkManagement.bind(this), showGroupLinkManagement: this.showGroupLinkManagement.bind(this),
showGroupV2Permissions: this.showGroupV2Permissions.bind(this), showGroupV2Permissions: this.showGroupV2Permissions.bind(this),
showConversationNotificationsSettings: showConversationNotificationsSettings:
@ -1000,13 +1035,24 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
props props
), ),
}); });
const headerTitle = '';
this.addPanel({ view, headerTitle });
view.render(); view.render();
return view;
} }
showMessageDetail(messageId: string): void { showMessageDetail(messageId: string): void {
window.reduxActions.conversations.pushPanelForConversation(this.model.id, {
type: PanelType.MessageDetails,
args: { messageId },
});
}
getMessageDetail({
messageId,
}: {
messageId: string;
}): Backbone.View | undefined {
const message = window.MessageController.getById(messageId); const message = window.MessageController.getById(messageId);
if (!message) { if (!message) {
throw new Error(`showMessageDetail: Message ${messageId} missing!`); throw new Error(`showMessageDetail: Message ${messageId} missing!`);
@ -1025,7 +1071,7 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
const onClose = () => { const onClose = () => {
this.stopListening(message, 'change', update); this.stopListening(message, 'change', update);
this.resetPanel(); window.reduxActions.conversations.popPanelForConversation(this.model.id);
}; };
const view = new ReactWrapperView({ const view = new ReactWrapperView({
@ -1048,21 +1094,31 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
this.listenTo(message, 'expired', onClose); this.listenTo(message, 'expired', onClose);
// We could listen to all involved contacts, but we'll call that overkill // We could listen to all involved contacts, but we'll call that overkill
this.addPanel({ view });
view.render(); view.render();
return view;
} }
showStickerManager(): void { showStickerManager(): void {
window.reduxActions.conversations.pushPanelForConversation(this.model.id, {
type: PanelType.StickerManager,
});
}
getStickerManager(): Backbone.View {
const view = new ReactWrapperView({ const view = new ReactWrapperView({
className: ['sticker-manager-wrapper', 'panel'].join(' '), className: ['sticker-manager-wrapper', 'panel'].join(' '),
JSX: window.Signal.State.Roots.createStickerManager(window.reduxStore), JSX: window.Signal.State.Roots.createStickerManager(window.reduxStore),
onClose: () => { onClose: () => {
this.resetPanel(); window.reduxActions.conversations.popPanelForConversation(
this.model.id
);
}, },
}); });
this.addPanel({ view });
view.render(); view.render();
return view;
} }
showContactDetail({ showContactDetail({
@ -1075,6 +1131,22 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
uuid: UUIDStringType; uuid: UUIDStringType;
}; };
}): void { }): void {
window.reduxActions.conversations.pushPanelForConversation(this.model.id, {
type: PanelType.ContactDetails,
args: { contact, signalAccount },
});
}
getContactDetail({
contact,
signalAccount,
}: {
contact: EmbeddedContactType;
signalAccount?: {
phoneNumber: string;
uuid: UUIDStringType;
};
}): Backbone.View {
const view = new ReactWrapperView({ const view = new ReactWrapperView({
className: 'contact-detail-pane panel', className: 'contact-detail-pane panel',
JSX: ( JSX: (
@ -1090,11 +1162,13 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
/> />
), ),
onClose: () => { onClose: () => {
this.resetPanel(); window.reduxActions.conversations.popPanelForConversation(
this.model.id
);
}, },
}); });
this.addPanel({ view }); return view;
} }
async openConversation( async openConversation(
@ -1108,32 +1182,63 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
); );
} }
addPanel(panel: PanelType): void { pushPanel(panel: PanelRenderType): void {
if (isPanelHandledByReact(panel)) {
return;
}
this.panels = this.panels || []; this.panels = this.panels || [];
if (this.panels.length === 0) { if (this.panels.length === 0) {
this.previousFocus = document.activeElement as HTMLElement; this.previousFocus = document.activeElement as HTMLElement;
} }
this.panels.unshift(panel); const { type } = panel as BackbonePanelRenderType;
panel.view.$el.insertAfter(this.$('.panel').last());
panel.view.$el.one('animationend', () => {
panel.view.$el.addClass('panel--static');
});
window.reduxActions.conversations.setSelectedConversationPanelDepth( let view: Backbone.View | undefined;
this.panels.length if (type === PanelType.AllMedia) {
); view = this.getAllMedia();
window.reduxActions.conversations.setSelectedConversationHeaderTitle( } else if (panel.type === PanelType.ContactDetails) {
panel.headerTitle view = this.getContactDetail(panel.args);
); } else if (type === PanelType.ConversationDetails) {
view = this.getConversationDetails();
} else if (type === PanelType.GroupInvites) {
view = this.getPendingInvites();
} else if (type === PanelType.GroupLinkManagement) {
view = this.getGroupLinkManagement();
} else if (type === PanelType.GroupPermissions) {
view = this.getGroupV2Permissions();
} else if (type === PanelType.GroupV1Members) {
view = this.getGV1Members();
} else if (type === PanelType.NotificationSettings) {
view = this.getConversationNotificationsSettings();
} else if (panel.type === PanelType.MessageDetails) {
view = this.getMessageDetail(panel.args);
} else if (type === PanelType.StickerManager) {
view = this.getStickerManager();
} }
resetPanel(): void {
if (!this.panels || !this.panels.length) { if (!view) {
return; return;
} }
const panel = this.panels.shift(); 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 ( if (
this.panels.length === 0 && this.panels.length === 0 &&
@ -1144,11 +1249,27 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
this.previousFocus = undefined; this.previousFocus = undefined;
} }
if (this.panels.length > 0) { const panel = this.panels[this.panels.length - 1];
this.panels[0].view.$el.fadeIn(250);
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);
} }
if (panel) {
let timeout: ReturnType<typeof setTimeout> | undefined; let timeout: ReturnType<typeof setTimeout> | undefined;
const removePanel = () => { const removePanel = () => {
if (!timeout) { if (!timeout) {
@ -1160,22 +1281,12 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
panel.view.remove(); panel.view.remove();
}; };
panel.view.$el panel.view.$el.addClass('panel--remove').one('transitionend', removePanel);
.addClass('panel--remove')
.one('transitionend', removePanel);
// Backup, in case things go wrong with the transitionend event // Backup, in case things go wrong with the transitionend event
timeout = setTimeout(removePanel, SECOND); timeout = setTimeout(removePanel, SECOND);
} }
window.reduxActions.conversations.setSelectedConversationPanelDepth(
this.panels.length
);
window.reduxActions.conversations.setSelectedConversationHeaderTitle(
this.panels[0]?.headerTitle
);
}
async clearAttachments(): Promise<void> { async clearAttachments(): Promise<void> {
const draftAttachments = this.model.get('draftAttachments') || []; const draftAttachments = this.model.get('draftAttachments') || [];
this.model.set({ this.model.set({

2
ts/window.d.ts vendored
View file

@ -37,7 +37,6 @@ import type { ConversationController } from './ConversationController';
import type { ReduxActions } from './state/types'; 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 { createChatColorPicker } from './state/roots/createChatColorPicker';
import type { createConversationDetails } from './state/roots/createConversationDetails'; import type { createConversationDetails } from './state/roots/createConversationDetails';
import type { createGroupLinkManagement } from './state/roots/createGroupLinkManagement'; import type { createGroupLinkManagement } from './state/roots/createGroupLinkManagement';
import type { createGroupV2JoinModal } from './state/roots/createGroupV2JoinModal'; import type { createGroupV2JoinModal } from './state/roots/createGroupV2JoinModal';
@ -167,7 +166,6 @@ export type SignalCoreType = {
createStore: typeof createStore; createStore: typeof createStore;
Roots: { Roots: {
createApp: typeof createApp; createApp: typeof createApp;
createChatColorPicker: typeof createChatColorPicker;
createConversationDetails: typeof createConversationDetails; createConversationDetails: typeof createConversationDetails;
createGroupLinkManagement: typeof createGroupLinkManagement; createGroupLinkManagement: typeof createGroupLinkManagement;
createGroupV2JoinModal: typeof createGroupV2JoinModal; createGroupV2JoinModal: typeof createGroupV2JoinModal;