Init create/admin call links flow

This commit is contained in:
Jamie Kyle 2024-06-10 08:23:43 -07:00 committed by GitHub
parent 53b8f5f152
commit f19f0fb47d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 1256 additions and 149 deletions

View file

@ -7080,6 +7080,10 @@
"messageformat": "No results for “{query}”",
"description": "Calls Tab > Calls List > When no results found > With a search query"
},
"icu:CallsList__CreateCallLink": {
"messageformat": "Create a Call Link",
"description": "Calls Tab > Calls List > Create Call Link Button"
},
"icu:CallsList__ItemCallInfo--Incoming": {
"messageformat": "Incoming",
"description": "Calls Tab > Calls List > Call Item > Call Status > When call was accepted and was incoming"
@ -7164,6 +7168,30 @@
"messageformat": "Share link via Signal",
"description": "Call History > Call Link Details > Share Link via Signal Button"
},
"icu:CallLinkEditModal__Title": {
"messageformat": "Call link details",
"description": "Call Link Edit Modal > Title"
},
"icu:CallLinkEditModal__InputLabel--Name--SrOnly": {
"messageformat": "Name",
"description": "Call Link Edit Modal > Name Input > Label (for screenreaders)"
},
"icu:CallLinkEditModal__JoinButtonLabel": {
"messageformat": "Join",
"description": "Call Link Edit Modal > Join Button > Label"
},
"icu:CallLinkEditModal__InputLabel--ApproveAllMembers": {
"messageformat": "Approve all members",
"description": "Call Link Edit Modal > Approve All Members Checkbox > Label"
},
"icu:CallLinkEditModal__ApproveAllMembers__Option--Off": {
"messageformat": "Off",
"description": "Call Link Edit Modal > Approve All Members Checkbox > Option > Off"
},
"icu:CallLinkEditModal__ApproveAllMembers__Option--On": {
"messageformat": "On",
"description": "Call Link Edit Modal > Approve All Members Checkbox > Option > On"
},
"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."

View file

@ -211,7 +211,7 @@
"@formatjs/intl": "2.6.7",
"@indutny/rezip-electron": "1.3.1",
"@mixer/parallel-prettier": "2.0.3",
"@signalapp/mock-server": "6.5.0",
"@signalapp/mock-server": "6.6.0",
"@storybook/addon-a11y": "7.4.5",
"@storybook/addon-actions": "7.4.5",
"@storybook/addon-controls": "7.4.5",

View file

@ -0,0 +1,139 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
.CallLinkEditModal__SrOnly {
@include sr-only;
}
.CallLinkEditModal__Header {
display: flex;
gap: 16px;
align-items: center;
margin-bottom: 26px;
}
.CallLinkEditModal__Header__Details {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0; // fix overflow issue
}
// Overriding default style
.Input__container.CallLinkEditModal__Input--Name__container {
margin: 0;
}
.CallLinkEditModal__CallLinkAndJoinButton {
display: flex;
gap: 14px;
align-items: center;
}
.CallLinkEditModal__CopyUrlTextButton {
@include button-reset;
border: none;
padding-block: 10px;
padding-inline: 8px;
border-radius: 6px;
flex: 1;
// truncate with ellipsis
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@include light-theme {
background: $color-gray-02;
color: $color-black;
}
@include dark-theme {
background: $color-gray-75;
color: $color-gray-15;
}
}
.CallLinkEditModal__JoinButton {
@include font-body-1-bold;
}
.CallLinkEditModal__ApproveAllMembers__Row {
display: flex;
align-items: center;
margin-bottom: 18px;
}
.CallLinkEditModal__ApproveAllMembers__Label {
flex: 1;
}
.CallLinkEditModal__ActionButton {
@include button-reset;
@include font-body-2;
display: flex;
gap: 8px;
align-items: center;
padding-block: 8px;
width: 100%;
@include light-theme {
color: $color-black;
}
@include dark-theme {
color: $color-gray-15;
}
}
.CallLinkEditModal__ActionButton__Icon {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 9999px;
@include light-theme {
background: $color-gray-05;
}
@include dark-theme {
background: $color-gray-65;
}
&::after {
content: '';
display: block;
width: 20px;
height: 20px;
background-size: contain;
background-repeat: no-repeat;
background-position: center;
}
}
.CallLinkEditModal__ActionButton__Icon--Copy {
&::after {
@include light-theme {
@include color-svg('../images/icons/v3/copy/copy.svg', $color-gray-75);
}
@include dark-theme {
@include color-svg('../images/icons/v3/copy/copy.svg', $color-gray-15);
}
}
}
.CallLinkEditModal__ActionButton__Icon--Share {
&::after {
@include light-theme {
@include color-svg(
'../images/icons/v3/forward/forward.svg',
$color-gray-75
);
}
@include dark-theme {
@include color-svg(
'../images/icons/v3/forward/forward.svg',
$color-gray-15
);
}
}
}

View file

