diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 25c54f45919c..7898a9135758 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -5134,6 +5134,10 @@ "messageformat": "Forward To", "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": { "messageformat": "Continue", "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", "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": { "messageformat": "Learn more", "description": "Shown on the message request warning. Clicking this button will open a dialog with more information" @@ -7128,6 +7136,18 @@ "messageformat": "Call link", "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": { "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." diff --git a/stylesheets/components/CallLinkDetails.scss b/stylesheets/components/CallLinkDetails.scss new file mode 100644 index 000000000000..ccb00edea2f8 --- /dev/null +++ b/stylesheets/components/CallLinkDetails.scss @@ -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; +} diff --git a/stylesheets/components/ConversationDetails.scss b/stylesheets/components/ConversationDetails.scss index 2cf4e1d2caad..da7cccdaf406 100644 --- a/stylesheets/components/ConversationDetails.scss +++ b/stylesheets/components/ConversationDetails.scss @@ -189,6 +189,12 @@ } } + &--forward { + &::after { + @include details-icon('../images/icons/v3/forward/forward.svg'); + } + } + &--down { border-radius: 18px; @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 { flex: 1; } diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index f92aab6697f7..d64a537257c9 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -50,6 +50,7 @@ @import './components/CallingScreenSharingController.scss'; @import './components/CallingSelectPresentingSourcesModal.scss'; @import './components/CallingToast.scss'; +@import './components/CallLinkDetails.scss'; @import './components/CallingRaisedHandsList.scss'; @import './components/CallingRaisedHandsToasts.scss'; @import './components/CallingReactionsToasts.scss'; diff --git a/ts/components/Avatar.tsx b/ts/components/Avatar.tsx index 178a9682e13a..3e013308cef7 100644 --- a/ts/components/Avatar.tsx +++ b/ts/components/Avatar.tsx @@ -43,6 +43,7 @@ export enum AvatarSize { FORTY = 40, FORTY_EIGHT = 48, FIFTY_TWO = 52, + SIXTY_FOUR = 64, EIGHTY = 80, NINETY_SIX = 96, TWO_HUNDRED_SIXTEEN = 216, diff --git a/ts/components/CallLinkDetails.tsx b/ts/components/CallLinkDetails.tsx new file mode 100644 index 000000000000..8656cbc74dac --- /dev/null +++ b/ts/components/CallLinkDetails.tsx @@ -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 ( +
+
+ +
+

+ {callLink.name === '' + ? i18n('icu:calling__call-link-default-title') + : callLink.name} +

+

+ {toUrlWithoutProtocol(webUrl)} +

+
+
+ +
+
+ + + + } + label={i18n('icu:CallLinkDetails__CopyLink')} + onClick={() => { + drop(copyCallLink(webUrl.toString())); + }} + /> + + } + label={i18n('icu:CallLinkDetails__ShareLinkViaSignal')} + onClick={onShareCallLinkViaSignal} + /> + +
+ ); +} diff --git a/ts/components/CallManager.stories.tsx b/ts/components/CallManager.stories.tsx index bb2b25dbc101..6c8451e895e5 100644 --- a/ts/components/CallManager.stories.tsx +++ b/ts/components/CallManager.stories.tsx @@ -100,7 +100,6 @@ const createProps = (storyProps: Partial = {}): PropsType => ({ setPresenting: action('toggle-presenting'), setRendererCanvas: action('set-renderer-canvas'), setOutgoingRing: action('set-outgoing-ring'), - showToast: action('show-toast'), startCall: action('start-call'), stopRingtone: action('stop-ringtone'), switchToPresentationView: action('switch-to-presentation-view'), diff --git a/ts/components/CallManager.tsx b/ts/components/CallManager.tsx index 0b7bccbc6aa9..e404474960e0 100644 --- a/ts/components/CallManager.tsx +++ b/ts/components/CallManager.tsx @@ -53,10 +53,9 @@ import * as log from '../logging/log'; import { isGroupOrAdhocActiveCall } from '../util/isGroupOrAdhocCall'; import { CallingAdhocCallInfo } from './CallingAdhocCallInfo'; import { callLinkRootKeyToUrl } from '../util/callLinkRootKeyToUrl'; -import { ToastType } from '../types/Toast'; -import type { ShowToastAction } from '../state/ducks/toast'; import { isSharingPhoneNumberWithEverybody } from '../util/phoneNumberSharingMode'; import { usePrevious } from '../hooks/usePrevious'; +import { copyCallLink } from '../util/copyLinksWithToast'; const GROUP_CALL_RING_DURATION = 60 * 1000; @@ -127,7 +126,6 @@ export type PropsType = { setOutgoingRing: (_: boolean) => void; setPresenting: (_?: PresentedSource) => void; setRendererCanvas: (_: SetRendererCanvasType) => void; - showToast: ShowToastAction; stopRingtone: () => unknown; switchToPresentationView: () => void; switchFromPresentationView: () => void; @@ -186,7 +184,6 @@ function ActiveCallManager({ setPresenting, setRendererCanvas, setOutgoingRing, - showToast, startCall, switchToPresentationView, switchFromPresentationView, @@ -266,10 +263,9 @@ function ActiveCallManager({ const link = callLinkRootKeyToUrl(callLink.rootKey); if (link) { - await window.navigator.clipboard.writeText(link); - showToast({ toastType: ToastType.CopiedCallLink }); + await copyCallLink(link); } - }, [callLink, showToast]); + }, [callLink]); let isCallFull: boolean; let showCallLobby: boolean; @@ -528,7 +524,6 @@ export function CallManager({ setOutgoingRing, setPresenting, setRendererCanvas, - showToast, startCall, stopRingtone, switchFromPresentationView, @@ -615,7 +610,6 @@ export function CallManager({ setOutgoingRing={setOutgoingRing} setPresenting={setPresenting} setRendererCanvas={setRendererCanvas} - showToast={showToast} startCall={startCall} switchFromPresentationView={switchFromPresentationView} switchToPresentationView={switchToPresentationView} diff --git a/ts/components/CallsList.tsx b/ts/components/CallsList.tsx index 97fd29f1abb6..03725f4dc3f2 100644 --- a/ts/components/CallsList.tsx +++ b/ts/components/CallsList.tsx @@ -53,6 +53,7 @@ import { callLinkToConversation, getPlaceholderCallLinkConversation, } from '../util/callLinks'; +import type { CallsTabSelectedView } from './CallsTab'; import type { CallStateType } from '../state/selectors/calling'; import { isGroupOrAdhocCallMode, @@ -143,10 +144,7 @@ type CallsListProps = Readonly<{ selectedCallHistoryGroup: CallHistoryGroup | null; onOutgoingAudioCallInConversation: (conversationId: string) => void; onOutgoingVideoCallInConversation: (conversationId: string) => void; - onSelectCallHistoryGroup: ( - conversationId: string, - selectedCallHistoryGroup: CallHistoryGroup - ) => void; + onChangeCallsTabSelectedView: (selectedView: CallsTabSelectedView) => void; peekNotConnectedGroupCall: (options: PeekNotConnectedGroupCallType) => void; startCallLinkLobbyByRoomId: (roomId: string) => void; togglePip: () => void; @@ -184,7 +182,7 @@ export function CallsList({ selectedCallHistoryGroup, onOutgoingAudioCallInConversation, onOutgoingVideoCallInConversation, - onSelectCallHistoryGroup, + onChangeCallsTabSelectedView, peekNotConnectedGroupCall, startCallLinkLobbyByRoomId, togglePip, @@ -771,13 +769,22 @@ export function CallsList({ } onClick={() => { if (isAdhoc) { + onChangeCallsTabSelectedView({ + type: 'callLink', + roomId: item.peerId, + callHistoryGroup: item, + }); return; } if (conversation == null) { return; } - onSelectCallHistoryGroup(conversation.id, item); + onChangeCallsTabSelectedView({ + type: 'conversation', + conversationId: conversation.id, + callHistoryGroup: item, + }); }} /> @@ -791,7 +798,7 @@ export function CallsList({ getIsCallActive, getIsInCall, selectedCallHistoryGroup, - onSelectCallHistoryGroup, + onChangeCallsTabSelectedView, onOutgoingAudioCallInConversation, onOutgoingVideoCallInConversation, startCallLinkLobbyByRoomId, diff --git a/ts/components/CallsNewCall.tsx b/ts/components/CallsNewCall.tsx index d45d78baf6d8..d1c4a45588a9 100644 --- a/ts/components/CallsNewCall.tsx +++ b/ts/components/CallsNewCall.tsx @@ -19,13 +19,14 @@ import { Avatar, AvatarSize } from './Avatar'; import { I18n } from './I18n'; import { SizeObserver } from '../hooks/useSizeObserver'; import { CallType } from '../types/CallDisposition'; +import type { CallsTabSelectedView } from './CallsTab'; import { Tooltip, TooltipPlacement } from './Tooltip'; type CallsNewCallProps = Readonly<{ hasActiveCall: boolean; allConversations: ReadonlyArray; i18n: LocalizerType; - onSelectConversation: (conversationId: string) => void; + onChangeCallsTabSelectedView: (selectedView: CallsTabSelectedView) => void; onOutgoingAudioCallInConversation: (conversationId: string) => void; onOutgoingVideoCallInConversation: (conversationId: string) => void; regionCode: string | undefined; @@ -106,7 +107,7 @@ export function CallsNewCall({ hasActiveCall, allConversations, i18n, - onSelectConversation, + onChangeCallsTabSelectedView, onOutgoingAudioCallInConversation, onOutgoingVideoCallInConversation, regionCode, @@ -262,7 +263,11 @@ export function CallsNewCall({ } onClick={() => { - onSelectConversation(item.conversation.id); + onChangeCallsTabSelectedView({ + type: 'conversation', + conversationId: item.conversation.id, + callHistoryGroup: null, + }); }} /> @@ -272,7 +277,7 @@ export function CallsNewCall({ rows, i18n, hasActiveCall, - onSelectConversation, + onChangeCallsTabSelectedView, onOutgoingAudioCallInConversation, onOutgoingVideoCallInConversation, ] diff --git a/ts/components/CallsTab.tsx b/ts/components/CallsTab.tsx index 25228fb7f92a..479594f7e347 100644 --- a/ts/components/CallsTab.tsx +++ b/ts/components/CallsTab.tsx @@ -57,6 +57,10 @@ type CallsTabProps = Readonly<{ onOutgoingVideoCallInConversation: (conversationId: string) => void; peekNotConnectedGroupCall: (options: PeekNotConnectedGroupCallType) => void; preferredLeftPaneWidth: number; + renderCallLinkDetails: ( + roomId: string, + callHistoryGroup: CallHistoryGroup + ) => JSX.Element; renderConversationDetails: ( conversationId: string, callHistoryGroup: CallHistoryGroup | null @@ -70,6 +74,18 @@ type CallsTabProps = Readonly<{ togglePip: () => void; }>; +export type CallsTabSelectedView = + | { + type: 'conversation'; + conversationId: string; + callHistoryGroup: CallHistoryGroup | null; + } + | { + type: 'callLink'; + roomId: string; + callHistoryGroup: CallHistoryGroup; + }; + export function CallsTab({ activeCall, allConversations, @@ -93,6 +109,7 @@ export function CallsTab({ onOutgoingVideoCallInConversation, peekNotConnectedGroupCall, preferredLeftPaneWidth, + renderCallLinkDetails, renderConversationDetails, renderToastManager, regionCode, @@ -103,37 +120,31 @@ export function CallsTab({ const [sidebarView, setSidebarView] = useState( CallsTabSidebarView.CallsListView ); - const [selected, setSelected] = useState<{ - conversationId: string; - callHistoryGroup: CallHistoryGroup | null; - } | null>(null); + const [selectedView, setSelectedViewInner] = + useState(null); + const [selectedViewKey, setSelectedViewKey] = useState(() => 1); + const [ confirmClearCallHistoryDialogOpen, setConfirmClearCallHistoryDialogOpen, ] = useState(false); + const updateSelectedView = useCallback( + (nextSelected: CallsTabSelectedView | null) => { + setSelectedViewInner(nextSelected); + setSelectedViewKey(key => key + 1); + }, + [] + ); + const updateSidebarView = useCallback( (newSidebarView: CallsTabSidebarView) => { 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( sidebarView === CallsTabSidebarView.NewCallView ? () => { @@ -167,12 +178,12 @@ export function CallsTab({ ); useEffect(() => { - if (selected?.callHistoryGroup != null) { - selected.callHistoryGroup.children.forEach(child => { - onMarkCallHistoryRead(selected.conversationId, child.callId); + if (selectedView?.type === 'conversation') { + selectedView.callHistoryGroup?.children.forEach(child => { + onMarkCallHistoryRead(selectedView.conversationId, child.callId); }); } - }, [selected, onMarkCallHistoryRead]); + }, [selectedView, onMarkCallHistoryRead]); return ( <> @@ -255,8 +266,8 @@ export function CallsTab({ getConversation={getConversation} hangUpActiveCall={hangUpActiveCall} i18n={i18n} - selectedCallHistoryGroup={selected?.callHistoryGroup ?? null} - onSelectCallHistoryGroup={handleSelectCallHistoryGroup} + selectedCallHistoryGroup={selectedView?.callHistoryGroup ?? null} + onChangeCallsTabSelectedView={updateSelectedView} onOutgoingAudioCallInConversation={ handleOutgoingAudioCallInConversation } @@ -275,7 +286,7 @@ export function CallsTab({ allConversations={allConversations} i18n={i18n} regionCode={regionCode} - onSelectConversation={handleSelectConversation} + onChangeCallsTabSelectedView={updateSelectedView} onOutgoingAudioCallInConversation={ handleOutgoingAudioCallInConversation } @@ -285,7 +296,7 @@ export function CallsTab({ /> )} - {selected == null ? ( + {selectedView == null ? (

@@ -295,13 +306,19 @@ export function CallsTab({ ) : (

- {renderConversationDetails( - selected.conversationId, - selected.callHistoryGroup - )} + {selectedView.type === 'conversation' && + renderConversationDetails( + selectedView.conversationId, + selectedView.callHistoryGroup + )} + {selectedView.type === 'callLink' && + renderCallLinkDetails( + selectedView.roomId, + selectedView.callHistoryGroup + )}
)}
diff --git a/ts/components/CompositionArea.tsx b/ts/components/CompositionArea.tsx index cf55506aaa27..2edb66e1e13b 100644 --- a/ts/components/CompositionArea.tsx +++ b/ts/components/CompositionArea.tsx @@ -72,6 +72,8 @@ import type { SmartCompositionRecordingProps } from '../state/smart/CompositionR import SelectModeActions from './conversation/SelectModeActions'; import type { ShowToastAction } from '../state/ducks/toast'; import type { DraftEditMessageType } from '../model-types.d'; +import type { ForwardMessagesPayload } from '../state/ducks/globalModals'; +import { ForwardMessagesModalType } from './ForwardMessagesModal'; export type OwnProps = Readonly<{ acceptedMessageRequest: boolean | null; @@ -181,7 +183,7 @@ export type OwnProps = Readonly<{ selectedMessageIds: ReadonlyArray | undefined; toggleSelectMode: (on: boolean) => void; toggleForwardMessagesModal: ( - messageIds: ReadonlyArray, + payload: ForwardMessagesPayload, onForward: () => void ) => void; }>; @@ -725,9 +727,15 @@ export const CompositionArea = memo(function CompositionArea({ }} onForwardMessages={() => { if (selectedMessageIds.length > 0) { - toggleForwardMessagesModal(selectedMessageIds, () => { - toggleSelectMode(false); - }); + toggleForwardMessagesModal( + { + type: ForwardMessagesModalType.Forward, + messageIds: selectedMessageIds, + }, + () => { + toggleSelectMode(false); + } + ); } }} showToast={showToast} diff --git a/ts/components/ForwardMessagesModal.stories.tsx b/ts/components/ForwardMessagesModal.stories.tsx index 6b7a01bdb36e..2ae62a51b453 100644 --- a/ts/components/ForwardMessagesModal.stories.tsx +++ b/ts/components/ForwardMessagesModal.stories.tsx @@ -7,7 +7,10 @@ import type { Meta } from '@storybook/react'; import enMessages from '../../_locales/en/messages.json'; import type { AttachmentType } from '../types/Attachment'; import type { PropsType } from './ForwardMessagesModal'; -import { ForwardMessagesModal } from './ForwardMessagesModal'; +import { + ForwardMessagesModal, + ForwardMessagesModalType, +} from './ForwardMessagesModal'; import { IMAGE_JPEG, VIDEO_MP4, stringToMIMEType } from '../types/MIME'; import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation'; import { setupI18n } from '../util/setupI18n'; @@ -67,6 +70,7 @@ const useProps = (overrideProps: Partial = {}): PropsType => ({ /> ), showToast: action('showToast'), + type: ForwardMessagesModalType.Forward, theme: React.useContext(StorybookThemeContext), regionCode: 'US', }); diff --git a/ts/components/ForwardMessagesModal.tsx b/ts/components/ForwardMessagesModal.tsx index 459de6b5fee2..ec07df0dadb8 100644 --- a/ts/components/ForwardMessagesModal.tsx +++ b/ts/components/ForwardMessagesModal.tsx @@ -42,6 +42,12 @@ import { isDraftForwardable, type MessageForwardDraft, } from '../types/ForwardDraft'; +import { missingCaseError } from '../util/missingCaseError'; + +export enum ForwardMessagesModalType { + Forward, + ShareCallLink, +} export type DataPropsType = { candidateConversations: ReadonlyArray; @@ -63,6 +69,7 @@ export type DataPropsType = { ) => unknown; regionCode: string | undefined; RenderCompositionTextArea: ComponentType; + type: ForwardMessagesModalType; showToast: ShowToastAction; theme: ThemeType; }; @@ -76,6 +83,7 @@ export type PropsType = DataPropsType & ActionPropsType; const MAX_FORWARD = 5; export function ForwardMessagesModal({ + type, drafts, candidateConversations, doForwardMessages, @@ -292,6 +300,15 @@ export function ForwardMessagesModal({
); + 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 ( <> {cannotMessage && ( @@ -311,7 +328,7 @@ export function ForwardMessagesModal({ onClose={onClose} onBackButtonClick={isEditingMessage ? handleBackOrClose : undefined} moduleClassName="module-ForwardMessageModal" - title={i18n('icu:ForwardMessageModal__title')} + title={title} useFocusTrap={false} padded={false} modalFooter={footer} diff --git a/ts/components/Lightbox.tsx b/ts/components/Lightbox.tsx index 424c3db30076..733424175c2c 100644 --- a/ts/components/Lightbox.tsx +++ b/ts/components/Lightbox.tsx @@ -28,6 +28,8 @@ import { usePrevious } from '../hooks/usePrevious'; import { arrow } from '../util/keyboard'; import { drop } from '../util/drop'; import { isCmdOrCtrl } from '../hooks/useKeyboardShortcuts'; +import type { ForwardMessagesPayload } from '../state/ducks/globalModals'; +import { ForwardMessagesModalType } from './ForwardMessagesModal'; export type PropsType = { children?: ReactNode; @@ -39,7 +41,7 @@ export type PropsType = { playbackDisabled: boolean; saveAttachment: SaveAttachmentActionCreatorType; selectedIndex: number; - toggleForwardMessagesModal: (messageIds: ReadonlyArray) => unknown; + toggleForwardMessagesModal: (payload: ForwardMessagesPayload) => unknown; onMediaPlaybackStart: () => void; onNextAttachment: () => void; onPrevAttachment: () => void; @@ -195,7 +197,10 @@ export function Lightbox({ closeLightbox(); const mediaItem = media[selectedIndex]; - toggleForwardMessagesModal([mediaItem.message.id]); + toggleForwardMessagesModal({ + type: ForwardMessagesModalType.Forward, + messageIds: [mediaItem.message.id], + }); }; const onKeyDown = useCallback( diff --git a/ts/components/conversation/TimelineMessage.tsx b/ts/components/conversation/TimelineMessage.tsx index d1f2343a2b42..dc148d382b01 100644 --- a/ts/components/conversation/TimelineMessage.tsx +++ b/ts/components/conversation/TimelineMessage.tsx @@ -32,13 +32,17 @@ import { useToggleReactionPicker, } from '../../hooks/useKeyboardShortcuts'; 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 { type ContextMenuTriggerType, MessageContextMenu, useHandleMessageContextMenu, } from './MessageContextMenu'; +import { ForwardMessagesModalType } from '../ForwardMessagesModal'; export type PropsData = { canDownload: boolean; @@ -55,7 +59,7 @@ export type PropsData = { export type PropsActions = { pushPanelForConversation: PushPanelForConversationActionType; toggleDeleteMessagesModal: (props: DeleteMessagesPropsType) => void; - toggleForwardMessagesModal: (messageIds: Array) => void; + toggleForwardMessagesModal: (payload: ForwardMessagesPayload) => void; reactToMessage: ( id: string, { emoji, remove }: { emoji: string; remove: boolean } @@ -372,7 +376,13 @@ export function TimelineMessage(props: Props): JSX.Element { onCopy={canCopy ? () => copyMessageText(id) : undefined} onSelect={() => toggleSelectMessage(conversationId, id, false, true)} onForward={ - canForward ? () => toggleForwardMessagesModal([id]) : undefined + canForward + ? () => + toggleForwardMessagesModal({ + type: ForwardMessagesModalType.Forward, + messageIds: [id], + }) + : undefined } onDeleteMessage={() => { toggleDeleteMessagesModal({ diff --git a/ts/components/conversation/conversation-details/CallHistoryGroupPanelSection.tsx b/ts/components/conversation/conversation-details/CallHistoryGroupPanelSection.tsx new file mode 100644 index 000000000000..c24cfac6906c --- /dev/null +++ b/ts/components/conversation/conversation-details/CallHistoryGroupPanelSection.tsx @@ -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 ( + +
    + {callHistoryGroup.children.map(child => { + return ( +
  1. + + + {describeCallHistory( + i18n, + callHistoryGroup.type, + callHistoryGroup.direction, + callHistoryGroup.status + )} + + + {formatTime(i18n, child.timestamp, Date.now(), false)} + +
  2. + ); + })} +
+
+ ); +} diff --git a/ts/components/conversation/conversation-details/ConversationDetails.tsx b/ts/components/conversation/conversation-details/ConversationDetails.tsx index d41635c8463a..64d8bf50629d 100644 --- a/ts/components/conversation/conversation-details/ConversationDetails.tsx +++ b/ts/components/conversation/conversation-details/ConversationDetails.tsx @@ -4,7 +4,6 @@ import type { ReactNode } from 'react'; import React, { useEffect, useState, useCallback } from 'react'; -import classNames from 'classnames'; import { Button, ButtonIconType, ButtonVariant } from '../../Button'; import { Tooltip } from '../../Tooltip'; import type { @@ -53,43 +52,11 @@ import type { import { isConversationMuted } from '../../../util/isConversationMuted'; import { ConversationDetailsGroups } from './ConversationDetailsGroups'; import { PanelType } from '../../../types/Panels'; -import type { CallStatus } from '../../../types/CallDisposition'; -import { - CallType, - type CallHistoryGroup, - CallDirection, - DirectCallStatus, - GroupCallStatus, -} from '../../../types/CallDisposition'; -import { formatDate, formatTime } from '../../../util/timestamp'; +import { type CallHistoryGroup } from '../../../types/CallDisposition'; import { NavTab } from '../../../state/ducks/nav'; import { ContextMenu } from '../../ContextMenu'; import { canHaveNicknameAndNote } from '../../../util/nicknames'; - -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 }); -} +import { CallHistoryGroupPanelSection } from './CallHistoryGroupPanelSection'; enum ModalState { AddingGroupMembers, @@ -501,41 +468,10 @@ export function ConversationDetails({ {callHistoryGroup && ( - -
    - {callHistoryGroup.children.map(child => { - return ( -
  1. - - - {describeCallHistory( - i18n, - callHistoryGroup.type, - callHistoryGroup.direction, - callHistoryGroup.status - )} - - - {formatTime(i18n, child.timestamp, Date.now(), false)} - -
  2. - ); - })} -
-
+ )} diff --git a/ts/components/conversation/conversation-details/ConversationDetailsIcon.tsx b/ts/components/conversation/conversation-details/ConversationDetailsIcon.tsx index 49f5490a33b0..6854840020ed 100644 --- a/ts/components/conversation/conversation-details/ConversationDetailsIcon.tsx +++ b/ts/components/conversation/conversation-details/ConversationDetailsIcon.tsx @@ -13,6 +13,7 @@ export enum IconType { 'unblock' = 'unblock', 'color' = 'color', 'down' = 'down', + 'forward' = 'forward', 'invites' = 'invites', 'leave' = 'leave', 'link' = 'link', diff --git a/ts/components/conversation/conversation-details/GroupLinkManagement.tsx b/ts/components/conversation/conversation-details/GroupLinkManagement.tsx index 8641a23f964d..358e7400e8b9 100644 --- a/ts/components/conversation/conversation-details/GroupLinkManagement.tsx +++ b/ts/components/conversation/conversation-details/GroupLinkManagement.tsx @@ -13,7 +13,7 @@ import { PanelSection } from './PanelSection'; import { Select } from '../../Select'; import { SignalService as Proto } from '../../../protobuf'; -import { copyGroupLink } from '../../../util/copyGroupLink'; +import { copyGroupLink } from '../../../util/copyLinksWithToast'; import { useDelayedRestoreFocus } from '../../../hooks/useRestoreFocus'; import { useUniqueId } from '../../../hooks/useUniqueId'; diff --git a/ts/services/addGlobalKeyboardShortcuts.ts b/ts/services/addGlobalKeyboardShortcuts.ts index 3d5cb79c2cb6..e151b0045c40 100644 --- a/ts/services/addGlobalKeyboardShortcuts.ts +++ b/ts/services/addGlobalKeyboardShortcuts.ts @@ -9,6 +9,7 @@ import { drop } from '../util/drop'; import { matchOrQueryFocusable } from '../util/focusableSelectors'; import { getQuotedMessageSelector } from '../state/selectors/composer'; import { removeLinkPreview } from './LinkPreview'; +import { ForwardMessagesModalType } from '../components/ForwardMessagesModal'; export function addGlobalKeyboardShortcuts(): void { const isMacOS = window.platform === 'darwin'; @@ -492,7 +493,7 @@ export function addGlobalKeyboardShortcuts(): void { event.stopPropagation(); window.reduxActions.globalModals.toggleForwardMessagesModal( - messageIds, + { type: ForwardMessagesModalType.Forward, messageIds }, () => { if (selectedMessageIds != null) { window.reduxActions.conversations.toggleSelectMode(false); diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index 8d08c488e046..7908282eaf0e 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -3772,8 +3772,6 @@ function getCallHistoryGroupDataSync( -- Desktop Constraints: AND callsHistory.status IS c.status AND ${filterClause} - -- Skip grouping logic for adhoc calls - AND callsHistory.mode IS NOT ${CALL_MODE_ADHOC} ORDER BY timestamp DESC ) as possibleChildren, @@ -3893,17 +3891,21 @@ async function getCallHistoryGroups( }) .reverse() .map(group => { - const { possibleChildren, inPeriod, ...rest } = group; + const { possibleChildren, inPeriod, type, ...rest } = group; const children = []; for (const child of possibleChildren) { if (!taken.has(child.callId) && inPeriod.has(child.callId)) { children.push(child); 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(); } diff --git a/ts/sql/server/groupEndorsements.ts b/ts/sql/server/groupEndorsements.ts index 4c2342e57279..2916c61a0eec 100644 --- a/ts/sql/server/groupEndorsements.ts +++ b/ts/sql/server/groupEndorsements.ts @@ -79,7 +79,9 @@ export async function getGroupSendCombinedEndorsementExpiration( SELECT expiration FROM groupSendCombinedEndorsement WHERE groupId = ${groupId}; `; - const value = prepare(db, selectGroup).pluck().get(selectGroupParams); + const value = prepare>(db, selectGroup) + .pluck() + .get(selectGroupParams); if (value == null) { return null; } diff --git a/ts/state/ducks/globalModals.ts b/ts/state/ducks/globalModals.ts index ae3c0f834542..e4493cd9a323 100644 --- a/ts/state/ducks/globalModals.ts +++ b/ts/state/ducks/globalModals.ts @@ -37,6 +37,14 @@ import { import { isDownloaded } from '../../types/Attachment'; import type { ButtonVariant } from '../../components/Button'; 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 @@ -53,7 +61,8 @@ export type DeleteMessagesPropsType = ReadonlyDeep<{ }>; export type ForwardMessagePropsType = ReadonlyDeep; export type ForwardMessagesPropsType = ReadonlyDeep<{ - messages: Array; + type: ForwardMessagesModalType; + messageDrafts: Array; onForward?: () => void; }>; 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; + } + | { + type: ForwardMessagesModalType.ShareCallLink; + draft: MessageForwardDraft; + } +>; + function toggleForwardMessagesModal( - messageIds?: ReadonlyArray, + payload: ForwardMessagesPayload | null, onForward?: () => void ): ThunkAction< void, @@ -532,7 +567,7 @@ function toggleForwardMessagesModal( ToggleForwardMessagesModalActionType > { return async (dispatch, getState) => { - if (!messageIds) { + if (payload == null) { dispatch({ type: TOGGLE_FORWARD_MESSAGES_MODAL, payload: undefined, @@ -540,31 +575,46 @@ function toggleForwardMessagesModal( return; } - const messagesProps = await Promise.all( - messageIds.map(async messageId => { - const messageAttributes = await window.MessageCache.resolveAttributes( - 'toggleForwardMessagesModal', - messageId - ); + let messageDrafts: ReadonlyArray; - const { attachments = [] } = messageAttributes; - - if (!attachments.every(isDownloaded)) { - dispatch( - conversationsActions.kickOffAttachmentDownload({ messageId }) + if (payload.type === ForwardMessagesModalType.Forward) { + messageDrafts = await Promise.all( + payload.messageIds.map(async messageId => { + const messageAttributes = await window.MessageCache.resolveAttributes( + 'toggleForwardMessagesModal', + messageId ); - } - const messagePropsSelector = getMessagePropsSelector(getState()); - const messageProps = messagePropsSelector(messageAttributes); + const { attachments = [] } = messageAttributes; - return messageProps; - }) - ); + if (!attachments.every(isDownloaded)) { + dispatch( + conversationsActions.kickOffAttachmentDownload({ messageId }) + ); + } + + const state = getState(); + const messagePropsSelector = getMessagePropsSelector(state); + const conversationSelector = getConversationSelector(state); + + 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({ type: TOGGLE_FORWARD_MESSAGES_MODAL, - payload: { messages: messagesProps, onForward }, + payload: { type: payload.type, messageDrafts, onForward }, }); }; } @@ -802,15 +852,15 @@ function closeEditHistoryModal(): CloseEditHistoryModalActionType { } function copyOverMessageAttributesIntoForwardMessages( - messagesProps: ReadonlyArray, + messageDrafts: ReadonlyArray, attributes: ReadonlyDeep -): ReadonlyArray { - return messagesProps.map(messageProps => { - if (messageProps.id !== attributes.id) { - return messageProps; +): ReadonlyArray { + return messageDrafts.map(messageDraft => { + if (messageDraft.originalMessageId !== attributes.id) { + return messageDraft; } return { - ...messageProps, + ...messageDraft, attachments: attributes.attachments, }; }); @@ -1078,8 +1128,8 @@ export function reducer( if (state.forwardMessagesProps != null) { if (action.type === MESSAGE_CHANGED) { if ( - !state.forwardMessagesProps.messages.some(message => { - return message.id === action.payload.id; + !state.forwardMessagesProps.messageDrafts.some(message => { + return message.originalMessageId === action.payload.id; }) ) { return state; @@ -1089,8 +1139,8 @@ export function reducer( ...state, forwardMessagesProps: { ...state.forwardMessagesProps, - messages: copyOverMessageAttributesIntoForwardMessages( - state.forwardMessagesProps.messages, + messageDrafts: copyOverMessageAttributesIntoForwardMessages( + state.forwardMessagesProps.messageDrafts, action.payload.data ), }, diff --git a/ts/state/smart/CallLinkDetails.tsx b/ts/state/smart/CallLinkDetails.tsx new file mode 100644 index 000000000000..b1de325eb483 --- /dev/null +++ b/ts/state/smart/CallLinkDetails.tsx @@ -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 ( + + ); +}); diff --git a/ts/state/smart/CallManager.tsx b/ts/state/smart/CallManager.tsx index 45b593476cd4..27fe4e68150d 100644 --- a/ts/state/smart/CallManager.tsx +++ b/ts/state/smart/CallManager.tsx @@ -42,7 +42,6 @@ import { missingCaseError } from '../../util/missingCaseError'; import { useAudioPlayerActions } from '../ducks/audioPlayer'; import { getActiveCall, useCallingActions } from '../ducks/calling'; import type { ConversationType } from '../ducks/conversations'; -import { useToastActions } from '../ducks/toast'; import type { StateType } from '../reducer'; import { getHasInitialLoadCompleted } from '../selectors/app'; import { @@ -453,7 +452,6 @@ export const SmartCallManager = memo(function SmartCallManager() { toggleScreenRecordingPermissionsDialog, toggleSettings, } = useCallingActions(); - const { showToast } = useToastActions(); const { pauseVoiceNotePlayer } = useAudioPlayerActions(); return ( @@ -497,7 +495,6 @@ export const SmartCallManager = memo(function SmartCallManager() { setOutgoingRing={setOutgoingRing} setPresenting={setPresenting} setRendererCanvas={setRendererCanvas} - showToast={showToast} startCall={startCall} stopRingtone={stopRingtone} switchFromPresentationView={switchFromPresentationView} diff --git a/ts/state/smart/CallsTab.tsx b/ts/state/smart/CallsTab.tsx index ffbb8baa1c74..3a6f06eccc49 100644 --- a/ts/state/smart/CallsTab.tsx +++ b/ts/state/smart/CallsTab.tsx @@ -37,6 +37,7 @@ import { getCallHistoryEdition } from '../selectors/callHistory'; import { getHasPendingUpdate } from '../selectors/updates'; import { getHasAnyFailedStorySends } from '../selectors/stories'; import { getOtherTabsUnreadStats } from '../selectors/nav'; +import { SmartCallLinkDetails } from './CallLinkDetails'; import type { CallLinkType } from '../../types/CallLink'; import { filterCallLinks } from '../../util/filterCallLinks'; @@ -101,6 +102,15 @@ function getCallHistoryFilter({ }; } +function renderCallLinkDetails( + roomId: string, + callHistoryGroup: CallHistoryGroup +): JSX.Element { + return ( + + ); +} + function renderConversationDetails( conversationId: string, callHistoryGroup: CallHistoryGroup | null @@ -225,6 +235,7 @@ export const SmartCallsTab = memo(function SmartCallsTab() { onOutgoingVideoCallInConversation={onOutgoingVideoCallInConversation} peekNotConnectedGroupCall={peekNotConnectedGroupCall} preferredLeftPaneWidth={preferredLeftPaneWidth} + renderCallLinkDetails={renderCallLinkDetails} renderConversationDetails={renderConversationDetails} renderToastManager={renderToastManager} regionCode={regionCode} diff --git a/ts/state/smart/ForwardMessagesModal.tsx b/ts/state/smart/ForwardMessagesModal.tsx index c9383f1f74e1..d4f39ba77077 100644 --- a/ts/state/smart/ForwardMessagesModal.tsx +++ b/ts/state/smart/ForwardMessagesModal.tsx @@ -3,19 +3,12 @@ import React, { useCallback, useState } from 'react'; import { useSelector } from 'react-redux'; -import type { - ForwardMessagePropsType, - ForwardMessagesPropsType, -} from '../ducks/globalModals'; +import type { ForwardMessagesPropsType } from '../ducks/globalModals'; import * as log from '../../logging/log'; import { ForwardMessagesModal } from '../../components/ForwardMessagesModal'; import { LinkPreviewSourceType } from '../../types/LinkPreview'; import * as Errors from '../../types/errors'; -import type { GetConversationByIdType } from '../selectors/conversations'; -import { - getAllComposableConversations, - getConversationSelector, -} from '../selectors/conversations'; +import { getAllComposableConversations } from '../selectors/conversations'; import { getIntl, getTheme, getRegionCode } from '../selectors/user'; import { getLinkPreview } from '../selectors/linkPreviews'; import { getPreferredBadgeSelector } from '../selectors/badges'; @@ -28,7 +21,6 @@ import { useGlobalModalActions } from '../ducks/globalModals'; import { useLinkPreviewActions } from '../ducks/linkPreviews'; import { SmartCompositionTextArea } from './CompositionTextArea'; import { useToastActions } from '../ducks/toast'; -import { hydrateRanges } from '../../types/BodyRange'; import { isDownloaded } from '../../types/Attachment'; import { __DEPRECATED$getMessageById } from '../../messages/getMessageById'; import { strictAssert } from '../../util/assert'; @@ -38,21 +30,6 @@ import type { } from '../../types/ForwardDraft'; 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 { const forwardMessagesProps = useSelector(getForwardMessagesProps); @@ -61,8 +38,8 @@ export function SmartForwardMessagesModal(): JSX.Element | null { } if ( - !forwardMessagesProps.messages.every(message => { - return message.attachments?.every(isDownloaded) ?? true; + !forwardMessagesProps.messageDrafts.every(messageDraft => { + return messageDraft.attachments?.every(isDownloaded) ?? true; }) ) { return null; @@ -82,7 +59,6 @@ function SmartForwardMessagesModalInner({ }): JSX.Element | null { const candidateConversations = useSelector(getAllComposableConversations); const getPreferredBadge = useSelector(getPreferredBadgeSelector); - const getConversation = useSelector(getConversationSelector); const i18n = useSelector(getIntl); const linkPreviewForSource = useSelector(getLinkPreview); const regionCode = useSelector(getRegionCode); @@ -91,12 +67,11 @@ function SmartForwardMessagesModalInner({ const { removeLinkPreview } = useLinkPreviewActions(); const { toggleForwardMessagesModal } = useGlobalModalActions(); const { showToast } = useToastActions(); + const { type } = forwardMessagesProps; const [drafts, setDrafts] = useState>( () => { - return forwardMessagesProps.messages.map((props): MessageForwardDraft => { - return toMessageForwardDraft(props, getConversation); - }); + return forwardMessagesProps.messageDrafts; } ); @@ -125,7 +100,7 @@ function SmartForwardMessagesModalInner({ const closeModal = useCallback(() => { resetLinkPreview(); - toggleForwardMessagesModal(); + toggleForwardMessagesModal(null); }, [toggleForwardMessagesModal]); const doForwardMessages = useCallback( @@ -136,6 +111,9 @@ function SmartForwardMessagesModalInner({ try { const messages = await Promise.all( finalDrafts.map(async (draft): Promise => { + if (draft.originalMessageId == null) { + return { draft, originalMessage: null }; + } const message = await __DEPRECATED$getMessageById( draft.originalMessageId ); @@ -180,6 +158,7 @@ function SmartForwardMessagesModalInner({ regionCode={regionCode} RenderCompositionTextArea={SmartCompositionTextArea} removeLinkPreview={removeLinkPreview} + type={type} showToast={showToast} theme={theme} /> diff --git a/ts/state/smart/StoriesTab.tsx b/ts/state/smart/StoriesTab.tsx index 418fc4a118c4..4af7fd7b0e05 100644 --- a/ts/state/smart/StoriesTab.tsx +++ b/ts/state/smart/StoriesTab.tsx @@ -34,6 +34,7 @@ import { getHasPendingUpdate } from '../selectors/updates'; import { getOtherTabsUnreadStats } from '../selectors/nav'; import { getIsStoriesSettingsVisible } from '../selectors/globalModals'; import type { StoryViewType } from '../../types/Stories'; +import { ForwardMessagesModalType } from '../../components/ForwardMessagesModal'; function renderStoryCreator(): JSX.Element { return ; @@ -95,7 +96,10 @@ export const SmartStoriesTab = memo(function SmartStoriesTab() { const handleForwardStory = useCallback( (messageId: string) => { - toggleForwardMessagesModal([messageId]); + toggleForwardMessagesModal({ + type: ForwardMessagesModalType.Forward, + messageIds: [messageId], + }); }, [toggleForwardMessagesModal] ); diff --git a/ts/test-electron/sql/getCallHistoryGroups_test.ts b/ts/test-electron/sql/getCallHistoryGroups_test.ts index 0277865fdb08..cadf52f8ffba 100644 --- a/ts/test-electron/sql/getCallHistoryGroups_test.ts +++ b/ts/test-electron/sql/getCallHistoryGroups_test.ts @@ -62,7 +62,12 @@ function toAdhocGroup(call: CallHistoryDetails): CallHistoryGroup { direction: call.direction, timestamp: call.timestamp, status: call.status, - children: [], + children: [ + { + callId: call.callId, + timestamp: call.timestamp, + }, + ], }; } diff --git a/ts/types/ForwardDraft.ts b/ts/types/ForwardDraft.ts index 91f98520beb2..06348df8986f 100644 --- a/ts/types/ForwardDraft.ts +++ b/ts/types/ForwardDraft.ts @@ -17,12 +17,13 @@ export type MessageForwardDraft = Readonly<{ hasContact: boolean; isSticker: boolean; messageBody?: string; - originalMessageId: string; + originalMessageId: string | null; // null for new messages previews: ReadonlyArray; }>; export type ForwardMessageData = Readonly<{ - originalMessage: MessageAttributesType; + // only null for new messages + originalMessage: MessageAttributesType | null; draft: MessageForwardDraft; }>; @@ -71,11 +72,14 @@ export function sortByMessageOrder( items: ReadonlyArray, getMesssage: ( item: T - ) => Pick + ) => Pick | null ): Array { return orderBy( items, - [item => getMesssage(item).received_at, item => getMesssage(item).sent_at], + [ + item => getMesssage(item)?.received_at, + item => getMesssage(item)?.sent_at, + ], ['ASC', 'ASC'] ); } diff --git a/ts/types/Util.ts b/ts/types/Util.ts index b8aba0932641..9b703455be27 100644 --- a/ts/types/Util.ts +++ b/ts/types/Util.ts @@ -23,12 +23,19 @@ export type RenderTextCallbackType = (options: { export { ICUJSXMessageParamsByKeyType, ICUStringMessageParamsByKeyType }; +export type LocalizerOptions = { + textIsBidiFreeSkipNormalization?: boolean; +}; + export type LocalizerType = { ( key: Key, ...values: ICUStringMessageParamsByKeyType[Key] extends undefined - ? [undefined?] - : [ICUStringMessageParamsByKeyType[Key]] + ? [params?: undefined, options?: LocalizerOptions] + : [ + params: ICUStringMessageParamsByKeyType[Key], + options?: LocalizerOptions + ] ): string; getIntl(): IntlShape; getLocale(): string; diff --git a/ts/util/copyGroupLink.ts b/ts/util/copyLinksWithToast.ts similarity index 61% rename from ts/util/copyGroupLink.ts rename to ts/util/copyLinksWithToast.ts index e990d70526c5..b5370a532910 100644 --- a/ts/util/copyGroupLink.ts +++ b/ts/util/copyLinksWithToast.ts @@ -7,3 +7,8 @@ export async function copyGroupLink(groupLink: string): Promise { await window.navigator.clipboard.writeText(groupLink); window.reduxActions.toast.showToast({ toastType: ToastType.GroupLinkCopied }); } + +export async function copyCallLink(callLink: string): Promise { + await window.navigator.clipboard.writeText(callLink); + window.reduxActions.toast.showToast({ toastType: ToastType.CopiedCallLink }); +} diff --git a/ts/util/maybeForwardMessages.ts b/ts/util/maybeForwardMessages.ts index d81d4708f6a5..1ff4bf29cb5f 100644 --- a/ts/util/maybeForwardMessages.ts +++ b/ts/util/maybeForwardMessages.ts @@ -78,10 +78,13 @@ export async function maybeForwardMessages( const preparedMessages = await Promise.all( messages.map(async message => { const { draft, originalMessage } = message; - const { sticker, contact } = originalMessage; + const { sticker, contact } = originalMessage ?? {}; const { attachments, bodyRanges, messageBody, previews } = draft; - const idForLogging = getMessageIdForLogging(originalMessage); + const idForLogging = + originalMessage != null + ? getMessageIdForLogging(originalMessage) + : '(new message)'; log.info(`maybeForwardMessage: Forwarding ${idForLogging}`); const attachmentLookup = new Set(); @@ -180,7 +183,9 @@ export async function maybeForwardMessages( log.error( 'maybeForwardMessage: message send error', getConversationIdForLogging(conversation.attributes), - getMessageIdForLogging(originalMessage), + originalMessage != null + ? getMessageIdForLogging(originalMessage) + : '(new message)', toLogFormat(error) ); }) diff --git a/ts/util/setupI18nMain.ts b/ts/util/setupI18nMain.ts index 6ec8f289b53f..541871199974 100644 --- a/ts/util/setupI18nMain.ts +++ b/ts/util/setupI18nMain.ts @@ -7,6 +7,7 @@ import type { LocaleMessageType, LocaleMessagesType } from '../types/I18N'; import type { LocalizerType, ICUStringMessageParamsByKeyType, + LocalizerOptions, } from '../types/Util'; import { strictAssert } from './assert'; import * as log from '../logging/log'; @@ -117,11 +118,14 @@ export function setupI18n( Key extends keyof ICUStringMessageParamsByKeyType >( key: Key, - substitutions: ICUStringMessageParamsByKeyType[Key] + substitutions: ICUStringMessageParamsByKeyType[Key], + options?: LocalizerOptions ) => { const result = intl.formatMessage( { id: key }, - normalizeSubstitutions(substitutions) + options?.textIsBidiFreeSkipNormalization + ? substitutions + : normalizeSubstitutions(substitutions) ); strictAssert(result !== key, `i18n: missing translation for "${key}"`);