Init CallLinkDetails view in calls tab

This commit is contained in:
Jamie Kyle 2024-05-22 09:24:27 -07:00 committed by GitHub
parent e9b661873b
commit 19083cadf7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 665 additions and 222 deletions

View file

@ -5134,6 +5134,10 @@
"messageformat": "Forward To", "messageformat": "Forward To",
"description": "Title for the forward a message modal dialog" "description": "Title for the forward a message modal dialog"
}, },
"icu:ForwardMessageModal__ShareCallLink": {
"messageformat": "Share call link",
"description": "Title for the forward a message modal dialog in share call link mode"
},
"icu:ForwardMessageModal--continue": { "icu:ForwardMessageModal--continue": {
"messageformat": "Continue", "messageformat": "Continue",
"description": "aria-label for the 'next' button in the forward a message modal dialog" "description": "aria-label for the 'next' button in the forward a message modal dialog"
@ -5142,6 +5146,10 @@
"messageformat": "Cannot forward empty or deleted messages", "messageformat": "Cannot forward empty or deleted messages",
"description": "Toast message shown when trying to forward an empty or deleted message" "description": "Toast message shown when trying to forward an empty or deleted message"
}, },
"icu:ShareCallLinkViaSignal__DraftMessageText": {
"messageformat": "Use this link to join a Signal call: {url}",
"description": "Draft message text for sharing a call link"
},
"icu:MessageRequestWarning__learn-more": { "icu:MessageRequestWarning__learn-more": {
"messageformat": "Learn more", "messageformat": "Learn more",
"description": "Shown on the message request warning. Clicking this button will open a dialog with more information" "description": "Shown on the message request warning. Clicking this button will open a dialog with more information"
@ -7128,6 +7136,18 @@
"messageformat": "Call link", "messageformat": "Call link",
"description": "Call History > Short description of call > When you joined a call link call" "description": "Call History > Short description of call > When you joined a call link call"
}, },
"icu:CallLinkDetails__Join": {
"messageformat": "Join",
"description": "Call History > Call Link Details > Join Button"
},
"icu:CallLinkDetails__CopyLink": {
"messageformat": "Copy link",
"description": "Call History > Call Link Details > Copy Link Button"
},
"icu:CallLinkDetails__ShareLinkViaSignal": {
"messageformat": "Share link via Signal",
"description": "Call History > Call Link Details > Share Link via Signal Button"
},
"icu:TypingBubble__avatar--overflow-count": { "icu:TypingBubble__avatar--overflow-count": {
"messageformat": "{count, plural, one {# other is} other {# others are}} typing.", "messageformat": "{count, plural, one {# other is} other {# others are}} typing.",
"description": "Group chat multiple person typing indicator when space isn't available to show every avatar, this is the count of avatars hidden." "description": "Group chat multiple person typing indicator when space isn't available to show every avatar, this is the count of avatars hidden."

View file

@ -0,0 +1,47 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
.CallLinkDetails__Container {
max-width: 750px;
margin-block: 0;
margin-inline: auto;
user-select: none;
}
.CallLinkDetails__Header {
display: flex;
align-items: center;
gap: 16px;
width: 100%;
margin-bottom: 24px;
}
.CallLinkDetails__HeaderAvatar,
.CallLinkDetails__HeaderActions {
flex-shrink: 0;
}
.CallLinkDetails__HeaderDetails {
flex: 1;
}
.CallLinkDetails__HeaderTitle {
margin: 0;
@include font-title-medium;
}
.CallLinkDetails__HeaderDescription {
margin: 0;
user-select: text;
@include font-body-1;
@include light-theme {
color: $color-gray-60;
}
@include dark-theme {
color: $color-gray-25;
}
}
.CallLinkDetails__HeaderButton {
font-weight: 600;
}

View file

@ -189,6 +189,12 @@
} }
} }
&--forward {
&::after {
@include details-icon('../images/icons/v3/forward/forward.svg');
}
}
&--down { &--down {
border-radius: 18px; border-radius: 18px;
@include light-theme { @include light-theme {
@ -486,6 +492,15 @@
} }
} }
.ConversationDetails__CallHistoryGroup__ItemIcon--Adhoc {
@include light-theme {
@include color-svg('../images/icons/v3/link/link.svg', $color-black);
}
@include dark-theme {
@include color-svg('../images/icons/v3/link/link.svg', $color-gray-15);
}
}
.ConversationDetails__CallHistoryGroup__ItemLabel { .ConversationDetails__CallHistoryGroup__ItemLabel {
flex: 1; flex: 1;
} }

View file

@ -50,6 +50,7 @@
@import './components/CallingScreenSharingController.scss'; @import './components/CallingScreenSharingController.scss';
@import './components/CallingSelectPresentingSourcesModal.scss'; @import './components/CallingSelectPresentingSourcesModal.scss';
@import './components/CallingToast.scss'; @import './components/CallingToast.scss';
@import './components/CallLinkDetails.scss';
@import './components/CallingRaisedHandsList.scss'; @import './components/CallingRaisedHandsList.scss';
@import './components/CallingRaisedHandsToasts.scss'; @import './components/CallingRaisedHandsToasts.scss';
@import './components/CallingReactionsToasts.scss'; @import './components/CallingReactionsToasts.scss';

View file