@ -161,7 +161,7 @@
@include NavTabs__Scroller;
}
.CallsList__List--loading {
.CallsList__List--disableScrolling {
overflow: hidden !important;
}

View file

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

View file

@ -16,6 +16,7 @@ import { getCountryCode } from './types/PhoneNumber';
export type ConfigKeyType =
| 'desktop.calling.adhoc'
| 'desktop.calling.adhoc.create'
| 'desktop.clientExpiration'
| 'desktop.backup.credentialFetch'
| 'desktop.deleteSync.send'

View file

@ -0,0 +1,32 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { action } from '@storybook/addon-actions';
import { setupI18n } from '../util/setupI18n';
import enMessages from '../../_locales/en/messages.json';
import type { CallLinkEditModalProps } from './CallLinkEditModal';
import { CallLinkEditModal } from './CallLinkEditModal';
import type { ComponentMeta } from '../storybook/types';
import { FAKE_CALL_LINK_WITH_ADMIN_KEY } from '../test-both/helpers/fakeCallLink';
const i18n = setupI18n('en', enMessages);
export default {
title: 'Components/CallLinkEditModal',
component: CallLinkEditModal,
args: {
i18n,
callLink: FAKE_CALL_LINK_WITH_ADMIN_KEY,
onClose: action('onClose'),
onCopyCallLink: action('onCopyCallLink'),
onUpdateCallLinkName: action('onUpdateCallLinkName'),
onUpdateCallLinkRestrictions: action('onUpdateCallLinkRestrictions'),
onShareCallLinkViaSignal: action('onShareCallLinkViaSignal'),
onStartCallLinkLobby: action('onStartCallLinkLobby'),
},
} satisfies ComponentMeta<CallLinkEditModalProps>;
export function Basic(args: CallLinkEditModalProps): JSX.Element {
return <CallLinkEditModal {...args} />;
}

View file

@ -0,0 +1,214 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useCallback, useMemo, useState } from 'react';
import { v4 as generateUuid } from 'uuid';
import { Modal } from './Modal';
import type { LocalizerType } from '../types/I18N';
import {
CallLinkRestrictions,
toCallLinkRestrictions,
type CallLinkType,
} from '../types/CallLink';
import { Input } from './Input';
import { Select } from './Select';
import { linkCallRoute } from '../util/signalRoutes';
import { Button, ButtonSize, ButtonVariant } from './Button';
import { Avatar, AvatarSize } from './Avatar';
import { formatUrlWithoutProtocol } from '../util/url';
export type CallLinkEditModalProps = {
i18n: LocalizerType;
callLink: CallLinkType;
onClose: () => void;
onCopyCallLink: () => void;
onUpdateCallLinkName: (name: string) => void;
onUpdateCallLinkRestrictions: (restrictions: CallLinkRestrictions) => void;
onShareCallLinkViaSignal: () => void;
onStartCallLinkLobby: () => void;
};
export function CallLinkEditModal({
i18n,
callLink,
onClose,
onCopyCallLink,
onUpdateCallLinkName,
onUpdateCallLinkRestrictions,
onShareCallLinkViaSignal,
onStartCallLinkLobby,
}: CallLinkEditModalProps): JSX.Element {
const { name: savedName, restrictions: savedRestrictions } = callLink;
const [nameId] = useState(() => generateUuid());
const [restrictionsId] = useState(() => generateUuid());
const [nameInput, setNameInput] = useState(savedName);
const [restrictionsInput, setRestrictionsInput] = useState(savedRestrictions);
// We only want to use the default name "Signal Call" as a value if the user
// modified the input and then chose that name. Doesn't revert when saved.
const [nameTouched, setNameTouched] = useState(false);
const callLinkWebUrl = useMemo(() => {
return formatUrlWithoutProtocol(
linkCallRoute.toWebUrl({ key: callLink.rootKey })
);
}, [callLink.rootKey]);
const onSaveName = useCallback(
(newName: string) => {
if (!nameTouched) {
return;
}
if (newName === savedName) {
return;
}
onUpdateCallLinkName(newName);
},
[nameTouched, savedName, onUpdateCallLinkName]
);
const onSaveRestrictions = useCallback(
(newRestrictions: CallLinkRestrictions) => {
if (newRestrictions === savedRestrictions) {
return;
}
onUpdateCallLinkRestrictions(newRestrictions);
},
[savedRestrictions, onUpdateCallLinkRestrictions]
);
return (
<Modal
i18n={i18n}
modalName="CallLinkEditModal"
moduleClassName="CallLinkEditModal"
title={i18n('icu:CallLinkEditModal__Title')}
hasXButton
onClose={() => {
// Save the modal in case the user hits escape
onSaveName(nameInput);
onClose();
}}
>
<div className="CallLinkEditModal__Header">
<Avatar
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="CallLinkEditModal__Header__Details">
<label htmlFor={nameId} className="CallLinkEditModal__SrOnly">
{i18n('icu:CallLinkEditModal__InputLabel--Name--SrOnly')}
</label>
<Input
moduleClassName="CallLinkEditModal__Input--Name"
i18n={i18n}
value={
nameInput === '' && !nameTouched
? i18n('icu:calling__call-link-default-title')
: nameInput
}
maxByteCount={120}
onChange={value => {
setNameTouched(true);
setNameInput(value);
}}
onBlur={() => {
onSaveName(nameInput);
}}
onEnter={() => {
onSaveName(nameInput);
}}
placeholder={i18n('icu:calling__call-link-default-title')}
/>
<div className="CallLinkEditModal__CallLinkAndJoinButton">
<button
className="CallLinkEditModal__CopyUrlTextButton"
type="button"
onClick={onCopyCallLink}
aria-label={i18n('icu:CallLinkDetails__CopyLink')}
>
{callLinkWebUrl}
</button>
<Button
onClick={onStartCallLinkLobby}
size={ButtonSize.Small}
variant={ButtonVariant.SecondaryAffirmative}
className="CallLinkEditModal__JoinButton"
>
{i18n('icu:CallLinkEditModal__JoinButtonLabel')}
</Button>
</div>
</div>
</div>
<div
className="CallLinkEditModal__ApproveAllMembers__Row"
// For testing, to easily check the restrictions saved
data-restrictions={savedRestrictions}
>
<label
htmlFor={restrictionsId}
className="CallLinkEditModal__ApproveAllMembers__Label"
>
{i18n('icu:CallLinkEditModal__InputLabel--ApproveAllMembers')}
</label>
<Select
id={restrictionsId}
value={restrictionsInput}
options={[
{
value: CallLinkRestrictions.None,
text: i18n(
'icu:CallLinkEditModal__ApproveAllMembers__Option--Off'
),
},
{
value: CallLinkRestrictions.AdminApproval,
text: i18n(
'icu:CallLinkEditModal__ApproveAllMembers__Option--On'
),
},
]}
onChange={value => {
const newRestrictions = toCallLinkRestrictions(value);
setRestrictionsInput(newRestrictions);
onSaveRestrictions(newRestrictions);
}}
/>
</div>
<button
type="button"
className="CallLinkEditModal__ActionButton"
onClick={onCopyCallLink}
>
<i
role="presentation"
className="CallLinkEditModal__ActionButton__Icon CallLinkEditModal__ActionButton__Icon--Copy"
/>
{i18n('icu:CallLinkDetails__CopyLink')}
</button>
<button
type="button"
className="CallLinkEditModal__ActionButton"
onClick={onShareCallLinkViaSignal}
>
<i
role="presentation"
className="CallLinkEditModal__ActionButton__Icon CallLinkEditModal__ActionButton__Icon--Share"
/>
{i18n('icu:CallLinkDetails__ShareLinkViaSignal')}
</button>
</Modal>
);
}

View file

@ -127,6 +127,7 @@ const defaultPendingState: SearchState = {
type CallsListProps = Readonly<{
activeCall: ActiveCallStateType | undefined;
canCreateCallLinks: boolean;
getCallHistoryGroupsCount: (
options: CallHistoryFilterOptions
) => Promise<number>;
@ -142,6 +143,7 @@ type CallsListProps = Readonly<{
hangUpActiveCall: (reason: string) => void;
i18n: LocalizerType;
selectedCallHistoryGroup: CallHistoryGroup | null;
onCreateCallLink: () => void;
onOutgoingAudioCallInConversation: (conversationId: string) => void;
onOutgoingVideoCallInConversation: (conversationId: string) => void;
onChangeCallsTabSelectedView: (selectedView: CallsTabSelectedView) => void;
@ -157,10 +159,6 @@ const INACTIVE_CALL_LINK_PEEK_INTERVAL = 5 * MINUTE;
const PEEK_BATCH_COUNT = 10;
const PEEK_QUEUE_INTERVAL = 30 * SECOND;
function rowHeight() {
return CALL_LIST_ITEM_ROW_HEIGHT;
}
function isSameOptions(
a: CallHistoryFilterOptions,
b: CallHistoryFilterOptions
@ -168,8 +166,12 @@ function isSameOptions(
return a.query === b.query && a.status === b.status;
}
type SpecialRows = 'CreateCallLink' | 'EmptyState';
type Row = CallHistoryGroup | SpecialRows;
export function CallsList({
activeCall,
canCreateCallLinks,
getCallHistoryGroupsCount,
getCallHistoryGroups,
callHistoryEdition,
@ -180,6 +182,7 @@ export function CallsList({
hangUpActiveCall,
i18n,
selectedCallHistoryGroup,
onCreateCallLink,
onOutgoingAudioCallInConversation,
onOutgoingVideoCallInConversation,
onChangeCallsTabSelectedView,
@ -190,7 +193,7 @@ export function CallsList({
const infiniteLoaderRef = useRef<InfiniteLoader>(null);
const listRef = useRef<List>(null);
const [queryInput, setQueryInput] = useState('');
const [status, setStatus] = useState(CallHistoryFilterStatus.All);
const [statusInput, setStatusInput] = useState(CallHistoryFilterStatus.All);
const [searchState, setSearchState] = useState(defaultInitState);
const [isLeaveCallDialogVisible, setIsLeaveCallDialogVisible] =
useState(false);
@ -200,6 +203,27 @@ export function CallsList({
const getCallHistoryGroupsCountRef = useRef(getCallHistoryGroupsCount);
const getCallHistoryGroupsRef = useRef(getCallHistoryGroups);
const searchStateQuery = searchState.options?.query ?? '';
const searchStateStatus =
searchState.options?.status ?? CallHistoryFilterStatus.All;
const searchFiltering =
searchStateQuery !== '' ||
searchStateStatus !== CallHistoryFilterStatus.All;
const searchPending = searchState.state === 'pending';
const rows = useMemo(() => {
let results: ReadonlyArray<Row> = searchState.results?.items ?? [];
if (results.length === 0) {
results = ['EmptyState'];
}
if (!searchFiltering && canCreateCallLinks) {
results = ['CreateCallLink', ...results];
}
return results;
}, [searchState.results?.items, searchFiltering, canCreateCallLinks]);
const rowCount = rows.length;
const searchStateItemsRef = useRef<ReadonlyArray<CallHistoryGroup> | null>(
null
);
@ -208,17 +232,14 @@ export function CallsList({
new Map()
);
const inactiveCallLinksPeekedAtRef = useRef<Map<string, number>>(new Map());
const peekQueueTimerRef = useRef<NodeJS.Timeout | null>(null);
function clearPeekQueueTimer() {
if (peekQueueTimerRef.current != null) {
clearInterval(peekQueueTimerRef.current);
peekQueueTimerRef.current = null;
}
}
useEffect(() => {
return () => {
clearPeekQueueTimer();
if (peekQueueTimerRef.current != null) {
clearInterval(peekQueueTimerRef.current);
peekQueueTimerRef.current = null;
}
};
}, []);
@ -489,7 +510,7 @@ export function CallsList({
async function search() {
const options: CallHistoryFilterOptions = {
query: queryInput.toLowerCase().normalize().trim(),
status,
status: statusInput,
};
let timer = setTimeout(() => {
@ -560,7 +581,7 @@ export function CallsList({
return () => {
controller.abort();
};
}, [queryInput, status, callHistoryEdition, enqueueCallPeeks]);
}, [queryInput, statusInput, callHistoryEdition, enqueueCallPeeks]);
const loadMoreRows = useCallback(
async (props: IndexRange) => {
@ -625,9 +646,72 @@ export function CallsList({
[searchState]
);
const rowHeight = useCallback(
({ index }: Index) => {
const item = rows.at(index) ?? null;
if (item === 'EmptyState') {
// arbitary large number so the empty state can be as big as it wants,
// scrolling should always be locked when the list is empty
return 9999;
}
return CALL_LIST_ITEM_ROW_HEIGHT;
},
[rows]
);
const rowRenderer = useCallback(
({ key, index, style }: ListRowProps) => {
const item = searchState.results?.items.at(index) ?? null;
const item = rows.at(index) ?? null;
if (item === 'CreateCallLink') {
return (
<div key={key} style={style}>
<ListTile
moduleClassName="CallsList__ItemTile"
title={
<span className="CallsList__ItemTitle">
{i18n('icu:CallsList__CreateCallLink')}
</span>
}
leading={
<Avatar
acceptedMessageRequest
conversationType="callLink"
i18n={i18n}
isMe={false}
title=""
sharedGroupNames={[]}
size={AvatarSize.THIRTY_SIX}
badge={undefined}
className="CallsList__ItemAvatar"
/>
}
onClick={onCreateCallLink}
/>
</div>
);
}
if (item === 'EmptyState') {
return (
<div key={key} className="CallsList__EmptyState" style={style}>
{searchStateQuery === '' ? (
i18n('icu:CallsList__EmptyState--noQuery')
) : (
<I18n
i18n={i18n}
id="icu:CallsList__EmptyState--hasQuery"
components={{
query: <UserText text={searchStateQuery} />,
}}
/>
)}
</div>
);
}
const conversation = getConversationForItem(item);
const activeCallConversationId = activeCall?.conversationId;
@ -647,11 +731,7 @@ export function CallsList({
);
const isActiveVisible = Boolean(isCallButtonVisible && item && isActive);
if (
searchState.state === 'pending' ||
item == null ||
conversation == null
) {
if (searchPending || item == null || conversation == null) {
return (
<div key={key} style={style}>
<ListTile
@ -697,6 +777,7 @@ export function CallsList({
<div
key={key}
style={style}
data-type={item.type}
className={classNames('CallsList__Item', {
'CallsList__Item--selected': isSelected,
'CallsList__Item--missed': wasMissed,
@ -792,13 +873,16 @@ export function CallsList({
},
[
activeCall,
searchState,
rows,
searchStateQuery,
searchPending,
getCallLink,
getConversationForItem,
getIsCallActive,
getIsInCall,
selectedCallHistoryGroup,
onChangeCallsTabSelectedView,
onCreateCallLink,
onOutgoingAudioCallInConversation,
onOutgoingVideoCallInConversation,
startCallLinkLobbyByRoomId,
@ -819,18 +903,13 @@ export function CallsList({
}, []);
const handleStatusToggle = useCallback(() => {
setStatus(prevStatus => {
setStatusInput(prevStatus => {
return prevStatus === CallHistoryFilterStatus.All
? CallHistoryFilterStatus.Missed
: CallHistoryFilterStatus.All;
});
}, []);
const filteringByMissed = status === CallHistoryFilterStatus.Missed;
const hasEmptyResults = searchState.results?.count === 0;
const currentQuery = searchState.options?.query ?? '';
return (
<>
{isLeaveCallDialogVisible && (
@ -874,10 +953,11 @@ export function CallsList({
>
<button
className={classNames('CallsList__ToggleFilterByMissed', {
'CallsList__ToggleFilterByMissed--pressed': filteringByMissed,
'CallsList__ToggleFilterByMissed--pressed':
statusInput === CallHistoryFilterStatus.Missed,
})}
type="button"
aria-pressed={filteringByMissed}
aria-pressed={statusInput === CallHistoryFilterStatus.Missed}
aria-roledescription={i18n(
'icu:CallsList__ToggleFilterByMissed__RoleDescription'
)}
@ -890,22 +970,6 @@ export function CallsList({
</Tooltip>
</NavSidebarSearchHeader>
{hasEmptyResults && (
<p className="CallsList__EmptyState">
{currentQuery === '' ? (
i18n('icu:CallsList__EmptyState--noQuery')
) : (
<I18n
i18n={i18n}
id="icu:CallsList__EmptyState--hasQuery"
components={{
query: <UserText text={currentQuery} />,
}}
/>
)}
</p>
)}
<SizeObserver>
{(ref, size) => {
return (
@ -915,7 +979,7 @@ export function CallsList({
ref={infiniteLoaderRef}
isRowLoaded={isRowLoaded}
loadMoreRows={loadMoreRows}
rowCount={searchState.results?.count}
rowCount={rowCount}
minimumBatchSize={100}
threshold={30}
>
@ -923,13 +987,14 @@ export function CallsList({
return (
<List
className={classNames('CallsList__List', {
'CallsList__List--loading':
searchState.state === 'pending',
'CallsList__List--disableScrolling':
searchState.results == null ||
searchState.results.count === 0,
})}
ref={refMerger(listRef, registerChild)}
width={size.width}
height={size.height}
rowCount={searchState.results?.count ?? 0}
rowCount={rowCount}
rowHeight={rowHeight}
rowRenderer={rowRenderer}
onRowsRendered={onRowsRendered}

View file

@ -41,6 +41,7 @@ type CallsTabProps = Readonly<{
pagination: CallHistoryPagination
) => Promise<Array<CallHistoryGroup>>;
callHistoryEdition: number;
canCreateCallLinks: boolean;
getAdhocCall: (roomId: string) => CallStateType | undefined;
getCall: (id: string) => CallStateType | undefined;
getCallLink: (id: string) => CallLinkType | undefined;
@ -53,6 +54,7 @@ type CallsTabProps = Readonly<{
onClearCallHistory: () => void;
onMarkCallHistoryRead: (conversationId: string, callId: string) => void;
onToggleNavTabsCollapse: (navTabsCollapsed: boolean) => void;
onCreateCallLink: () => void;
onOutgoingAudioCallInConversation: (conversationId: string) => void;
onOutgoingVideoCallInConversation: (conversationId: string) => void;
peekNotConnectedGroupCall: (options: PeekNotConnectedGroupCallType) => void;
@ -93,6 +95,7 @@ export function CallsTab({
getCallHistoryGroupsCount,
getCallHistoryGroups,
callHistoryEdition,
canCreateCallLinks,
getAdhocCall,
getCall,
getCallLink,
@ -105,6 +108,7 @@ export function CallsTab({
onClearCallHistory,
onMarkCallHistoryRead,
onToggleNavTabsCollapse,
onCreateCallLink,
onOutgoingAudioCallInConversation,
onOutgoingVideoCallInConversation,
peekNotConnectedGroupCall,
@ -257,6 +261,7 @@ export function CallsTab({
<CallsList
key={CallsTabSidebarView.CallsListView}
activeCall={activeCall}
canCreateCallLinks={canCreateCallLinks}
getCallHistoryGroupsCount={getCallHistoryGroupsCount}
getCallHistoryGroups={getCallHistoryGroups}
callHistoryEdition={callHistoryEdition}
@ -268,6 +273,7 @@ export function CallsTab({
i18n={i18n}
selectedCallHistoryGroup={selectedView?.callHistoryGroup ?? null}
onChangeCallsTabSelectedView={updateSelectedView}
onCreateCallLink={onCreateCallLink}
onOutgoingAudioCallInConversation={
handleOutgoingAudioCallInConversation
}

View file

@ -29,6 +29,9 @@ export type PropsType = {
// AddUserToAnotherGroupModal
addUserToAnotherGroupModalContactId: string | undefined;
renderAddUserToAnotherGroup: () => JSX.Element;
// CallLinkEditModal
callLinkEditModalRoomId: string | null;
renderCallLinkEditModal: () => JSX.Element;
// ContactModal
contactModalState: ContactModalStateType | undefined;
renderContactModal: () => JSX.Element;
@ -102,6 +105,9 @@ export function GlobalModalContainer({
// AddUserToAnotherGroupModal
addUserToAnotherGroupModalContactId,
renderAddUserToAnotherGroup,
// CallLinkEditModal
callLinkEditModalRoomId,
renderCallLinkEditModal,
// ContactModal
contactModalState,
renderContactModal,
@ -164,7 +170,8 @@ export function GlobalModalContainer({
// We want the following dialogs to show in this order:
// 1. Errors
// 2. Safety Number Changes
// 3. The Rest (in no particular order, but they're ordered alphabetically)
// 3. Forward Modal, so other modals can open it
// 4. The Rest (in no particular order, but they're ordered alphabetically)
// Errors
if (errorModalProps) {
@ -176,12 +183,21 @@ export function GlobalModalContainer({
return renderSendAnywayDialog();
}
// Forward Modal
if (forwardMessagesProps) {
return renderForwardMessagesModal();
}
// The Rest
if (addUserToAnotherGroupModalContactId) {
return renderAddUserToAnotherGroup();
}
if (callLinkEditModalRoomId) {
return renderCallLinkEditModal();
}
if (editHistoryMessages) {
return renderEditHistoryMessagesModal();
}
@ -194,10 +210,6 @@ export function GlobalModalContainer({
return renderDeleteMessagesModal();
}
if (forwardMessagesProps) {
return renderForwardMessagesModal();
}
if (messageRequestActionsConfirmationProps) {
return renderMessageRequestActionsConfirmation();
}

View file

@ -32,6 +32,7 @@ export type PropsType = {
maxLengthCount?: number;
moduleClassName?: string;
onChange: (value: string) => unknown;
onBlur?: () => unknown;
onEnter?: () => unknown;
placeholder: string;
value?: string;
@ -76,6 +77,7 @@ export const Input = forwardRef<
maxLengthCount = 0,
moduleClassName,
onChange,
onBlur,
onEnter,
placeholder,
value = '',
@ -214,6 +216,7 @@ export const Input = forwardRef<
id,
spellCheck: !disableSpellcheck,
onChange: handleChange,
onBlur,
onKeyDown: handleKeyDown,
onPaste: handlePaste,
placeholder,

View file

@ -45,6 +45,13 @@ import { uniqBy, noop, compact } from 'lodash';
import Long from 'long';
import type { CallLinkAuthCredentialPresentation } from '@signalapp/libsignal-client/zkgroup';
import {
CallLinkSecretParams,
CreateCallLinkCredentialRequestContext,
CreateCallLinkCredentialResponse,
GenericServerPublicParams,
} from '@signalapp/libsignal-client/zkgroup';
import { Aci } from '@signalapp/libsignal-client';
import type {
ActionsType as CallingReduxActionsType,
GroupCallParticipantInfoType,
@ -135,13 +142,20 @@ import {
getRoomIdFromRootKey,
getCallLinkAuthCredentialPresentation,
toAdminKeyBytes,
callLinkRestrictionsToRingRTC,
callLinkStateFromRingRTC,
} from '../util/callLinks';
import { isAdhocCallingEnabled } from '../util/isAdhocCallingEnabled';
import {
conversationJobQueue,
conversationQueueJobEnum,
} from '../jobs/conversationJobQueue';
import type { ReadCallLinkState } from '../types/CallLink';
import type {
CallLinkType,
CallLinkStateType,
ReadCallLinkState,
} from '../types/CallLink';
import { CallLinkRestrictions } from '../types/CallLink';
const {
processGroupCallRingCancellation,
@ -586,6 +600,162 @@ export class CallingClass {
}
}
async createCallLink(): Promise<CallLinkType> {
strictAssert(
this._sfuUrl,
'createCallLink() missing SFU URL; not creating call link'
);
const sfuUrl = this._sfuUrl;
const userId = Aci.parseFromServiceIdString(
window.textsecure.storage.user.getCheckedAci()
);
const rootKey = CallLinkRootKey.generate();
const roomId = rootKey.deriveRoomId();
const roomIdHex = roomId.toString('hex');
const logId = `createCallLink(${roomIdHex})`;
log.info(`${logId}: Creating call link`);
const adminKey = CallLinkRootKey.generateAdminPassKey();
const context = CreateCallLinkCredentialRequestContext.forRoomId(roomId);
const requestBase64 = Bytes.toBase64(context.getRequest().serialize());
strictAssert(
window.textsecure.messaging,
'createCallLink(): We are offline'
);
const { credential: credentialBase64 } =
await window.textsecure.messaging.server.callLinkCreateAuth(
requestBase64
);
const response = new CreateCallLinkCredentialResponse(
Buffer.from(credentialBase64, 'base64')
);
const genericServerPublicParams = new GenericServerPublicParams(
Buffer.from(window.getGenericServerPublicParams(), 'base64')
);
const credential = context.receive(
response,
userId,
genericServerPublicParams
);
const secretParams = CallLinkSecretParams.deriveFromRootKey(rootKey.bytes);
const credentialPresentation = credential
.present(roomId, userId, genericServerPublicParams, secretParams)
.serialize();
const serializedPublicParams = secretParams.getPublicParams().serialize();
const result = await RingRTC.createCallLink(
sfuUrl,
credentialPresentation,
rootKey,
adminKey,
serializedPublicParams
);
if (!result.success) {
const message = `Failed to create call link: ${result.errorStatusCode}`;
log.error(`${logId}: ${message}`);
throw new Error(message);
}
log.info(`${logId}: success`);
const state = callLinkStateFromRingRTC(result.value);
return {
roomId: roomIdHex,
rootKey: rootKey.toString(),
adminKey: adminKey.toString('base64'),
...state,
};
}
async updateCallLinkName(
callLink: CallLinkType,
name: string
): Promise<CallLinkStateType> {
strictAssert(
this._sfuUrl,
'updateCallLinkName() missing SFU URL; not update call link name'
);
const sfuUrl = this._sfuUrl;
const logId = `updateCallLinkName(${callLink.roomId})`;
log.info(`${logId}: Updating call link name`);
const callLinkRootKey = CallLinkRootKey.parse(callLink.rootKey);
strictAssert(callLink.adminKey, 'Missing admin key');
const callLinkAdminKey = toAdminKeyBytes(callLink.adminKey);
const authCredentialPresentation =
await getCallLinkAuthCredentialPresentation(callLinkRootKey);
const result = await RingRTC.updateCallLinkName(
sfuUrl,
authCredentialPresentation.serialize(),
callLinkRootKey,
callLinkAdminKey,
name
);
if (!result.success) {
const message = `Failed to update call link name: ${result.errorStatusCode}`;
log.error(`${logId}: ${message}`);
throw new Error(message);
}
log.info(`${logId}: success`);
return callLinkStateFromRingRTC(result.value);
}
async updateCallLinkRestrictions(
callLink: CallLinkType,
restrictions: CallLinkRestrictions
): Promise<CallLinkStateType> {
strictAssert(
this._sfuUrl,
'updateCallLinkRestrictions() missing SFU URL; not update call link restrictions'
);
const sfuUrl = this._sfuUrl;
const logId = `updateCallLinkRestrictions(${callLink.roomId})`;
log.info(`${logId}: Updating call link restrictions`);
const callLinkRootKey = CallLinkRootKey.parse(callLink.rootKey);
strictAssert(callLink.adminKey, 'Missing admin key');
const callLinkAdminKey = toAdminKeyBytes(callLink.adminKey);
const authCredentialPresentation =
await getCallLinkAuthCredentialPresentation(callLinkRootKey);
const newRestrictions = callLinkRestrictionsToRingRTC(restrictions);
strictAssert(
newRestrictions !== CallLinkRestrictions.Unknown,
'Invalid call link restrictions value'
);
const result = await RingRTC.updateCallLinkRestrictions(
sfuUrl,
authCredentialPresentation.serialize(),
callLinkRootKey,
callLinkAdminKey,
newRestrictions
);
if (!result.success) {
const message = `Failed to update call link restrictions: ${result.errorStatusCode}`;
log.error(`${logId}: ${message}`);
throw new Error(message);
}
log.info(`${logId}: success`);
return callLinkStateFromRingRTC(result.value);
}
async readCallLink({
callLinkRootKey,
}: Readonly<{
@ -1348,9 +1518,7 @@ export class CallingClass {
const callLinkRootKey = CallLinkRootKey.parse(rootKey);
const authCredentialPresentation =
await getCallLinkAuthCredentialPresentation(callLinkRootKey);
const adminPasskey = adminKey
? Buffer.from(toAdminKeyBytes(adminKey))
: undefined;
const adminPasskey = adminKey ? toAdminKeyBytes(adminKey) : undefined;
// RingRTC reuses the same type GroupCall between Adhoc and Group calls.
const groupCall = this.connectCallLinkCall({

View file

@ -707,7 +707,7 @@ export type DataInterface = {
updateCallLinkState(
roomId: string,
callLinkState: CallLinkStateType
): Promise<void>;
): Promise<CallLinkType>;
migrateConversationMessages: (
obsoleteId: string,
currentId: string

View file

@ -92,7 +92,7 @@ export async function insertCallLink(callLink: CallLinkType): Promise<void> {
export async function updateCallLinkState(
roomId: string,
callLinkState: CallLinkStateType
): Promise<void> {
): Promise<CallLinkType> {
const { name, restrictions, expiration, revoked } = callLinkState;
const db = await getWritableInstance();
const restrictionsValue = callLinkRestrictionsSchema.parse(restrictions);
@ -103,9 +103,12 @@ export async function updateCallLinkState(
restrictions = ${restrictionsValue},
expiration = ${expiration},
revoked = ${revoked ? 1 : 0}
WHERE roomId = ${roomId};
WHERE roomId = ${roomId}
RETURNING *;
`;
db.prepare(query).run(params);
const row = db.prepare(query).get(params);
strictAssert(row, 'Expected row to be returned');
return callLinkFromRecord(callLinkRecordSchema.parse(row));
}
export async function updateCallLinkAdminKeyByRoomId(

View file

@ -115,7 +115,9 @@ function markCallsTabViewed(): ThunkAction<
};
}
function addCallHistory(callHistory: CallHistoryDetails): CallHistoryAdd {
export function addCallHistory(
callHistory: CallHistoryDetails
): CallHistoryAdd {
return {
type: CALL_HISTORY_ADD,
payload: callHistory,

View file

@ -13,6 +13,7 @@ import {
GroupCallEndReason,
type Reaction as CallReaction,
} from '@signalapp/ringrtc';
import { v4 as generateUuid } from 'uuid';
import { getOwn } from '../../util/getOwn';
import * as Errors from '../../types/errors';
import { getIntl, getPlatform } from '../selectors/user';
@ -31,7 +32,11 @@ import type {
PresentedSource,
PresentableSource,
} from '../../types/Calling';
import type { CallLinkStateType, CallLinkType } from '../../types/CallLink';
import type {
CallLinkRestrictions,
CallLinkStateType,
CallLinkType,
} from '../../types/CallLink';
import {
CALLING_REACTIONS_LIFETIME,
MAX_CALLING_REACTIONS,
@ -48,6 +53,7 @@ import { requestCameraPermissions } from '../../util/callingPermissions';
import {
CALL_LINK_DEFAULT_STATE,
getRoomIdFromRootKey,
isCallLinksCreateEnabled,
toAdminKeyBytes,
} from '../../util/callLinks';
import { sendCallLinkUpdateSync } from '../../util/sendCallLinkUpdateSync';
@ -82,6 +88,14 @@ import { ButtonVariant } from '../../components/Button';
import { getConversationIdForLogging } from '../../util/idForLogging';
import dataInterface from '../../sql/Client';
import { isAciString } from '../../util/isAciString';
import type { CallHistoryDetails } from '../../types/CallDisposition';
import {
AdhocCallStatus,
CallDirection,
CallType,
} from '../../types/CallDisposition';
import type { CallHistoryAdd } from './callHistory';
import { addCallHistory } from './callHistory';
// State
@ -1865,6 +1879,89 @@ function onOutgoingAudioCallInConversation(
};
}
function createCallLink(
onCreated: (roomId: string) => void
): ThunkAction<
void,
RootStateType,
unknown,
CallHistoryAdd | HandleCallLinkUpdateActionType
> {
return async dispatch => {
strictAssert(isCallLinksCreateEnabled(), 'Call links creation is disabled');
const callLink = await calling.createCallLink();
const callHistory: CallHistoryDetails = {
callId: generateUuid(),
peerId: callLink.roomId,
ringerId: null,
mode: CallMode.Adhoc,
type: CallType.Adhoc,
direction: CallDirection.Incoming,
timestamp: Date.now(),
status: AdhocCallStatus.Pending,
};
await Promise.all([
dataInterface.insertCallLink(callLink),
dataInterface.saveCallHistory(callHistory),
]);
dispatch({
type: HANDLE_CALL_LINK_UPDATE,
payload: { callLink },
});
dispatch(addCallHistory(callHistory));
// Call after dispatching the action to ensure the call link is in the store
onCreated(callLink.roomId);
};
}
function updateCallLinkName(
roomId: string,
name: string
): ThunkAction<void, RootStateType, unknown, HandleCallLinkUpdateActionType> {
return async dispatch => {
const prevCallLink = await dataInterface.getCallLinkByRoomId(roomId);
strictAssert(
prevCallLink,
`updateCallLinkName(${roomId}): call link not found`
);
const callLinkState = await calling.updateCallLinkName(prevCallLink, name);
const callLink = await dataInterface.updateCallLinkState(
roomId,
callLinkState
);
dispatch({
type: HANDLE_CALL_LINK_UPDATE,
payload: { callLink },
});
};
}
function updateCallLinkRestrictions(
roomId: string,
restrictions: CallLinkRestrictions
): ThunkAction<void, RootStateType, unknown, HandleCallLinkUpdateActionType> {
return async dispatch => {
const prevCallLink = await dataInterface.getCallLinkByRoomId(roomId);
strictAssert(
prevCallLink,
`updateCallLinkRestrictions(${roomId}): call link not found`
);
const callLinkState = await calling.updateCallLinkRestrictions(
prevCallLink,
restrictions
);
const callLink = await dataInterface.updateCallLinkState(
roomId,
callLinkState
);
dispatch({
type: HANDLE_CALL_LINK_UPDATE,
payload: { callLink },
});
};
}
function startCallLinkLobbyByRoomId(
roomId: string
): StartCallLinkLobbyThunkActionType {
@ -1977,9 +2074,7 @@ const _startCallLinkLobby = async ({
0;
const { adminKey } = getOwn(state.calling.callLinks, roomId) ?? {};
const adminPasskey = adminKey
? Buffer.from(toAdminKeyBytes(adminKey))
: undefined;
const adminPasskey = adminKey ? toAdminKeyBytes(adminKey) : undefined;
const callLobbyData = await calling.startCallLinkLobby({
callLinkRootKey,
adminPasskey,
@ -2182,6 +2277,7 @@ export const actions = {
changeCallView,
changeIODevice,
closeNeedPermissionScreen,
createCallLink,
declineCall,
denyUser,
getPresentingSources,
@ -2227,6 +2323,8 @@ export const actions = {
togglePip,
toggleScreenRecordingPermissionsDialog,
toggleSettings,
updateCallLinkName,
updateCallLinkRestrictions,
};
export const useCallingActions = (): BoundActionCreatorsMapObject<

View file

@ -45,6 +45,9 @@ import {
} from '../selectors/conversations';
import { missingCaseError } from '../../util/missingCaseError';
import { ForwardMessagesModalType } from '../../components/ForwardMessagesModal';
import type { CallLinkType } from '../../types/CallLink';
import type { LocalizerType } from '../../types/I18N';
import { linkCallRoute } from '../../util/signalRoutes';
// State
@ -88,6 +91,7 @@ type MigrateToGV2PropsType = ReadonlyDeep<{
export type GlobalModalsStateType = ReadonlyDeep<{
addUserToAnotherGroupModalContactId?: string;
aboutContactModalContactId?: string;
callLinkEditModalRoomId: string | null;
contactModalState?: ContactModalStateType;
deleteMessagesProps?: DeleteMessagesPropsType;
editHistoryMessages?: EditHistoryMessagesType;
@ -139,6 +143,7 @@ export const TOGGLE_PROFILE_EDITOR_ERROR =
const TOGGLE_SAFETY_NUMBER_MODAL = 'globalModals/TOGGLE_SAFETY_NUMBER_MODAL';
const TOGGLE_ADD_USER_TO_ANOTHER_GROUP_MODAL =
'globalModals/TOGGLE_ADD_USER_TO_ANOTHER_GROUP_MODAL';
const TOGGLE_CALL_LINK_EDIT_MODAL = 'globalModals/TOGGLE_CALL_LINK_EDIT_MODAL';
const TOGGLE_ABOUT_MODAL = 'globalModals/TOGGLE_ABOUT_MODAL';
const TOGGLE_SIGNAL_CONNECTIONS_MODAL =
'globalModals/TOGGLE_SIGNAL_CONNECTIONS_MODAL';
@ -239,6 +244,11 @@ type ToggleAddUserToAnotherGroupModalActionType = ReadonlyDeep<{
payload: string | undefined;
}>;
type ToggleCallLinkEditModalActionType = ReadonlyDeep<{
type: typeof TOGGLE_CALL_LINK_EDIT_MODAL;
payload: string | null;
}>;
type ToggleAboutContactModalActionType = ReadonlyDeep<{
type: typeof TOGGLE_ABOUT_MODAL;
payload: string | undefined;
@ -364,6 +374,7 @@ export type GlobalModalsActionType = ReadonlyDeep<
| StartMigrationToGV2ActionType
| ToggleAboutContactModalActionType
| ToggleAddUserToAnotherGroupModalActionType
| ToggleCallLinkEditModalActionType
| ToggleConfirmationModalActionType
| ToggleDeleteMessagesModalActionType
| ToggleForwardMessagesModalActionType
@ -395,6 +406,7 @@ export const actions = {
toggleEditNicknameAndNoteModal,
toggleMessageRequestActionsConfirmation,
showGV2MigrationDialog,
showShareCallLinkViaSignal,
showShortcutGuideModal,
showStickerPackPreview,
showStoriesSettings,
@ -402,6 +414,7 @@ export const actions = {
showWhatsNewModal,
toggleAboutContactModal,
toggleAddUserToAnotherGroupModal,
toggleCallLinkEditModal,
toggleConfirmationModal,
toggleDeleteMessagesModal,
toggleForwardMessagesModal,
@ -619,6 +632,48 @@ function toggleForwardMessagesModal(
};
}
function showShareCallLinkViaSignal(
callLink: CallLinkType,
i18n: LocalizerType
): ThunkAction<
void,
RootStateType,
unknown,
ToggleForwardMessagesModalActionType
> {
return dispatch => {
const url = linkCallRoute
.toWebUrl({
key: callLink.rootKey,
})
.toString();
dispatch(
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 }
),
},
})
);
};
}
function toggleNotePreviewModal(
payload: NotePreviewModalPropsType | null
): ToggleNotePreviewModalActionType {
@ -656,6 +711,15 @@ function toggleAddUserToAnotherGroupModal(
};
}
function toggleCallLinkEditModal(
roomId: string | null
): ToggleCallLinkEditModalActionType {
return {
type: TOGGLE_CALL_LINK_EDIT_MODAL,
payload: roomId,
};
}
function toggleAboutContactModal(
contactId?: string
): ToggleAboutContactModalActionType {
@ -871,6 +935,7 @@ function copyOverMessageAttributesIntoForwardMessages(
export function getEmptyState(): GlobalModalsStateType {
return {
hasConfirmationModal: false,
callLinkEditModalRoomId: null,
editNicknameAndNoteModalProps: null,
isProfileEditorVisible: false,
isShortcutGuideModalVisible: false,
@ -984,6 +1049,13 @@ export function reducer(
};
}
if (action.type === TOGGLE_CALL_LINK_EDIT_MODAL) {
return {
...state,
callLinkEditModalRoomId: action.payload,
};
}
if (action.type === TOGGLE_DELETE_MESSAGES_MODAL) {
return {
...state,

View file

@ -22,6 +22,11 @@ export const isShowingAnyModal = createSelector(
})
);
export const getCallLinkEditModalRoomId = createSelector(
getGlobalModalsState,
({ callLinkEditModalRoomId }) => callLinkEditModalRoomId
);
export const getContactModalState = createSelector(
getGlobalModalsState,
({ contactModalState }) => contactModalState

View file

@ -10,8 +10,6 @@ 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;
@ -25,40 +23,14 @@ export const SmartCallLinkDetails = memo(function SmartCallLinkDetails({
const i18n = useSelector(getIntl);
const callLinkSelector = useSelector(getCallLinkSelector);
const { startCallLinkLobby } = useCallingActions();
const { toggleForwardMessagesModal } = useGlobalModalActions();
const { showShareCallLinkViaSignal } = 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]);
showShareCallLinkViaSignal(callLink, i18n);
}, [callLink, i18n, showShareCallLinkViaSignal]);
const handleStartCallLinkLobby = useCallback(() => {
strictAssert(callLink != null, 'callLink not found');

View file

@ -0,0 +1,101 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { memo, useCallback, useMemo } from 'react';
import { useSelector } from 'react-redux';
import { CallLinkEditModal } from '../../components/CallLinkEditModal';
import { useCallingActions } from '../ducks/calling';
import { getCallLinkSelector } from '../selectors/calling';
import * as log from '../../logging/log';
import { getIntl } from '../selectors/user';
import { useGlobalModalActions } from '../ducks/globalModals';
import type { CallLinkRestrictions } from '../../types/CallLink';
import { getCallLinkEditModalRoomId } from '../selectors/globalModals';
import { strictAssert } from '../../util/assert';
import { linkCallRoute } from '../../util/signalRoutes';
import { copyCallLink } from '../../util/copyLinksWithToast';
import { drop } from '../../util/drop';
import { isCallLinksCreateEnabled } from '../../util/callLinks';
export const SmartCallLinkEditModal = memo(
function SmartCallLinkEditModal(): JSX.Element | null {
strictAssert(isCallLinksCreateEnabled(), 'Call links creation is disabled');
const roomId = useSelector(getCallLinkEditModalRoomId);
strictAssert(roomId, 'Expected roomId to be set');
const i18n = useSelector(getIntl);
const callLinkSelector = useSelector(getCallLinkSelector);
const {
updateCallLinkName,
updateCallLinkRestrictions,
startCallLinkLobby,
} = useCallingActions();
const { toggleCallLinkEditModal, showShareCallLinkViaSignal } =
useGlobalModalActions();
const callLink = useMemo(() => {
return callLinkSelector(roomId);
}, [callLinkSelector, roomId]);
const handleClose = useCallback(() => {
toggleCallLinkEditModal(null);
}, [toggleCallLinkEditModal]);
const handleCopyCallLink = useCallback(() => {
strictAssert(callLink != null, 'callLink not found');
const callLinkWebUrl = linkCallRoute
.toWebUrl({
key: callLink?.rootKey,
})
.toString();
drop(copyCallLink(callLinkWebUrl));
}, [callLink]);
const handleUpdateCallLinkName = useCallback(
(newName: string) => {
updateCallLinkName(roomId, newName);
},
[roomId, updateCallLinkName]
);
const handleUpdateCallLinkRestrictions = useCallback(
(newRestrictions: CallLinkRestrictions) => {
updateCallLinkRestrictions(roomId, newRestrictions);
},
[roomId, updateCallLinkRestrictions]
);
const handleShareCallLinkViaSignal = useCallback(() => {
strictAssert(callLink != null, 'callLink not found');
showShareCallLinkViaSignal(callLink, i18n);
}, [callLink, i18n, showShareCallLinkViaSignal]);
const handleStartCallLinkLobby = useCallback(() => {
strictAssert(callLink != null, 'callLink not found');
startCallLinkLobby({ rootKey: callLink.rootKey });
toggleCallLinkEditModal(null);
}, [callLink, startCallLinkLobby, toggleCallLinkEditModal]);
if (!callLink) {
log.error(
'SmartCallLinkEditModal: No call link found for roomId',
roomId
);
return null;
}
return (
<CallLinkEditModal
i18n={i18n}
callLink={callLink}
onClose={handleClose}
onCopyCallLink={handleCopyCallLink}
onUpdateCallLinkName={handleUpdateCallLinkName}
onUpdateCallLinkRestrictions={handleUpdateCallLinkRestrictions}
onShareCallLinkViaSignal={handleShareCallLinkViaSignal}
onStartCallLinkLobby={handleStartCallLinkLobby}
/>
);
}
);

View file

@ -1,6 +1,6 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { memo, useCallback, useEffect } from 'react';
import React, { memo, useCallback, useEffect, useMemo } from 'react';
import { useSelector } from 'react-redux';
import { useItemsActions } from '../ducks/items';
import {
@ -40,6 +40,8 @@ import { getOtherTabsUnreadStats } from '../selectors/nav';
import { SmartCallLinkDetails } from './CallLinkDetails';
import type { CallLinkType } from '../../types/CallLink';
import { filterCallLinks } from '../../util/filterCallLinks';
import { useGlobalModalActions } from '../ducks/globalModals';
import { isCallLinksCreateEnabled } from '../../util/callLinks';
function getCallHistoryFilter({
allCallLinks,
@ -151,7 +153,12 @@ export const SmartCallsTab = memo(function SmartCallsTab() {
const hasFailedStorySends = useSelector(getHasAnyFailedStorySends);
const otherTabsUnreadStats = useSelector(getOtherTabsUnreadStats);
const canCreateCallLinks = useMemo(() => {
return isCallLinksCreateEnabled();
}, []);
const {
createCallLink,
hangUpActiveCall,
onOutgoingAudioCallInConversation,
onOutgoingVideoCallInConversation,
@ -164,6 +171,7 @@ export const SmartCallsTab = memo(function SmartCallsTab() {
markCallHistoryRead,
markCallsTabViewed,
} = useCallHistoryActions();
const { toggleCallLinkEditModal } = useGlobalModalActions();
const getCallHistoryGroupsCount = useCallback(
async (options: CallHistoryFilterOptions) => {
@ -207,6 +215,12 @@ export const SmartCallsTab = memo(function SmartCallsTab() {
[allCallLinks, allConversations, regionCode]
);
const handleCreateCallLink = useCallback(() => {
createCallLink(roomId => {
toggleCallLinkEditModal(roomId);
});
}, [createCallLink, toggleCallLinkEditModal]);
useEffect(() => {
markCallsTabViewed();
}, [markCallsTabViewed]);
@ -223,6 +237,7 @@ export const SmartCallsTab = memo(function SmartCallsTab() {
getCall={getCall}
getCallLink={getCallLink}
callHistoryEdition={callHistoryEdition}
canCreateCallLinks={canCreateCallLinks}
hangUpActiveCall={hangUpActiveCall}
hasFailedStorySends={hasFailedStorySends}
hasPendingUpdate={hasPendingUpdate}
@ -231,6 +246,7 @@ export const SmartCallsTab = memo(function SmartCallsTab() {
onClearCallHistory={clearCallHistory}
onMarkCallHistoryRead={markCallHistoryRead}
onToggleNavTabsCollapse={toggleNavTabsCollapse}
onCreateCallLink={handleCreateCallLink}
onOutgoingAudioCallInConversation={onOutgoingAudioCallInConversation}
onOutgoingVideoCallInConversation={onOutgoingVideoCallInConversation}
peekNotConnectedGroupCall={peekNotConnectedGroupCall}

View file

@ -26,6 +26,11 @@ import { SmartMessageRequestActionsConfirmation } from './MessageRequestActionsC
import { getGlobalModalsState } from '../selectors/globalModals';
import { SmartEditNicknameAndNoteModal } from './EditNicknameAndNoteModal';
import { SmartNotePreviewModal } from './NotePreviewModal';
import { SmartCallLinkEditModal } from './CallLinkEditModal';
function renderCallLinkEditModal(): JSX.Element {
return <SmartCallLinkEditModal />;
}
function renderEditHistoryMessagesModal(): JSX.Element {
return <SmartEditHistoryMessagesModal />;
@ -90,6 +95,7 @@ export const SmartGlobalModalContainer = memo(
const {
aboutContactModalContactId,
addUserToAnotherGroupModalContactId,
callLinkEditModalRoomId,
contactModalState,
deleteMessagesProps,
editHistoryMessages,
@ -168,6 +174,7 @@ export const SmartGlobalModalContainer = memo(
addUserToAnotherGroupModalContactId={
addUserToAnotherGroupModalContactId
}
callLinkEditModalRoomId={callLinkEditModalRoomId}
contactModalState={contactModalState}
editHistoryMessages={editHistoryMessages}
editNicknameAndNoteModalProps={editNicknameAndNoteModalProps}
@ -190,6 +197,7 @@ export const SmartGlobalModalContainer = memo(
isWhatsNewVisible={isWhatsNewVisible}
renderAboutContactModal={renderAboutContactModal}
renderAddUserToAnotherGroup={renderAddUserToAnotherGroup}
renderCallLinkEditModal={renderCallLinkEditModal}
renderContactModal={renderContactModal}
renderEditHistoryMessagesModal={renderEditHistoryMessagesModal}
renderEditNicknameAndNoteModal={renderEditNicknameAndNoteModal}

View file

@ -645,6 +645,7 @@ export class Bootstrap {
storageProfile: 'mock',
serverUrl: url,
storageUrl: url,
sfuUrl: url,
cdn: {
'0': url,
'2': url,

View file

@ -0,0 +1,64 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { expect } from 'playwright/test';
import * as durations from '../../util/durations';
import type { App } from '../playwright';
import { Bootstrap } from '../bootstrap';
describe('calling/callLinkAdmin', function (this: Mocha.Suite) {
this.timeout(durations.MINUTE);
let bootstrap: Bootstrap;
let app: App;
beforeEach(async () => {
bootstrap = new Bootstrap();
await bootstrap.init();
app = await bootstrap.link();
});
afterEach(async function (this: Mocha.Context) {
if (!bootstrap) {
return;
}
await bootstrap.maybeSaveLogs(this.currentTest, app);
await app.close();
await bootstrap.teardown();
});
it('can create and edit a call link', async () => {
const window = await app.getWindow();
await window.locator('[data-testid="NavTabsItem--Calls"]').click();
await window
.locator('.NavSidebar__HeaderTitle')
.getByText('Calls')
.waitFor();
await window
.locator('.CallsList__ItemTile')
.getByText('Create a Call Link')
.click();
const callLinkItem = window.locator('.CallsList__Item[data-type="Adhoc"]');
const modal = window.locator('.CallLinkEditModal');
await modal.waitFor();
const row = modal.locator('.CallLinkEditModal__ApproveAllMembers__Row');
await expect(row).toHaveAttribute('data-restrictions', '0');
const select = modal.locator('select');
await select.selectOption({ label: 'On' });
await expect(row).toHaveAttribute('data-restrictions', '1');
const nameInput = modal.locator('.CallLinkEditModal__Input--Name__input');
await nameInput.fill('New Name');
await nameInput.blur();
await expect(callLinkItem).toContainText('New Name');
});
});

View file

@ -570,6 +570,7 @@ const URL_CALLS = {
backupMedia: 'v1/archives/media',
backupMediaBatch: 'v1/archives/media/batch',
backupMediaDelete: 'v1/archives/media/delete',
callLinkCreateAuth: 'v1/call-link/create-auth',
registration: 'v1/registration',
registerCapabilities: 'v1/devices/capabilities',
reportMessage: 'v1/messages/report',
@ -666,7 +667,7 @@ type AjaxOptionsType = {
basicAuth?: string;
call: keyof typeof URL_CALLS;
contentType?: string;
data?: Uint8Array | Buffer | Uint8Array | string;
data?: Buffer | Uint8Array | string;
disableSessionResumption?: boolean;
headers?: HeaderListType;
host?: string;
@ -1148,6 +1149,14 @@ export type GetBackupInfoResponseType = z.infer<
typeof getBackupInfoResponseSchema
>;
export type CallLinkCreateAuthResponseType = Readonly<{
credential: string;
}>;
export const callLinkCreateAuthResponseSchema = z.object({
credential: z.string(),
}) satisfies z.ZodSchema<CallLinkCreateAuthResponseType>;
const StickerPackUploadAttributesSchema = z.object({
acl: z.string(),
algorithm: z.string(),
@ -1384,6 +1393,9 @@ export type WebAPIType = {
options: BackupListMediaOptionsType
) => Promise<BackupListMediaResponseType>;
backupDeleteMedia: (options: BackupDeleteMediaOptionsType) => Promise<void>;
callLinkCreateAuth: (
requestBase64: string
) => Promise<CallLinkCreateAuthResponseType>;
setPhoneNumberDiscoverability: (newValue: boolean) => Promise<void>;
updateDeviceName: (deviceName: string) => Promise<void>;
uploadAvatar: (
@ -1666,6 +1678,7 @@ export function initialize({
checkAccountExistence,
checkSockets,
createAccount,
callLinkCreateAuth,
createFetchForAttachmentUpload,
confirmUsername,
createGroup,
@ -2959,6 +2972,18 @@ export function initialize({
return backupListMediaResponseSchema.parse(res);
}
async function callLinkCreateAuth(
requestBase64: string
): Promise<CallLinkCreateAuthResponseType> {
const response = await _ajax({
call: 'callLinkCreateAuth',
httpType: 'POST',
responseType: 'json',
jsonData: { createCallLinkCredentialRequest: requestBase64 },
});
return callLinkCreateAuthResponseSchema.parse(response);
}
async function setPhoneNumberDiscoverability(newValue: boolean) {
await _ajax({
call: 'phoneNumberDiscoverability',

View file

@ -3,6 +3,7 @@
import type { ReadonlyDeep } from 'type-fest';
import { z } from 'zod';
import type { ConversationType } from '../state/ducks/conversations';
import { safeParseInteger } from '../util/numbers';
export enum CallLinkUpdateSyncType {
Update = 'Update',
@ -28,9 +29,9 @@ export enum CallLinkRestrictions {
export const callLinkRestrictionsSchema = z.nativeEnum(CallLinkRestrictions);
export function toCallLinkRestrictions(
restrictions: number
restrictions: number | string
): CallLinkRestrictions {
return callLinkRestrictionsSchema.parse(restrictions);
return callLinkRestrictionsSchema.parse(safeParseInteger(restrictions));
}
/**

View file

@ -1,7 +1,13 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { CallLinkRootKey } from '@signalapp/ringrtc';
import type { CallLinkState as RingRTCCallLinkState } from '@signalapp/ringrtc';
import {
CallLinkRootKey,
CallLinkRestrictions as RingRTCCallLinkRestrictions,
} from '@signalapp/ringrtc';
import { Aci } from '@signalapp/libsignal-client';
import { z } from 'zod';
import * as RemoteConfig from '../RemoteConfig';
import type { CallLinkAuthCredentialPresentation } from './zkgroup';
import {
CallLinkAuthCredential,
@ -15,6 +21,7 @@ import type {
CallLinkConversationType,
CallLinkType,
CallLinkRecord,
CallLinkStateType,
} from '../types/CallLink';
import {
callLinkRecordSchema,
@ -22,6 +29,7 @@ import {
toCallLinkRestrictions,
} from '../types/CallLink';
import type { LocalizerType } from '../types/Util';
import { isTestOrMockEnvironment } from '../environment';
export const CALL_LINK_DEFAULT_STATE = {
name: '',
@ -30,6 +38,13 @@ export const CALL_LINK_DEFAULT_STATE = {
expiration: null,
};
export function isCallLinksCreateEnabled(): boolean {
if (isTestOrMockEnvironment()) {
return true;
}
return RemoteConfig.getValue('desktop.calling.adhoc.create') === 'TRUE';
}
export function getRoomIdFromRootKey(rootKey: CallLinkRootKey): string {
return rootKey.deriveRoomId().toString('hex');
}
@ -112,14 +127,39 @@ export function fromRootKeyBytes(rootKey: Uint8Array): string {
return CallLinkRootKey.fromBytes(rootKey as Buffer).toString();
}
export function toAdminKeyBytes(adminKey: string): Uint8Array {
return Bytes.fromBase64(adminKey);
export function toAdminKeyBytes(adminKey: string): Buffer {
return Buffer.from(adminKey, 'base64');
}
export function fromAdminKeyBytes(adminKey: Uint8Array): string {
return Bytes.toBase64(adminKey);
}
/**
* RingRTC conversions
*/
export function callLinkStateFromRingRTC(
state: RingRTCCallLinkState
): CallLinkStateType {
return {
name: state.name,
restrictions: toCallLinkRestrictions(state.restrictions),
revoked: state.revoked,
expiration: state.expiration.getTime(),
};
}
const RingRTCCallLinkRestrictionsSchema = z.nativeEnum(
RingRTCCallLinkRestrictions
);
export function callLinkRestrictionsToRingRTC(
restrictions: CallLinkRestrictions
): RingRTCCallLinkRestrictions {
return RingRTCCallLinkRestrictionsSchema.parse(restrictions);
}
/**
* DB record conversions
*/

59
ts/util/numbers.ts Normal file
View file

@ -0,0 +1,59 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { strictAssert } from './assert';
export function safeParseNumber(value: number | string): number | null {
if (typeof value === 'number') {
return value;
}
strictAssert(typeof value === 'string', 'Expected string or number');
const trimmed = value.trim();
if (trimmed === '') {
return null;
}
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
return null;
}
if (parsed < Number.MIN_SAFE_INTEGER || parsed > Number.MAX_SAFE_INTEGER) {
return null;
}
return parsed;
}
export function safeParseInteger(
value: number | string,
trunc = false
): number | null {
const parsed = safeParseNumber(value);
if (parsed == null) {
return null;
}
if (trunc) {
return Math.trunc(parsed);
}
if (!Number.isInteger(parsed)) {
return null;
}
return parsed;
}
export function safeParseBigint(
value: bigint | number | string
): bigint | null {
if (typeof value === 'bigint') {
return value;
}
if (typeof value === 'number') {
if (!Number.isInteger(value)) {
return null;
}
return BigInt(value);
}
strictAssert(typeof value === 'string', 'Expected string, number, or bigint');
const trimmed = value.trim();
if (trimmed === '') {
return null;
}
return BigInt(value);
}

View file

@ -37,3 +37,7 @@ export function urlPathFromComponents(
): string {
return `/${components.filter(Boolean).map(encodeURIComponent).join('/')}`;
}
export function formatUrlWithoutProtocol(url: Readonly<URL>): string {
return `${url.hostname}${url.pathname}${url.search}${url.hash}`;
}

View file

@ -4011,10 +4011,10 @@
type-fest "^3.5.0"
uuid "^8.3.0"
"@signalapp/mock-server@6.5.0":
version "6.5.0"
resolved "https://registry.yarnpkg.com/@signalapp/mock-server/-/mock-server-6.5.0.tgz#0fa420ff2d7386770b3c8dfe6f57be425816a130"
integrity sha512-QuEYX9EFFaPIvQzGlHkgfrnpnhs+Q8jOwk2UuE+5txJNXezrQnq1nRFChG+M/XAv0aSbc7thiq8iBxwpN2F2EA==
"@signalapp/mock-server@6.6.0":
version "6.6.0"
resolved "https://registry.yarnpkg.com/@signalapp/mock-server/-/mock-server-6.6.0.tgz#9abce9c7e634ef4e3a316cdf165965500010bd92"
integrity sha512-zeLz9YikLaCQfWgSy2XDeEMdLWUTpyGOteSucD1BLcmv54J2eysk7ppDJns3H13opmF4Qj1Xmy5VftG3V3QKow==
dependencies:
"@signalapp/libsignal-client" "^0.45.0"
debug "^4.3.2"
@ -7617,7 +7617,7 @@ caniuse-lite@^1.0.30001349, caniuse-lite@^1.0.30001541:
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001541.tgz"
integrity sha512-bLOsqxDgTqUBkzxbNlSBt8annkDpQB9NdzdTbO2ooJ+eC/IQcvDspDc058g84ejCelF7vHUx57KIOjEecOHXaw==
canvas@^2.6.1, "canvas@https://registry.yarnpkg.com/nop/-/nop-1.0.0.tgz":
canvas@^2.6.1, "canvas@https://registry.yarnpkg.com/nop/-/nop-1.0.0.tgz", dmg-license@^1.0.11, "dmg-license@https://registry.yarnpkg.com/nop/-/nop-1.0.0.tgz", jsdom@^15.2.1, "jsdom@https://registry.yarnpkg.com/nop/-/nop-1.0.0.tgz":
version "1.0.0"
resolved "https://registry.yarnpkg.com/nop/-/nop-1.0.0.tgz#cb46cf7e01574aa6390858149f66897afe53c9ca"
@ -9028,10 +9028,6 @@ dmg-builder@24.6.3:
optionalDependencies:
dmg-license "^1.0.11"
dmg-license@^1.0.11, "dmg-license@https://registry.yarnpkg.com/nop/-/nop-1.0.0.tgz":
version "1.0.0"
resolved "https://registry.yarnpkg.com/nop/-/nop-1.0.0.tgz#cb46cf7e01574aa6390858149f66897afe53c9ca"
dns-equal@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/dns-equal/-/dns-equal-1.0.0.tgz#b39e7f1da6eb0a75ba9c17324b34753c47e0654d"
@ -13541,10 +13537,6 @@ jsdoc@^4.0.0:
strip-json-comments "^3.1.0"
underscore "~1.13.2"
jsdom@^15.2.1, "jsdom@https://registry.yarnpkg.com/nop/-/nop-1.0.0.tgz":
version "1.0.0"
resolved "https://registry.yarnpkg.com/nop/-/nop-1.0.0.tgz#cb46cf7e01574aa6390858149f66897afe53c9ca"
jsesc@^1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-1.3.0.tgz#46c3fec8c1892b12b0833db9bc7622176dbab34b"
@ -18727,7 +18719,7 @@ string-length@^5.0.1:
char-regex "^2.0.0"
strip-ansi "^7.0.1"
"string-width-cjs@npm:string-width@^4.2.0":
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.2.3:
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@ -18771,15 +18763,6 @@ string-width@^4.1.0, string-width@^4.2.0:
is-fullwidth-code-point "^3.0.0"
strip-ansi "^6.0.0"
string-width@^4.2.3:
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
dependencies:
emoji-regex "^8.0.0"
is-fullwidth-code-point "^3.0.0"
strip-ansi "^6.0.1"
string-width@^5.0.1, string-width@^5.1.2:
version "5.1.2"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794"
@ -18860,7 +18843,7 @@ string_decoder@~1.1.1:
dependencies:
safe-buffer "~5.1.0"
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@ -18895,13 +18878,6 @@ strip-ansi@^6.0.0:
dependencies:
ansi-regex "^5.0.0"
strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
dependencies:
ansi-regex "^5.0.1"
strip-ansi@^7.0.1:
version "7.1.0"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45"
@ -20553,7 +20529,7 @@ workerpool@6.2.1:
resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.2.1.tgz#46fc150c17d826b86a008e5a4508656777e9c343"
integrity sha512-ILEIE97kDZvF9Wb9f6h5aXK4swSlKGUcOEGiIYb2OOu/IrDU9iwj0fD//SsA6E5ibwJxpEvhullJY4Sl4GcpAw==
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
@ -20588,15 +20564,6 @@ wrap-ansi@^6.2.0:
string-width "^4.1.0"
strip-ansi "^6.0.0"
wrap-ansi@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
dependencies:
ansi-styles "^4.0.0"
string-width "^4.1.0"
strip-ansi "^6.0.0"
wrap-ansi@^8.1.0:
version "8.1.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"