@ -43,6 +43,7 @@ export enum AvatarSize {
FORTY = 40, FORTY = 40,
FORTY_EIGHT = 48, FORTY_EIGHT = 48,
FIFTY_TWO = 52, FIFTY_TWO = 52,
SIXTY_FOUR = 64,
EIGHTY = 80, EIGHTY = 80,
NINETY_SIX = 96, NINETY_SIX = 96,
TWO_HUNDRED_SIXTEEN = 216, TWO_HUNDRED_SIXTEEN = 216,

View file

@ -0,0 +1,107 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import type { CallHistoryGroup } from '../types/CallDisposition';
import type { LocalizerType } from '../types/I18N';
import { CallHistoryGroupPanelSection } from './conversation/conversation-details/CallHistoryGroupPanelSection';
import { PanelSection } from './conversation/conversation-details/PanelSection';
import {
ConversationDetailsIcon,
IconType,
} from './conversation/conversation-details/ConversationDetailsIcon';
import { PanelRow } from './conversation/conversation-details/PanelRow';
import type { CallLinkType } from '../types/CallLink';
import { linkCallRoute } from '../util/signalRoutes';
import { drop } from '../util/drop';
import { Avatar, AvatarSize } from './Avatar';
import { Button, ButtonSize, ButtonVariant } from './Button';
import { copyCallLink } from '../util/copyLinksWithToast';
function toUrlWithoutProtocol(url: URL): string {
return `${url.hostname}${url.pathname}${url.search}${url.hash}`;
}
export type CallLinkDetailsProps = Readonly<{
callHistoryGroup: CallHistoryGroup;
callLink: CallLinkType;
i18n: LocalizerType;
onStartCallLinkLobby: () => void;
onShareCallLinkViaSignal: () => void;
}>;
export function CallLinkDetails({
callHistoryGroup,
callLink,
i18n,
onStartCallLinkLobby,
onShareCallLinkViaSignal,
}: CallLinkDetailsProps): JSX.Element {
const webUrl = linkCallRoute.toWebUrl({
key: callLink.rootKey,
});
return (
<div className="CallLinkDetails__Container">
<header className="CallLinkDetails__Header">
<Avatar
className="CallLinkDetails__HeaderAvatar"
i18n={i18n}
badge={undefined}
conversationType="callLink"
size={AvatarSize.SIXTY_FOUR}
acceptedMessageRequest
isMe={false}
sharedGroupNames={[]}
title={callLink.name ?? i18n('icu:calling__call-link-default-title')}
/>
<div className="CallLinkDetails__HeaderDetails">
<h1 className="CallLinkDetails__HeaderTitle">
{callLink.name === ''
? i18n('icu:calling__call-link-default-title')
: callLink.name}
</h1>
<p className="CallLinkDetails__HeaderDescription">
{toUrlWithoutProtocol(webUrl)}
</p>
</div>
<div className="CallLinkDetails__HeaderActions">
<Button
className="CallLinkDetails__HeaderButton"
variant={ButtonVariant.SecondaryAffirmative}
size={ButtonSize.Small}
onClick={onStartCallLinkLobby}
>
{i18n('icu:CallLinkDetails__Join')}
</Button>
</div>
</header>
<CallHistoryGroupPanelSection
callHistoryGroup={callHistoryGroup}
i18n={i18n}
/>
<PanelSection>
<PanelRow
icon={
<ConversationDetailsIcon
ariaLabel={i18n('icu:CallLinkDetails__CopyLink')}
icon={IconType.share}
/>
}
label={i18n('icu:CallLinkDetails__CopyLink')}
onClick={() => {
drop(copyCallLink(webUrl.toString()));
}}
/>
<PanelRow
icon={
<ConversationDetailsIcon
ariaLabel={i18n('icu:CallLinkDetails__ShareLinkViaSignal')}
icon={IconType.forward}
/>
}
label={i18n('icu:CallLinkDetails__ShareLinkViaSignal')}
onClick={onShareCallLinkViaSignal}
/>
</PanelSection>
</div>
);
}

View file

@ -100,7 +100,6 @@ const createProps = (storyProps: Partial<PropsType> = {}): PropsType => ({
setPresenting: action('toggle-presenting'), setPresenting: action('toggle-presenting'),
setRendererCanvas: action('set-renderer-canvas'), setRendererCanvas: action('set-renderer-canvas'),
setOutgoingRing: action('set-outgoing-ring'), setOutgoingRing: action('set-outgoing-ring'),
showToast: action('show-toast'),
startCall: action('start-call'), startCall: action('start-call'),
stopRingtone: action('stop-ringtone'), stopRingtone: action('stop-ringtone'),
switchToPresentationView: action('switch-to-presentation-view'), switchToPresentationView: action('switch-to-presentation-view'),

View file

@ -53,10 +53,9 @@ import * as log from '../logging/log';
import { isGroupOrAdhocActiveCall } from '../util/isGroupOrAdhocCall'; import { isGroupOrAdhocActiveCall } from '../util/isGroupOrAdhocCall';
import { CallingAdhocCallInfo } from './CallingAdhocCallInfo'; import { CallingAdhocCallInfo } from './CallingAdhocCallInfo';
import { callLinkRootKeyToUrl } from '../util/callLinkRootKeyToUrl'; import { callLinkRootKeyToUrl } from '../util/callLinkRootKeyToUrl';
import { ToastType } from '../types/Toast';
import type { ShowToastAction } from '../state/ducks/toast';
import { isSharingPhoneNumberWithEverybody } from '../util/phoneNumberSharingMode'; import { isSharingPhoneNumberWithEverybody } from '../util/phoneNumberSharingMode';
import { usePrevious } from '../hooks/usePrevious'; import { usePrevious } from '../hooks/usePrevious';
import { copyCallLink } from '../util/copyLinksWithToast';
const GROUP_CALL_RING_DURATION = 60 * 1000; const GROUP_CALL_RING_DURATION = 60 * 1000;
@ -127,7 +126,6 @@ export type PropsType = {
setOutgoingRing: (_: boolean) => void; setOutgoingRing: (_: boolean) => void;
setPresenting: (_?: PresentedSource) => void; setPresenting: (_?: PresentedSource) => void;
setRendererCanvas: (_: SetRendererCanvasType) => void; setRendererCanvas: (_: SetRendererCanvasType) => void;
showToast: ShowToastAction;
stopRingtone: () => unknown; stopRingtone: () => unknown;
switchToPresentationView: () => void; switchToPresentationView: () => void;
switchFromPresentationView: () => void; switchFromPresentationView: () => void;
@ -186,7 +184,6 @@ function ActiveCallManager({
setPresenting, setPresenting,
setRendererCanvas, setRendererCanvas,
setOutgoingRing, setOutgoingRing,
showToast,
startCall, startCall,
switchToPresentationView, switchToPresentationView,
switchFromPresentationView, switchFromPresentationView,
@ -266,10 +263,9 @@ function ActiveCallManager({
const link = callLinkRootKeyToUrl(callLink.rootKey); const link = callLinkRootKeyToUrl(callLink.rootKey);
if (link) { if (link) {
await window.navigator.clipboard.writeText(link); await copyCallLink(link);
showToast({ toastType: ToastType.CopiedCallLink });
} }
}, [callLink, showToast]); }, [callLink]);
let isCallFull: boolean; let isCallFull: boolean;
let showCallLobby: boolean; let showCallLobby: boolean;
@ -528,7 +524,6 @@ export function CallManager({
setOutgoingRing, setOutgoingRing,
setPresenting, setPresenting,
setRendererCanvas, setRendererCanvas,
showToast,
startCall, startCall,
stopRingtone, stopRingtone,
switchFromPresentationView, switchFromPresentationView,
@ -615,7 +610,6 @@ export function CallManager({
setOutgoingRing={setOutgoingRing} setOutgoingRing={setOutgoingRing}
setPresenting={setPresenting} setPresenting={setPresenting}
setRendererCanvas={setRendererCanvas} setRendererCanvas={setRendererCanvas}
showToast={showToast}
startCall={startCall} startCall={startCall}
switchFromPresentationView={switchFromPresentationView} switchFromPresentationView={switchFromPresentationView}
switchToPresentationView={switchToPresentationView} switchToPresentationView={switchToPresentationView}

View file

@ -53,6 +53,7 @@ import {
callLinkToConversation, callLinkToConversation,
getPlaceholderCallLinkConversation, getPlaceholderCallLinkConversation,
} from '../util/callLinks'; } from '../util/callLinks';
import type { CallsTabSelectedView } from './CallsTab';
import type { CallStateType } from '../state/selectors/calling'; import type { CallStateType } from '../state/selectors/calling';
import { import {
isGroupOrAdhocCallMode, isGroupOrAdhocCallMode,
@ -143,10 +144,7 @@ type CallsListProps = Readonly<{
selectedCallHistoryGroup: CallHistoryGroup | null; selectedCallHistoryGroup: CallHistoryGroup | null;
onOutgoingAudioCallInConversation: (conversationId: string) => void; onOutgoingAudioCallInConversation: (conversationId: string) => void;
onOutgoingVideoCallInConversation: (conversationId: string) => void; onOutgoingVideoCallInConversation: (conversationId: string) => void;
onSelectCallHistoryGroup: ( onChangeCallsTabSelectedView: (selectedView: CallsTabSelectedView) => void;
conversationId: string,
selectedCallHistoryGroup: CallHistoryGroup
) => void;
peekNotConnectedGroupCall: (options: PeekNotConnectedGroupCallType) => void; peekNotConnectedGroupCall: (options: PeekNotConnectedGroupCallType) => void;
startCallLinkLobbyByRoomId: (roomId: string) => void; startCallLinkLobbyByRoomId: (roomId: string) => void;
togglePip: () => void; togglePip: () => void;
@ -184,7 +182,7 @@ export function CallsList({
selectedCallHistoryGroup, selectedCallHistoryGroup,
onOutgoingAudioCallInConversation, onOutgoingAudioCallInConversation,
onOutgoingVideoCallInConversation, onOutgoingVideoCallInConversation,
onSelectCallHistoryGroup, onChangeCallsTabSelectedView,
peekNotConnectedGroupCall, peekNotConnectedGroupCall,
startCallLinkLobbyByRoomId, startCallLinkLobbyByRoomId,
togglePip, togglePip,
@ -771,13 +769,22 @@ export function CallsList({
} }
onClick={() => { onClick={() => {
if (isAdhoc) { if (isAdhoc) {
onChangeCallsTabSelectedView({
type: 'callLink',
roomId: item.peerId,
callHistoryGroup: item,
});
return; return;
} }
if (conversation == null) { if (conversation == null) {
return; return;
} }
onSelectCallHistoryGroup(conversation.id, item); onChangeCallsTabSelectedView({
type: 'conversation',
conversationId: conversation.id,
callHistoryGroup: item,
});
}} }}
/> />
</div> </div>
@ -791,7 +798,7 @@ export function CallsList({
getIsCallActive, getIsCallActive,
getIsInCall, getIsInCall,
selectedCallHistoryGroup, selectedCallHistoryGroup,
onSelectCallHistoryGroup, onChangeCallsTabSelectedView,
onOutgoingAudioCallInConversation, onOutgoingAudioCallInConversation,
onOutgoingVideoCallInConversation, onOutgoingVideoCallInConversation,
startCallLinkLobbyByRoomId, startCallLinkLobbyByRoomId,

View file

@ -19,13 +19,14 @@ import { Avatar, AvatarSize } from './Avatar';
import { I18n } from './I18n'; import { I18n } from './I18n';
import { SizeObserver } from '../hooks/useSizeObserver'; import { SizeObserver } from '../hooks/useSizeObserver';
import { CallType } from '../types/CallDisposition'; import { CallType } from '../types/CallDisposition';
import type { CallsTabSelectedView } from './CallsTab';
import { Tooltip, TooltipPlacement } from './Tooltip'; import { Tooltip, TooltipPlacement } from './Tooltip';
type CallsNewCallProps = Readonly<{ type CallsNewCallProps = Readonly<{
hasActiveCall: boolean; hasActiveCall: boolean;
allConversations: ReadonlyArray<ConversationType>; allConversations: ReadonlyArray<ConversationType>;
i18n: LocalizerType; i18n: LocalizerType;
onSelectConversation: (conversationId: string) => void; onChangeCallsTabSelectedView: (selectedView: CallsTabSelectedView) => void;
onOutgoingAudioCallInConversation: (conversationId: string) => void; onOutgoingAudioCallInConversation: (conversationId: string) => void;
onOutgoingVideoCallInConversation: (conversationId: string) => void; onOutgoingVideoCallInConversation: (conversationId: string) => void;
regionCode: string | undefined; regionCode: string | undefined;
@ -106,7 +107,7 @@ export function CallsNewCall({
hasActiveCall, hasActiveCall,
allConversations, allConversations,
i18n, i18n,
onSelectConversation, onChangeCallsTabSelectedView,
onOutgoingAudioCallInConversation, onOutgoingAudioCallInConversation,
onOutgoingVideoCallInConversation, onOutgoingVideoCallInConversation,
regionCode, regionCode,
@ -262,7 +263,11 @@ export function CallsNewCall({
</div> </div>
} }
onClick={() => { onClick={() => {
onSelectConversation(item.conversation.id); onChangeCallsTabSelectedView({
type: 'conversation',
conversationId: item.conversation.id,
callHistoryGroup: null,
});
}} }}
/> />
</div> </div>
@ -272,7 +277,7 @@ export function CallsNewCall({
rows, rows,
i18n, i18n,
hasActiveCall, hasActiveCall,
onSelectConversation, onChangeCallsTabSelectedView,
onOutgoingAudioCallInConversation, onOutgoingAudioCallInConversation,
onOutgoingVideoCallInConversation, onOutgoingVideoCallInConversation,
] ]

View file

@ -57,6 +57,10 @@ type CallsTabProps = Readonly<{
onOutgoingVideoCallInConversation: (conversationId: string) => void; onOutgoingVideoCallInConversation: (conversationId: string) => void;
peekNotConnectedGroupCall: (options: PeekNotConnectedGroupCallType) => void; peekNotConnectedGroupCall: (options: PeekNotConnectedGroupCallType) => void;
preferredLeftPaneWidth: number; preferredLeftPaneWidth: number;
renderCallLinkDetails: (
roomId: string,
callHistoryGroup: CallHistoryGroup
) => JSX.Element;
renderConversationDetails: ( renderConversationDetails: (
conversationId: string, conversationId: string,
callHistoryGroup: CallHistoryGroup | null callHistoryGroup: CallHistoryGroup | null
@ -70,6 +74,18 @@ type CallsTabProps = Readonly<{
togglePip: () => void; togglePip: () => void;
}>; }>;
export type CallsTabSelectedView =
| {
type: 'conversation';
conversationId: string;
callHistoryGroup: CallHistoryGroup | null;
}
| {
type: 'callLink';
roomId: string;
callHistoryGroup: CallHistoryGroup;
};
export function CallsTab({ export function CallsTab({
activeCall, activeCall,
allConversations, allConversations,
@ -93,6 +109,7 @@ export function CallsTab({
onOutgoingVideoCallInConversation, onOutgoingVideoCallInConversation,
peekNotConnectedGroupCall, peekNotConnectedGroupCall,
preferredLeftPaneWidth, preferredLeftPaneWidth,
renderCallLinkDetails,
renderConversationDetails, renderConversationDetails,
renderToastManager, renderToastManager,
regionCode, regionCode,
@ -103,37 +120,31 @@ export function CallsTab({
const [sidebarView, setSidebarView] = useState( const [sidebarView, setSidebarView] = useState(
CallsTabSidebarView.CallsListView CallsTabSidebarView.CallsListView
); );
const [selected, setSelected] = useState<{ const [selectedView, setSelectedViewInner] =
conversationId: string; useState<CallsTabSelectedView | null>(null);
callHistoryGroup: CallHistoryGroup | null; const [selectedViewKey, setSelectedViewKey] = useState(() => 1);
} | null>(null);
const [ const [
confirmClearCallHistoryDialogOpen, confirmClearCallHistoryDialogOpen,
setConfirmClearCallHistoryDialogOpen, setConfirmClearCallHistoryDialogOpen,
] = useState(false); ] = useState(false);
const updateSelectedView = useCallback(
(nextSelected: CallsTabSelectedView | null) => {
setSelectedViewInner(nextSelected);
setSelectedViewKey(key => key + 1);
},
[]
);
const updateSidebarView = useCallback( const updateSidebarView = useCallback(
(newSidebarView: CallsTabSidebarView) => { (newSidebarView: CallsTabSidebarView) => {
setSidebarView(newSidebarView); setSidebarView(newSidebarView);
setSelected(null); updateSelectedView(null);
}, },
[] [updateSelectedView]
); );
const handleSelectCallHistoryGroup = useCallback(
(conversationId: string, callHistoryGroup: CallHistoryGroup) => {
setSelected({
conversationId,
callHistoryGroup,
});
},
[]
);
const handleSelectConversation = useCallback((conversationId: string) => {
setSelected({ conversationId, callHistoryGroup: null });
}, []);
useEscapeHandling( useEscapeHandling(
sidebarView === CallsTabSidebarView.NewCallView sidebarView === CallsTabSidebarView.NewCallView
? () => { ? () => {
@ -167,12 +178,12 @@ export function CallsTab({
); );
useEffect(() => { useEffect(() => {
if (selected?.callHistoryGroup != null) { if (selectedView?.type === 'conversation') {
selected.callHistoryGroup.children.forEach(child => { selectedView.callHistoryGroup?.children.forEach(child => {
onMarkCallHistoryRead(selected.conversationId, child.callId); onMarkCallHistoryRead(selectedView.conversationId, child.callId);
}); });
} }
}, [selected, onMarkCallHistoryRead]); }, [selectedView, onMarkCallHistoryRead]);
return ( return (
<> <>
@ -255,8 +266,8 @@ export function CallsTab({
getConversation={getConversation} getConversation={getConversation}
hangUpActiveCall={hangUpActiveCall} hangUpActiveCall={hangUpActiveCall}
i18n={i18n} i18n={i18n}
selectedCallHistoryGroup={selected?.callHistoryGroup ?? null} selectedCallHistoryGroup={selectedView?.callHistoryGroup ?? null}
onSelectCallHistoryGroup={handleSelectCallHistoryGroup} onChangeCallsTabSelectedView={updateSelectedView}
onOutgoingAudioCallInConversation={ onOutgoingAudioCallInConversation={
handleOutgoingAudioCallInConversation handleOutgoingAudioCallInConversation
} }
@ -275,7 +286,7 @@ export function CallsTab({
allConversations={allConversations} allConversations={allConversations}
i18n={i18n} i18n={i18n}
regionCode={regionCode} regionCode={regionCode}
onSelectConversation={handleSelectConversation} onChangeCallsTabSelectedView={updateSelectedView}
onOutgoingAudioCallInConversation={ onOutgoingAudioCallInConversation={
handleOutgoingAudioCallInConversation handleOutgoingAudioCallInConversation
} }
@ -285,7 +296,7 @@ export function CallsTab({
/> />
)} )}
</NavSidebar> </NavSidebar>
{selected == null ? ( {selectedView == null ? (
<div className="CallsTab__EmptyState"> <div className="CallsTab__EmptyState">
<div className="CallsTab__EmptyStateIcon" /> <div className="CallsTab__EmptyStateIcon" />
<p className="CallsTab__EmptyStateLabel"> <p className="CallsTab__EmptyStateLabel">
@ -295,12 +306,18 @@ export function CallsTab({
) : ( ) : (
<div <div
className="CallsTab__ConversationCallDetails" className="CallsTab__ConversationCallDetails"
// Force scrolling to top when a new conversation is selected. // Force scrolling to top when selection changes
key={selected.conversationId} key={selectedViewKey}
> >
{renderConversationDetails( {selectedView.type === 'conversation' &&
selected.conversationId, renderConversationDetails(
selected.callHistoryGroup selectedView.conversationId,
selectedView.callHistoryGroup
)}
{selectedView.type === 'callLink' &&
renderCallLinkDetails(
selectedView.roomId,
selectedView.callHistoryGroup
)} )}
</div> </div>
)} )}

View file

@ -72,6 +72,8 @@ import type { SmartCompositionRecordingProps } from '../state/smart/CompositionR
import SelectModeActions from './conversation/SelectModeActions'; import SelectModeActions from './conversation/SelectModeActions';
import type { ShowToastAction } from '../state/ducks/toast'; import type { ShowToastAction } from '../state/ducks/toast';
import type { DraftEditMessageType } from '../model-types.d'; import type { DraftEditMessageType } from '../model-types.d';
import type { ForwardMessagesPayload } from '../state/ducks/globalModals';
import { ForwardMessagesModalType } from './ForwardMessagesModal';
export type OwnProps = Readonly<{ export type OwnProps = Readonly<{
acceptedMessageRequest: boolean | null; acceptedMessageRequest: boolean | null;
@ -181,7 +183,7 @@ export type OwnProps = Readonly<{
selectedMessageIds: ReadonlyArray<string> | undefined; selectedMessageIds: ReadonlyArray<string> | undefined;
toggleSelectMode: (on: boolean) => void; toggleSelectMode: (on: boolean) => void;
toggleForwardMessagesModal: ( toggleForwardMessagesModal: (
messageIds: ReadonlyArray<string>, payload: ForwardMessagesPayload,
onForward: () => void onForward: () => void
) => void; ) => void;
}>; }>;
@ -725,9 +727,15 @@ export const CompositionArea = memo(function CompositionArea({
}} }}
onForwardMessages={() => { onForwardMessages={() => {
if (selectedMessageIds.length > 0) { if (selectedMessageIds.length > 0) {
toggleForwardMessagesModal(selectedMessageIds, () => { toggleForwardMessagesModal(
{
type: ForwardMessagesModalType.Forward,
messageIds: selectedMessageIds,
},
() => {
toggleSelectMode(false); toggleSelectMode(false);
}); }
);
} }
}} }}
showToast={showToast} showToast={showToast}

View file

@ -7,7 +7,10 @@ import type { Meta } from '@storybook/react';
import enMessages from '../../_locales/en/messages.json'; import enMessages from '../../_locales/en/messages.json';
import type { AttachmentType } from '../types/Attachment'; import type { AttachmentType } from '../types/Attachment';
import type { PropsType } from './ForwardMessagesModal'; import type { PropsType } from './ForwardMessagesModal';
import { ForwardMessagesModal } from './ForwardMessagesModal'; import {
ForwardMessagesModal,
ForwardMessagesModalType,
} from './ForwardMessagesModal';
import { IMAGE_JPEG, VIDEO_MP4, stringToMIMEType } from '../types/MIME'; import { IMAGE_JPEG, VIDEO_MP4, stringToMIMEType } from '../types/MIME';
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation'; import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
import { setupI18n } from '../util/setupI18n'; import { setupI18n } from '../util/setupI18n';
@ -67,6 +70,7 @@ const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
/> />
), ),
showToast: action('showToast'), showToast: action('showToast'),
type: ForwardMessagesModalType.Forward,
theme: React.useContext(StorybookThemeContext), theme: React.useContext(StorybookThemeContext),
regionCode: 'US', regionCode: 'US',
}); });

View file

@ -42,6 +42,12 @@ import {
isDraftForwardable, isDraftForwardable,
type MessageForwardDraft, type MessageForwardDraft,
} from '../types/ForwardDraft'; } from '../types/ForwardDraft';
import { missingCaseError } from '../util/missingCaseError';
export enum ForwardMessagesModalType {
Forward,
ShareCallLink,
}
export type DataPropsType = { export type DataPropsType = {
candidateConversations: ReadonlyArray<ConversationType>; candidateConversations: ReadonlyArray<ConversationType>;
@ -63,6 +69,7 @@ export type DataPropsType = {
) => unknown; ) => unknown;
regionCode: string | undefined; regionCode: string | undefined;
RenderCompositionTextArea: ComponentType<SmartCompositionTextAreaProps>; RenderCompositionTextArea: ComponentType<SmartCompositionTextAreaProps>;
type: ForwardMessagesModalType;
showToast: ShowToastAction; showToast: ShowToastAction;
theme: ThemeType; theme: ThemeType;
}; };
@ -76,6 +83,7 @@ export type PropsType = DataPropsType & ActionPropsType;
const MAX_FORWARD = 5; const MAX_FORWARD = 5;
export function ForwardMessagesModal({ export function ForwardMessagesModal({
type,
drafts, drafts,
candidateConversations, candidateConversations,
doForwardMessages, doForwardMessages,
@ -292,6 +300,15 @@ export function ForwardMessagesModal({
</div> </div>
); );
let title: string;
if (type === ForwardMessagesModalType.Forward) {
title = i18n('icu:ForwardMessageModal__title');
} else if (type === ForwardMessagesModalType.ShareCallLink) {
title = i18n('icu:ForwardMessageModal__ShareCallLink');
} else {
throw missingCaseError(type);
}
return ( return (
<> <>
{cannotMessage && ( {cannotMessage && (
@ -311,7 +328,7 @@ export function ForwardMessagesModal({
onClose={onClose} onClose={onClose}
onBackButtonClick={isEditingMessage ? handleBackOrClose : undefined} onBackButtonClick={isEditingMessage ? handleBackOrClose : undefined}
moduleClassName="module-ForwardMessageModal" moduleClassName="module-ForwardMessageModal"
title={i18n('icu:ForwardMessageModal__title')} title={title}
useFocusTrap={false} useFocusTrap={false}
padded={false} padded={false}
modalFooter={footer} modalFooter={footer}

View file

@ -28,6 +28,8 @@ import { usePrevious } from '../hooks/usePrevious';
import { arrow } from '../util/keyboard'; import { arrow } from '../util/keyboard';
import { drop } from '../util/drop'; import { drop } from '../util/drop';
import { isCmdOrCtrl } from '../hooks/useKeyboardShortcuts'; import { isCmdOrCtrl } from '../hooks/useKeyboardShortcuts';
import type { ForwardMessagesPayload } from '../state/ducks/globalModals';
import { ForwardMessagesModalType } from './ForwardMessagesModal';
export type PropsType = { export type PropsType = {
children?: ReactNode; children?: ReactNode;
@ -39,7 +41,7 @@ export type PropsType = {
playbackDisabled: boolean; playbackDisabled: boolean;
saveAttachment: SaveAttachmentActionCreatorType; saveAttachment: SaveAttachmentActionCreatorType;
selectedIndex: number; selectedIndex: number;
toggleForwardMessagesModal: (messageIds: ReadonlyArray<string>) => unknown; toggleForwardMessagesModal: (payload: ForwardMessagesPayload) => unknown;
onMediaPlaybackStart: () => void; onMediaPlaybackStart: () => void;
onNextAttachment: () => void; onNextAttachment: () => void;
onPrevAttachment: () => void; onPrevAttachment: () => void;
@ -195,7 +197,10 @@ export function Lightbox({
closeLightbox(); closeLightbox();
const mediaItem = media[selectedIndex]; const mediaItem = media[selectedIndex];
toggleForwardMessagesModal([mediaItem.message.id]); toggleForwardMessagesModal({
type: ForwardMessagesModalType.Forward,
messageIds: [mediaItem.message.id],
});
}; };
const onKeyDown = useCallback( const onKeyDown = useCallback(

View file

@ -32,13 +32,17 @@ import {
useToggleReactionPicker, useToggleReactionPicker,
} from '../../hooks/useKeyboardShortcuts'; } from '../../hooks/useKeyboardShortcuts';
import { PanelType } from '../../types/Panels'; import { PanelType } from '../../types/Panels';
import type { DeleteMessagesPropsType } from '../../state/ducks/globalModals'; import type {
DeleteMessagesPropsType,
ForwardMessagesPayload,
} from '../../state/ducks/globalModals';
import { useScrollerLock } from '../../hooks/useScrollLock'; import { useScrollerLock } from '../../hooks/useScrollLock';
import { import {
type ContextMenuTriggerType, type ContextMenuTriggerType,
MessageContextMenu, MessageContextMenu,
useHandleMessageContextMenu, useHandleMessageContextMenu,
} from './MessageContextMenu'; } from './MessageContextMenu';
import { ForwardMessagesModalType } from '../ForwardMessagesModal';
export type PropsData = { export type PropsData = {
canDownload: boolean; canDownload: boolean;
@ -55,7 +59,7 @@ export type PropsData = {
export type PropsActions = { export type PropsActions = {
pushPanelForConversation: PushPanelForConversationActionType; pushPanelForConversation: PushPanelForConversationActionType;
toggleDeleteMessagesModal: (props: DeleteMessagesPropsType) => void; toggleDeleteMessagesModal: (props: DeleteMessagesPropsType) => void;
toggleForwardMessagesModal: (messageIds: Array<string>) => void; toggleForwardMessagesModal: (payload: ForwardMessagesPayload) => void;
reactToMessage: ( reactToMessage: (
id: string, id: string,
{ emoji, remove }: { emoji: string; remove: boolean } { emoji, remove }: { emoji: string; remove: boolean }
@ -372,7 +376,13 @@ export function TimelineMessage(props: Props): JSX.Element {
onCopy={canCopy ? () => copyMessageText(id) : undefined} onCopy={canCopy ? () => copyMessageText(id) : undefined}
onSelect={() => toggleSelectMessage(conversationId, id, false, true)} onSelect={() => toggleSelectMessage(conversationId, id, false, true)}
onForward={ onForward={
canForward ? () => toggleForwardMessagesModal([id]) : undefined canForward
? () =>
toggleForwardMessagesModal({
type: ForwardMessagesModalType.Forward,
messageIds: [id],
})
: undefined
} }
onDeleteMessage={() => { onDeleteMessage={() => {
toggleDeleteMessagesModal({ toggleDeleteMessagesModal({

View file

@ -0,0 +1,91 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import classNames from 'classnames';
import type { CallStatus } from '../../../types/CallDisposition';
import {
CallDirection,
CallType,
DirectCallStatus,
GroupCallStatus,
type CallHistoryGroup,
} from '../../../types/CallDisposition';
import type { LocalizerType } from '../../../types/I18N';
import { formatDate, formatTime } from '../../../util/timestamp';
import { PanelSection } from './PanelSection';
function describeCallHistory(
i18n: LocalizerType,
type: CallType,
direction: CallDirection,
status: CallStatus
): string {
if (type === CallType.Adhoc) {
return i18n('icu:CallHistory__Description--Adhoc');
}
if (status === DirectCallStatus.Missed || status === GroupCallStatus.Missed) {
if (direction === CallDirection.Incoming) {
return i18n('icu:CallHistory__Description--Missed', { type });
}
return i18n('icu:CallHistory__Description--Unanswered', { type });
}
if (
status === DirectCallStatus.Declined ||
status === GroupCallStatus.Declined
) {
return i18n('icu:CallHistory__Description--Declined', { type });
}
return i18n('icu:CallHistory__Description--Default', { type, direction });
}
export type CallHistoryPanelSectionProps = Readonly<{
callHistoryGroup: CallHistoryGroup;
i18n: LocalizerType;
}>;
export function CallHistoryGroupPanelSection({
callHistoryGroup,
i18n,
}: CallHistoryPanelSectionProps): JSX.Element {
return (
<PanelSection title={formatDate(i18n, callHistoryGroup.timestamp)}>
<ol className="ConversationDetails__CallHistoryGroup__List">
{callHistoryGroup.children.map(child => {
return (
<li
key={child.callId}
className="ConversationDetails__CallHistoryGroup__Item"
>
<span
className={classNames(
'ConversationDetails__CallHistoryGroup__ItemIcon',
{
'ConversationDetails__CallHistoryGroup__ItemIcon--Audio':
callHistoryGroup.type === CallType.Audio,
'ConversationDetails__CallHistoryGroup__ItemIcon--Video':
callHistoryGroup.type === CallType.Video ||
callHistoryGroup.type === CallType.Group,
'ConversationDetails__CallHistoryGroup__ItemIcon--Adhoc':
callHistoryGroup.type === CallType.Adhoc,
}
)}
/>
<span className="ConversationDetails__CallHistoryGroup__ItemLabel">
{describeCallHistory(
i18n,
callHistoryGroup.type,
callHistoryGroup.direction,
callHistoryGroup.status
)}
</span>
<span className="ConversationDetails__CallHistoryGroup__ItemTimestamp">
{formatTime(i18n, child.timestamp, Date.now(), false)}
</span>
</li>
);
})}
</ol>
</PanelSection>
);
}

View file

@ -4,7 +4,6 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import React, { useEffect, useState, useCallback } from 'react'; import React, { useEffect, useState, useCallback } from 'react';
import classNames from 'classnames';
import { Button, ButtonIconType, ButtonVariant } from '../../Button'; import { Button, ButtonIconType, ButtonVariant } from '../../Button';
import { Tooltip } from '../../Tooltip'; import { Tooltip } from '../../Tooltip';
import type { import type {
@ -53,43 +52,11 @@ import type {
import { isConversationMuted } from '../../../util/isConversationMuted'; import { isConversationMuted } from '../../../util/isConversationMuted';
import { ConversationDetailsGroups } from './ConversationDetailsGroups'; import { ConversationDetailsGroups } from './ConversationDetailsGroups';
import { PanelType } from '../../../types/Panels'; import { PanelType } from '../../../types/Panels';
import type { CallStatus } from '../../../types/CallDisposition'; import { type CallHistoryGroup } from '../../../types/CallDisposition';
import {
CallType,
type CallHistoryGroup,
CallDirection,
DirectCallStatus,
GroupCallStatus,
} from '../../../types/CallDisposition';
import { formatDate, formatTime } from '../../../util/timestamp';
import { NavTab } from '../../../state/ducks/nav'; import { NavTab } from '../../../state/ducks/nav';
import { ContextMenu } from '../../ContextMenu'; import { ContextMenu } from '../../ContextMenu';
import { canHaveNicknameAndNote } from '../../../util/nicknames'; import { canHaveNicknameAndNote } from '../../../util/nicknames';
import { CallHistoryGroupPanelSection } from './CallHistoryGroupPanelSection';
function describeCallHistory(
i18n: LocalizerType,
type: CallType,
direction: CallDirection,
status: CallStatus
): string {
if (type === CallType.Adhoc) {
return i18n('icu:CallHistory__Description--Adhoc');
}
if (status === DirectCallStatus.Missed || status === GroupCallStatus.Missed) {
if (direction === CallDirection.Incoming) {
return i18n('icu:CallHistory__Description--Missed', { type });
}
return i18n('icu:CallHistory__Description--Unanswered', { type });
}
if (
status === DirectCallStatus.Declined ||
status === GroupCallStatus.Declined
) {
return i18n('icu:CallHistory__Description--Declined', { type });
}
return i18n('icu:CallHistory__Description--Default', { type, direction });
}
enum ModalState { enum ModalState {
AddingGroupMembers, AddingGroupMembers,
@ -501,41 +468,10 @@ export function ConversationDetails({
</div> </div>
{callHistoryGroup && ( {callHistoryGroup && (
<PanelSection title={formatDate(i18n, callHistoryGroup.timestamp)}> <CallHistoryGroupPanelSection
<ol className="ConversationDetails__CallHistoryGroup__List"> callHistoryGroup={callHistoryGroup}
{callHistoryGroup.children.map(child => { i18n={i18n}
return (
<li
key={child.callId}
className="ConversationDetails__CallHistoryGroup__Item"
>
<span
className={classNames(
'ConversationDetails__CallHistoryGroup__ItemIcon',
{
'ConversationDetails__CallHistoryGroup__ItemIcon--Audio':
callHistoryGroup.type === CallType.Audio,
'ConversationDetails__CallHistoryGroup__ItemIcon--Video':
callHistoryGroup.type !== CallType.Audio,
}
)}
/> />
<span className="ConversationDetails__CallHistoryGroup__ItemLabel">
{describeCallHistory(
i18n,
callHistoryGroup.type,
callHistoryGroup.direction,
callHistoryGroup.status
)}
</span>
<span className="ConversationDetails__CallHistoryGroup__ItemTimestamp">
{formatTime(i18n, child.timestamp, Date.now(), false)}
</span>
</li>
);
})}
</ol>
</PanelSection>
)} )}
<PanelSection> <PanelSection>

View file

@ -13,6 +13,7 @@ export enum IconType {
'unblock' = 'unblock', 'unblock' = 'unblock',
'color' = 'color', 'color' = 'color',
'down' = 'down', 'down' = 'down',
'forward' = 'forward',
'invites' = 'invites', 'invites' = 'invites',
'leave' = 'leave', 'leave' = 'leave',
'link' = 'link', 'link' = 'link',

View file

@ -13,7 +13,7 @@ import { PanelSection } from './PanelSection';
import { Select } from '../../Select'; import { Select } from '../../Select';
import { SignalService as Proto } from '../../../protobuf'; import { SignalService as Proto } from '../../../protobuf';
import { copyGroupLink } from '../../../util/copyGroupLink'; import { copyGroupLink } from '../../../util/copyLinksWithToast';
import { useDelayedRestoreFocus } from '../../../hooks/useRestoreFocus'; import { useDelayedRestoreFocus } from '../../../hooks/useRestoreFocus';
import { useUniqueId } from '../../../hooks/useUniqueId'; import { useUniqueId } from '../../../hooks/useUniqueId';

View file

@ -9,6 +9,7 @@ import { drop } from '../util/drop';
import { matchOrQueryFocusable } from '../util/focusableSelectors'; import { matchOrQueryFocusable } from '../util/focusableSelectors';
import { getQuotedMessageSelector } from '../state/selectors/composer'; import { getQuotedMessageSelector } from '../state/selectors/composer';
import { removeLinkPreview } from './LinkPreview'; import { removeLinkPreview } from './LinkPreview';
import { ForwardMessagesModalType } from '../components/ForwardMessagesModal';
export function addGlobalKeyboardShortcuts(): void { export function addGlobalKeyboardShortcuts(): void {
const isMacOS = window.platform === 'darwin'; const isMacOS = window.platform === 'darwin';
@ -492,7 +493,7 @@ export function addGlobalKeyboardShortcuts(): void {
event.stopPropagation(); event.stopPropagation();
window.reduxActions.globalModals.toggleForwardMessagesModal( window.reduxActions.globalModals.toggleForwardMessagesModal(
messageIds, { type: ForwardMessagesModalType.Forward, messageIds },
() => { () => {
if (selectedMessageIds != null) { if (selectedMessageIds != null) {
window.reduxActions.conversations.toggleSelectMode(false); window.reduxActions.conversations.toggleSelectMode(false);

View file

@ -3772,8 +3772,6 @@ function getCallHistoryGroupDataSync(
-- Desktop Constraints: -- Desktop Constraints:
AND callsHistory.status IS c.status AND callsHistory.status IS c.status
AND ${filterClause} AND ${filterClause}
-- Skip grouping logic for adhoc calls
AND callsHistory.mode IS NOT ${CALL_MODE_ADHOC}
ORDER BY timestamp DESC ORDER BY timestamp DESC
) as possibleChildren, ) as possibleChildren,
@ -3893,17 +3891,21 @@ async function getCallHistoryGroups(
}) })
.reverse() .reverse()
.map(group => { .map(group => {
const { possibleChildren, inPeriod, ...rest } = group; const { possibleChildren, inPeriod, type, ...rest } = group;
const children = []; const children = [];
for (const child of possibleChildren) { for (const child of possibleChildren) {
if (!taken.has(child.callId) && inPeriod.has(child.callId)) { if (!taken.has(child.callId) && inPeriod.has(child.callId)) {
children.push(child); children.push(child);
taken.add(child.callId); taken.add(child.callId);
if (type === CallType.Adhoc) {
// By spec, limit adhoc call history to the most recent call
break;
}
} }
} }
return callHistoryGroupSchema.parse({ ...rest, children }); return callHistoryGroupSchema.parse({ ...rest, type, children });
}) })
.reverse(); .reverse();
} }

View file

@ -79,7 +79,9 @@ export async function getGroupSendCombinedEndorsementExpiration(
SELECT expiration FROM groupSendCombinedEndorsement SELECT expiration FROM groupSendCombinedEndorsement
WHERE groupId = ${groupId}; WHERE groupId = ${groupId};
`; `;
const value = prepare(db, selectGroup).pluck().get(selectGroupParams); const value = prepare<Array<unknown>>(db, selectGroup)
.pluck()
.get(selectGroupParams);
if (value == null) { if (value == null) {
return null; return null;
} }

View file

@ -37,6 +37,14 @@ import {
import { isDownloaded } from '../../types/Attachment'; import { isDownloaded } from '../../types/Attachment';
import type { ButtonVariant } from '../../components/Button'; import type { ButtonVariant } from '../../components/Button';
import type { MessageRequestState } from '../../components/conversation/MessageRequestActionsConfirmation'; import type { MessageRequestState } from '../../components/conversation/MessageRequestActionsConfirmation';
import type { MessageForwardDraft } from '../../types/ForwardDraft';
import { hydrateRanges } from '../../types/BodyRange';
import {
getConversationSelector,
type GetConversationByIdType,
} from '../selectors/conversations';
import { missingCaseError } from '../../util/missingCaseError';
import { ForwardMessagesModalType } from '../../components/ForwardMessagesModal';
// State // State
@ -53,7 +61,8 @@ export type DeleteMessagesPropsType = ReadonlyDeep<{
}>; }>;
export type ForwardMessagePropsType = ReadonlyDeep<MessagePropsType>; export type ForwardMessagePropsType = ReadonlyDeep<MessagePropsType>;
export type ForwardMessagesPropsType = ReadonlyDeep<{ export type ForwardMessagesPropsType = ReadonlyDeep<{
messages: Array<ForwardMessagePropsType>; type: ForwardMessagesModalType;
messageDrafts: Array<MessageForwardDraft>;
onForward?: () => void; onForward?: () => void;
}>; }>;
export type MessageRequestActionsConfirmationPropsType = ReadonlyDeep<{ export type MessageRequestActionsConfirmationPropsType = ReadonlyDeep<{
@ -522,8 +531,34 @@ function toggleDeleteMessagesModal(
}; };
} }
function toMessageForwardDraft(
props: ForwardMessagePropsType,
getConversation: GetConversationByIdType
): MessageForwardDraft {
return {
attachments: props.attachments ?? [],
bodyRanges: hydrateRanges(props.bodyRanges, getConversation),
hasContact: Boolean(props.contact),
isSticker: Boolean(props.isSticker),
messageBody: props.text,
originalMessageId: props.id,
previews: props.previews ?? [],
};
}
export type ForwardMessagesPayload = ReadonlyDeep<
| {
type: ForwardMessagesModalType.Forward;
messageIds: ReadonlyArray<string>;
}
| {
type: ForwardMessagesModalType.ShareCallLink;
draft: MessageForwardDraft;
}
>;
function toggleForwardMessagesModal( function toggleForwardMessagesModal(
messageIds?: ReadonlyArray<string>, payload: ForwardMessagesPayload | null,
onForward?: () => void onForward?: () => void
): ThunkAction< ): ThunkAction<
void, void,
@ -532,7 +567,7 @@ function toggleForwardMessagesModal(
ToggleForwardMessagesModalActionType ToggleForwardMessagesModalActionType
> { > {
return async (dispatch, getState) => { return async (dispatch, getState) => {
if (!messageIds) { if (payload == null) {
dispatch({ dispatch({
type: TOGGLE_FORWARD_MESSAGES_MODAL, type: TOGGLE_FORWARD_MESSAGES_MODAL,
payload: undefined, payload: undefined,
@ -540,8 +575,11 @@ function toggleForwardMessagesModal(
return; return;
} }
const messagesProps = await Promise.all( let messageDrafts: ReadonlyArray<MessageForwardDraft>;
messageIds.map(async messageId => {
if (payload.type === ForwardMessagesModalType.Forward) {
messageDrafts = await Promise.all(
payload.messageIds.map(async messageId => {
const messageAttributes = await window.MessageCache.resolveAttributes( const messageAttributes = await window.MessageCache.resolveAttributes(
'toggleForwardMessagesModal', 'toggleForwardMessagesModal',
messageId messageId
@ -555,16 +593,28 @@ function toggleForwardMessagesModal(
); );
} }
const messagePropsSelector = getMessagePropsSelector(getState()); const state = getState();
const messageProps = messagePropsSelector(messageAttributes); const messagePropsSelector = getMessagePropsSelector(state);
const conversationSelector = getConversationSelector(state);
return messageProps; const messageProps = messagePropsSelector(messageAttributes);
const messageDraft = toMessageForwardDraft(
messageProps,
conversationSelector
);
return messageDraft;
}) })
); );
} else if (payload.type === ForwardMessagesModalType.ShareCallLink) {
messageDrafts = [payload.draft];
} else {
throw missingCaseError(payload);
}
dispatch({ dispatch({
type: TOGGLE_FORWARD_MESSAGES_MODAL, type: TOGGLE_FORWARD_MESSAGES_MODAL,
payload: { messages: messagesProps, onForward }, payload: { type: payload.type, messageDrafts, onForward },
}); });
}; };
} }
@ -802,15 +852,15 @@ function closeEditHistoryModal(): CloseEditHistoryModalActionType {
} }
function copyOverMessageAttributesIntoForwardMessages( function copyOverMessageAttributesIntoForwardMessages(
messagesProps: ReadonlyArray<ForwardMessagePropsType>, messageDrafts: ReadonlyArray<MessageForwardDraft>,
attributes: ReadonlyDeep<MessageAttributesType> attributes: ReadonlyDeep<MessageAttributesType>
): ReadonlyArray<ForwardMessagePropsType> { ): ReadonlyArray<MessageForwardDraft> {
return messagesProps.map(messageProps => { return messageDrafts.map(messageDraft => {
if (messageProps.id !== attributes.id) { if (messageDraft.originalMessageId !== attributes.id) {
return messageProps; return messageDraft;
} }
return { return {
...messageProps, ...messageDraft,
attachments: attributes.attachments, attachments: attributes.attachments,
}; };
}); });
@ -1078,8 +1128,8 @@ export function reducer(
if (state.forwardMessagesProps != null) { if (state.forwardMessagesProps != null) {
if (action.type === MESSAGE_CHANGED) { if (action.type === MESSAGE_CHANGED) {
if ( if (
!state.forwardMessagesProps.messages.some(message => { !state.forwardMessagesProps.messageDrafts.some(message => {
return message.id === action.payload.id; return message.originalMessageId === action.payload.id;
}) })
) { ) {
return state; return state;
@ -1089,8 +1139,8 @@ export function reducer(
...state, ...state,
forwardMessagesProps: { forwardMessagesProps: {
...state.forwardMessagesProps, ...state.forwardMessagesProps,
messages: copyOverMessageAttributesIntoForwardMessages( messageDrafts: copyOverMessageAttributesIntoForwardMessages(
state.forwardMessagesProps.messages, state.forwardMessagesProps.messageDrafts,
action.payload.data action.payload.data
), ),
}, },

View file

@ -0,0 +1,82 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { memo, useCallback } from 'react';
import { useSelector } from 'react-redux';
import type { CallHistoryGroup } from '../../types/CallDisposition';
import { getIntl } from '../selectors/user';
import { CallLinkDetails } from '../../components/CallLinkDetails';
import { getCallLinkSelector } from '../selectors/calling';
import { useGlobalModalActions } from '../ducks/globalModals';
import { useCallingActions } from '../ducks/calling';
import * as log from '../../logging/log';
import { strictAssert } from '../../util/assert';
import { linkCallRoute } from '../../util/signalRoutes';
import { ForwardMessagesModalType } from '../../components/ForwardMessagesModal';
export type SmartCallLinkDetailsProps = Readonly<{
roomId: string;
callHistoryGroup: CallHistoryGroup;
}>;
export const SmartCallLinkDetails = memo(function SmartCallLinkDetails({
roomId,
callHistoryGroup,
}: SmartCallLinkDetailsProps) {
const i18n = useSelector(getIntl);
const callLinkSelector = useSelector(getCallLinkSelector);
const { startCallLinkLobby } = useCallingActions();
const { toggleForwardMessagesModal } = useGlobalModalActions();
const callLink = callLinkSelector(roomId);
const handleShareCallLinkViaSignal = useCallback(() => {
strictAssert(callLink != null, 'callLink not found');
const url = linkCallRoute
.toWebUrl({
key: callLink.rootKey,
})
.toString();
toggleForwardMessagesModal({
type: ForwardMessagesModalType.ShareCallLink,
draft: {
originalMessageId: null,
hasContact: false,
isSticker: false,
previews: [
{
title: callLink.name,
url,
isCallLink: true,
},
],
messageBody: i18n(
'icu:ShareCallLinkViaSignal__DraftMessageText',
{
url,
},
{ textIsBidiFreeSkipNormalization: true }
),
},
});
}, [callLink, i18n, toggleForwardMessagesModal]);
const handleStartCallLinkLobby = useCallback(() => {
strictAssert(callLink != null, 'callLink not found');
startCallLinkLobby({ rootKey: callLink.rootKey });
}, [callLink, startCallLinkLobby]);
if (callLink == null) {
log.error(`SmartCallLinkDetails: callLink not found for room ${roomId}`);
return null;
}
return (
<CallLinkDetails
callHistoryGroup={callHistoryGroup}
callLink={callLink}
i18n={i18n}
onStartCallLinkLobby={handleStartCallLinkLobby}
onShareCallLinkViaSignal={handleShareCallLinkViaSignal}
/>
);
});

View file

@ -42,7 +42,6 @@ import { missingCaseError } from '../../util/missingCaseError';
import { useAudioPlayerActions } from '../ducks/audioPlayer'; import { useAudioPlayerActions } from '../ducks/audioPlayer';
import { getActiveCall, useCallingActions } from '../ducks/calling'; import { getActiveCall, useCallingActions } from '../ducks/calling';
import type { ConversationType } from '../ducks/conversations'; import type { ConversationType } from '../ducks/conversations';
import { useToastActions } from '../ducks/toast';
import type { StateType } from '../reducer'; import type { StateType } from '../reducer';
import { getHasInitialLoadCompleted } from '../selectors/app'; import { getHasInitialLoadCompleted } from '../selectors/app';
import { import {
@ -453,7 +452,6 @@ export const SmartCallManager = memo(function SmartCallManager() {
toggleScreenRecordingPermissionsDialog, toggleScreenRecordingPermissionsDialog,
toggleSettings, toggleSettings,
} = useCallingActions(); } = useCallingActions();
const { showToast } = useToastActions();
const { pauseVoiceNotePlayer } = useAudioPlayerActions(); const { pauseVoiceNotePlayer } = useAudioPlayerActions();
return ( return (
@ -497,7 +495,6 @@ export const SmartCallManager = memo(function SmartCallManager() {
setOutgoingRing={setOutgoingRing} setOutgoingRing={setOutgoingRing}
setPresenting={setPresenting} setPresenting={setPresenting}
setRendererCanvas={setRendererCanvas} setRendererCanvas={setRendererCanvas}
showToast={showToast}
startCall={startCall} startCall={startCall}
stopRingtone={stopRingtone} stopRingtone={stopRingtone}
switchFromPresentationView={switchFromPresentationView} switchFromPresentationView={switchFromPresentationView}

View file

@ -37,6 +37,7 @@ import { getCallHistoryEdition } from '../selectors/callHistory';
import { getHasPendingUpdate } from '../selectors/updates'; import { getHasPendingUpdate } from '../selectors/updates';
import { getHasAnyFailedStorySends } from '../selectors/stories'; import { getHasAnyFailedStorySends } from '../selectors/stories';
import { getOtherTabsUnreadStats } from '../selectors/nav'; import { getOtherTabsUnreadStats } from '../selectors/nav';
import { SmartCallLinkDetails } from './CallLinkDetails';
import type { CallLinkType } from '../../types/CallLink'; import type { CallLinkType } from '../../types/CallLink';
import { filterCallLinks } from '../../util/filterCallLinks'; import { filterCallLinks } from '../../util/filterCallLinks';
@ -101,6 +102,15 @@ function getCallHistoryFilter({
}; };
} }
function renderCallLinkDetails(
roomId: string,
callHistoryGroup: CallHistoryGroup
): JSX.Element {
return (
<SmartCallLinkDetails roomId={roomId} callHistoryGroup={callHistoryGroup} />
);
}
function renderConversationDetails( function renderConversationDetails(
conversationId: string, conversationId: string,
callHistoryGroup: CallHistoryGroup | null callHistoryGroup: CallHistoryGroup | null
@ -225,6 +235,7 @@ export const SmartCallsTab = memo(function SmartCallsTab() {
onOutgoingVideoCallInConversation={onOutgoingVideoCallInConversation} onOutgoingVideoCallInConversation={onOutgoingVideoCallInConversation}
peekNotConnectedGroupCall={peekNotConnectedGroupCall} peekNotConnectedGroupCall={peekNotConnectedGroupCall}
preferredLeftPaneWidth={preferredLeftPaneWidth} preferredLeftPaneWidth={preferredLeftPaneWidth}
renderCallLinkDetails={renderCallLinkDetails}
renderConversationDetails={renderConversationDetails} renderConversationDetails={renderConversationDetails}
renderToastManager={renderToastManager} renderToastManager={renderToastManager}
regionCode={regionCode} regionCode={regionCode}

View file

@ -3,19 +3,12 @@
import React, { useCallback, useState } from 'react'; import React, { useCallback, useState } from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import type { import type { ForwardMessagesPropsType } from '../ducks/globalModals';
ForwardMessagePropsType,
ForwardMessagesPropsType,
} from '../ducks/globalModals';
import * as log from '../../logging/log'; import * as log from '../../logging/log';
import { ForwardMessagesModal } from '../../components/ForwardMessagesModal'; import { ForwardMessagesModal } from '../../components/ForwardMessagesModal';
import { LinkPreviewSourceType } from '../../types/LinkPreview'; import { LinkPreviewSourceType } from '../../types/LinkPreview';
import * as Errors from '../../types/errors'; import * as Errors from '../../types/errors';
import type { GetConversationByIdType } from '../selectors/conversations'; import { getAllComposableConversations } from '../selectors/conversations';
import {
getAllComposableConversations,
getConversationSelector,
} from '../selectors/conversations';
import { getIntl, getTheme, getRegionCode } from '../selectors/user'; import { getIntl, getTheme, getRegionCode } from '../selectors/user';
import { getLinkPreview } from '../selectors/linkPreviews'; import { getLinkPreview } from '../selectors/linkPreviews';
import { getPreferredBadgeSelector } from '../selectors/badges'; import { getPreferredBadgeSelector } from '../selectors/badges';
@ -28,7 +21,6 @@ import { useGlobalModalActions } from '../ducks/globalModals';
import { useLinkPreviewActions } from '../ducks/linkPreviews'; import { useLinkPreviewActions } from '../ducks/linkPreviews';
import { SmartCompositionTextArea } from './CompositionTextArea'; import { SmartCompositionTextArea } from './CompositionTextArea';
import { useToastActions } from '../ducks/toast'; import { useToastActions } from '../ducks/toast';
import { hydrateRanges } from '../../types/BodyRange';
import { isDownloaded } from '../../types/Attachment'; import { isDownloaded } from '../../types/Attachment';
import { __DEPRECATED$getMessageById } from '../../messages/getMessageById'; import { __DEPRECATED$getMessageById } from '../../messages/getMessageById';
import { strictAssert } from '../../util/assert'; import { strictAssert } from '../../util/assert';
@ -38,21 +30,6 @@ import type {
} from '../../types/ForwardDraft'; } from '../../types/ForwardDraft';
import { getForwardMessagesProps } from '../selectors/globalModals'; import { getForwardMessagesProps } from '../selectors/globalModals';
function toMessageForwardDraft(
props: ForwardMessagePropsType,
getConversation: GetConversationByIdType
): MessageForwardDraft {
return {
attachments: props.attachments ?? [],
bodyRanges: hydrateRanges(props.bodyRanges, getConversation),
hasContact: Boolean(props.contact),
isSticker: Boolean(props.isSticker),
messageBody: props.text,
originalMessageId: props.id,
previews: props.previews ?? [],
};
}
export function SmartForwardMessagesModal(): JSX.Element | null { export function SmartForwardMessagesModal(): JSX.Element | null {
const forwardMessagesProps = useSelector(getForwardMessagesProps); const forwardMessagesProps = useSelector(getForwardMessagesProps);
@ -61,8 +38,8 @@ export function SmartForwardMessagesModal(): JSX.Element | null {
} }
if ( if (
!forwardMessagesProps.messages.every(message => { !forwardMessagesProps.messageDrafts.every(messageDraft => {
return message.attachments?.every(isDownloaded) ?? true; return messageDraft.attachments?.every(isDownloaded) ?? true;
}) })
) { ) {
return null; return null;
@ -82,7 +59,6 @@ function SmartForwardMessagesModalInner({
}): JSX.Element | null { }): JSX.Element | null {
const candidateConversations = useSelector(getAllComposableConversations); const candidateConversations = useSelector(getAllComposableConversations);
const getPreferredBadge = useSelector(getPreferredBadgeSelector); const getPreferredBadge = useSelector(getPreferredBadgeSelector);
const getConversation = useSelector(getConversationSelector);
const i18n = useSelector(getIntl); const i18n = useSelector(getIntl);
const linkPreviewForSource = useSelector(getLinkPreview); const linkPreviewForSource = useSelector(getLinkPreview);
const regionCode = useSelector(getRegionCode); const regionCode = useSelector(getRegionCode);
@ -91,12 +67,11 @@ function SmartForwardMessagesModalInner({
const { removeLinkPreview } = useLinkPreviewActions(); const { removeLinkPreview } = useLinkPreviewActions();
const { toggleForwardMessagesModal } = useGlobalModalActions(); const { toggleForwardMessagesModal } = useGlobalModalActions();
const { showToast } = useToastActions(); const { showToast } = useToastActions();
const { type } = forwardMessagesProps;
const [drafts, setDrafts] = useState<ReadonlyArray<MessageForwardDraft>>( const [drafts, setDrafts] = useState<ReadonlyArray<MessageForwardDraft>>(
() => { () => {
return forwardMessagesProps.messages.map((props): MessageForwardDraft => { return forwardMessagesProps.messageDrafts;
return toMessageForwardDraft(props, getConversation);
});
} }
); );
@ -125,7 +100,7 @@ function SmartForwardMessagesModalInner({
const closeModal = useCallback(() => { const closeModal = useCallback(() => {
resetLinkPreview(); resetLinkPreview();
toggleForwardMessagesModal(); toggleForwardMessagesModal(null);
}, [toggleForwardMessagesModal]); }, [toggleForwardMessagesModal]);
const doForwardMessages = useCallback( const doForwardMessages = useCallback(
@ -136,6 +111,9 @@ function SmartForwardMessagesModalInner({
try { try {
const messages = await Promise.all( const messages = await Promise.all(
finalDrafts.map(async (draft): Promise<ForwardMessageData> => { finalDrafts.map(async (draft): Promise<ForwardMessageData> => {
if (draft.originalMessageId == null) {
return { draft, originalMessage: null };
}
const message = await __DEPRECATED$getMessageById( const message = await __DEPRECATED$getMessageById(
draft.originalMessageId draft.originalMessageId
); );
@ -180,6 +158,7 @@ function SmartForwardMessagesModalInner({
regionCode={regionCode} regionCode={regionCode}
RenderCompositionTextArea={SmartCompositionTextArea} RenderCompositionTextArea={SmartCompositionTextArea}
removeLinkPreview={removeLinkPreview} removeLinkPreview={removeLinkPreview}
type={type}
showToast={showToast} showToast={showToast}
theme={theme} theme={theme}
/> />

View file

@ -34,6 +34,7 @@ import { getHasPendingUpdate } from '../selectors/updates';
import { getOtherTabsUnreadStats } from '../selectors/nav'; import { getOtherTabsUnreadStats } from '../selectors/nav';
import { getIsStoriesSettingsVisible } from '../selectors/globalModals'; import { getIsStoriesSettingsVisible } from '../selectors/globalModals';
import type { StoryViewType } from '../../types/Stories'; import type { StoryViewType } from '../../types/Stories';
import { ForwardMessagesModalType } from '../../components/ForwardMessagesModal';
function renderStoryCreator(): JSX.Element { function renderStoryCreator(): JSX.Element {
return <SmartStoryCreator />; return <SmartStoryCreator />;
@ -95,7 +96,10 @@ export const SmartStoriesTab = memo(function SmartStoriesTab() {
const handleForwardStory = useCallback( const handleForwardStory = useCallback(
(messageId: string) => { (messageId: string) => {
toggleForwardMessagesModal([messageId]); toggleForwardMessagesModal({
type: ForwardMessagesModalType.Forward,
messageIds: [messageId],
});
}, },
[toggleForwardMessagesModal] [toggleForwardMessagesModal]
); );

View file

@ -62,7 +62,12 @@ function toAdhocGroup(call: CallHistoryDetails): CallHistoryGroup {
direction: call.direction, direction: call.direction,
timestamp: call.timestamp, timestamp: call.timestamp,
status: call.status, status: call.status,
children: [], children: [
{
callId: call.callId,
timestamp: call.timestamp,
},
],
}; };
} }

View file

@ -17,12 +17,13 @@ export type MessageForwardDraft = Readonly<{
hasContact: boolean; hasContact: boolean;
isSticker: boolean; isSticker: boolean;
messageBody?: string; messageBody?: string;
originalMessageId: string; originalMessageId: string | null; // null for new messages
previews: ReadonlyArray<LinkPreviewType>; previews: ReadonlyArray<LinkPreviewType>;
}>; }>;
export type ForwardMessageData = Readonly<{ export type ForwardMessageData = Readonly<{
originalMessage: MessageAttributesType; // only null for new messages
originalMessage: MessageAttributesType | null;
draft: MessageForwardDraft; draft: MessageForwardDraft;
}>; }>;
@ -71,11 +72,14 @@ export function sortByMessageOrder<T>(
items: ReadonlyArray<T>, items: ReadonlyArray<T>,
getMesssage: ( getMesssage: (
item: T item: T
) => Pick<MessageAttributesType, 'sent_at' | 'received_at'> ) => Pick<MessageAttributesType, 'sent_at' | 'received_at'> | null
): Array<T> { ): Array<T> {
return orderBy( return orderBy(
items, items,
[item => getMesssage(item).received_at, item => getMesssage(item).sent_at], [
item => getMesssage(item)?.received_at,
item => getMesssage(item)?.sent_at,
],
['ASC', 'ASC'] ['ASC', 'ASC']
); );
} }

View file

@ -23,12 +23,19 @@ export type RenderTextCallbackType = (options: {
export { ICUJSXMessageParamsByKeyType, ICUStringMessageParamsByKeyType }; export { ICUJSXMessageParamsByKeyType, ICUStringMessageParamsByKeyType };
export type LocalizerOptions = {
textIsBidiFreeSkipNormalization?: boolean;
};
export type LocalizerType = { export type LocalizerType = {
<Key extends keyof ICUStringMessageParamsByKeyType>( <Key extends keyof ICUStringMessageParamsByKeyType>(
key: Key, key: Key,
...values: ICUStringMessageParamsByKeyType[Key] extends undefined ...values: ICUStringMessageParamsByKeyType[Key] extends undefined
? [undefined?] ? [params?: undefined, options?: LocalizerOptions]
: [ICUStringMessageParamsByKeyType[Key]] : [
params: ICUStringMessageParamsByKeyType[Key],
options?: LocalizerOptions
]
): string; ): string;
getIntl(): IntlShape; getIntl(): IntlShape;
getLocale(): string; getLocale(): string;

View file

@ -7,3 +7,8 @@ export async function copyGroupLink(groupLink: string): Promise<void> {
await window.navigator.clipboard.writeText(groupLink); await window.navigator.clipboard.writeText(groupLink);
window.reduxActions.toast.showToast({ toastType: ToastType.GroupLinkCopied }); window.reduxActions.toast.showToast({ toastType: ToastType.GroupLinkCopied });
} }
export async function copyCallLink(callLink: string): Promise<void> {
await window.navigator.clipboard.writeText(callLink);
window.reduxActions.toast.showToast({ toastType: ToastType.CopiedCallLink });
}

View file

@ -78,10 +78,13 @@ export async function maybeForwardMessages(
const preparedMessages = await Promise.all( const preparedMessages = await Promise.all(
messages.map(async message => { messages.map(async message => {
const { draft, originalMessage } = message; const { draft, originalMessage } = message;
const { sticker, contact } = originalMessage; const { sticker, contact } = originalMessage ?? {};
const { attachments, bodyRanges, messageBody, previews } = draft; const { attachments, bodyRanges, messageBody, previews } = draft;
const idForLogging = getMessageIdForLogging(originalMessage); const idForLogging =
originalMessage != null
? getMessageIdForLogging(originalMessage)
: '(new message)';
log.info(`maybeForwardMessage: Forwarding ${idForLogging}`); log.info(`maybeForwardMessage: Forwarding ${idForLogging}`);
const attachmentLookup = new Set(); const attachmentLookup = new Set();
@ -180,7 +183,9 @@ export async function maybeForwardMessages(
log.error( log.error(
'maybeForwardMessage: message send error', 'maybeForwardMessage: message send error',
getConversationIdForLogging(conversation.attributes), getConversationIdForLogging(conversation.attributes),
getMessageIdForLogging(originalMessage), originalMessage != null
? getMessageIdForLogging(originalMessage)
: '(new message)',
toLogFormat(error) toLogFormat(error)
); );
}) })

View file

@ -7,6 +7,7 @@ import type { LocaleMessageType, LocaleMessagesType } from '../types/I18N';
import type { import type {
LocalizerType, LocalizerType,
ICUStringMessageParamsByKeyType, ICUStringMessageParamsByKeyType,
LocalizerOptions,
} from '../types/Util'; } from '../types/Util';
import { strictAssert } from './assert'; import { strictAssert } from './assert';
import * as log from '../logging/log'; import * as log from '../logging/log';
@ -117,11 +118,14 @@ export function setupI18n(
Key extends keyof ICUStringMessageParamsByKeyType Key extends keyof ICUStringMessageParamsByKeyType
>( >(
key: Key, key: Key,
substitutions: ICUStringMessageParamsByKeyType[Key] substitutions: ICUStringMessageParamsByKeyType[Key],
options?: LocalizerOptions
) => { ) => {
const result = intl.formatMessage( const result = intl.formatMessage(
{ id: key }, { id: key },
normalizeSubstitutions(substitutions) options?.textIsBidiFreeSkipNormalization
? substitutions
: normalizeSubstitutions(substitutions)
); );
strictAssert(result !== key, `i18n: missing translation for "${key}"`); strictAssert(result !== key, `i18n: missing translation for "${key}"`);