Init create/admin call links flow
This commit is contained in:
parent
53b8f5f152
commit
f19f0fb47d
31 changed files with 1256 additions and 149 deletions
|
@ -7080,6 +7080,10 @@
|
||||||
"messageformat": "No results for “{query}”",
|
"messageformat": "No results for “{query}”",
|
||||||
"description": "Calls Tab > Calls List > When no results found > With a search 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": {
|
"icu:CallsList__ItemCallInfo--Incoming": {
|
||||||
"messageformat": "Incoming",
|
"messageformat": "Incoming",
|
||||||
"description": "Calls Tab > Calls List > Call Item > Call Status > When call was accepted and was 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",
|
"messageformat": "Share link via Signal",
|
||||||
"description": "Call History > Call Link Details > Share Link via Signal Button"
|
"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": {
|
"icu:TypingBubble__avatar--overflow-count": {
|
||||||
"messageformat": "{count, plural, one {# other is} other {# others are}} typing.",
|
"messageformat": "{count, plural, one {# other is} other {# others are}} typing.",
|
||||||
"description": "Group chat multiple person typing indicator when space isn't available to show every avatar, this is the count of avatars hidden."
|
"description": "Group chat multiple person typing indicator when space isn't available to show every avatar, this is the count of avatars hidden."
|
||||||
|
|
|
@ -211,7 +211,7 @@
|
||||||
"@formatjs/intl": "2.6.7",
|
"@formatjs/intl": "2.6.7",
|
||||||
"@indutny/rezip-electron": "1.3.1",
|
"@indutny/rezip-electron": "1.3.1",
|
||||||
"@mixer/parallel-prettier": "2.0.3",
|
"@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-a11y": "7.4.5",
|
||||||
"@storybook/addon-actions": "7.4.5",
|
"@storybook/addon-actions": "7.4.5",
|
||||||
"@storybook/addon-controls": "7.4.5",
|
"@storybook/addon-controls": "7.4.5",
|
||||||
|
|
139
stylesheets/components/CallLinkEditModal.scss
Normal file
139
stylesheets/components/CallLinkEditModal.scss
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -161,7 +161,7 @@
|
||||||
@include NavTabs__Scroller;
|
@include NavTabs__Scroller;
|
||||||
}
|
}
|
||||||
|
|
||||||
.CallsList__List--loading {
|
.CallsList__List--disableScrolling {
|
||||||
overflow: hidden !important;
|
overflow: hidden !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -51,6 +51,7 @@
|
||||||
@import './components/CallingSelectPresentingSourcesModal.scss';
|
@import './components/CallingSelectPresentingSourcesModal.scss';
|
||||||
@import './components/CallingToast.scss';
|
@import './components/CallingToast.scss';
|
||||||
@import './components/CallLinkDetails.scss';
|
@import './components/CallLinkDetails.scss';
|
||||||
|
@import './components/CallLinkEditModal.scss';
|
||||||
@import './components/CallingRaisedHandsList.scss';
|
@import './components/CallingRaisedHandsList.scss';
|
||||||
@import './components/CallingRaisedHandsToasts.scss';
|
@import './components/CallingRaisedHandsToasts.scss';
|
||||||
@import './components/CallingReactionsToasts.scss';
|
@import './components/CallingReactionsToasts.scss';
|
||||||
|
|
|
@ -16,6 +16,7 @@ import { getCountryCode } from './types/PhoneNumber';
|
||||||
|
|
||||||
export type ConfigKeyType =
|
export type ConfigKeyType =
|
||||||
| 'desktop.calling.adhoc'
|
| 'desktop.calling.adhoc'
|
||||||
|
| 'desktop.calling.adhoc.create'
|
||||||
| 'desktop.clientExpiration'
|
| 'desktop.clientExpiration'
|
||||||
| 'desktop.backup.credentialFetch'
|
| 'desktop.backup.credentialFetch'
|
||||||
| 'desktop.deleteSync.send'
|
| 'desktop.deleteSync.send'
|
||||||
|
|
32
ts/components/CallLinkEditModal.stories.tsx
Normal file
32
ts/components/CallLinkEditModal.stories.tsx
Normal 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} />;
|
||||||
|
}
|
214
ts/components/CallLinkEditModal.tsx
Normal file
214
ts/components/CallLinkEditModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -127,6 +127,7 @@ const defaultPendingState: SearchState = {
|
||||||
|
|
||||||
type CallsListProps = Readonly<{
|
type CallsListProps = Readonly<{
|
||||||
activeCall: ActiveCallStateType | undefined;
|
activeCall: ActiveCallStateType | undefined;
|
||||||
|
canCreateCallLinks: boolean;
|
||||||
getCallHistoryGroupsCount: (
|
getCallHistoryGroupsCount: (
|
||||||
options: CallHistoryFilterOptions
|
options: CallHistoryFilterOptions
|
||||||
) => Promise<number>;
|
) => Promise<number>;
|
||||||
|
@ -142,6 +143,7 @@ type CallsListProps = Readonly<{
|
||||||
hangUpActiveCall: (reason: string) => void;
|
hangUpActiveCall: (reason: string) => void;
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
selectedCallHistoryGroup: CallHistoryGroup | null;
|
selectedCallHistoryGroup: CallHistoryGroup | null;
|
||||||
|
onCreateCallLink: () => void;
|
||||||
onOutgoingAudioCallInConversation: (conversationId: string) => void;
|
onOutgoingAudioCallInConversation: (conversationId: string) => void;
|
||||||
onOutgoingVideoCallInConversation: (conversationId: string) => void;
|
onOutgoingVideoCallInConversation: (conversationId: string) => void;
|
||||||
onChangeCallsTabSelectedView: (selectedView: CallsTabSelectedView) => void;
|
onChangeCallsTabSelectedView: (selectedView: CallsTabSelectedView) => void;
|
||||||
|
@ -157,10 +159,6 @@ const INACTIVE_CALL_LINK_PEEK_INTERVAL = 5 * MINUTE;
|
||||||
const PEEK_BATCH_COUNT = 10;
|
const PEEK_BATCH_COUNT = 10;
|
||||||
const PEEK_QUEUE_INTERVAL = 30 * SECOND;
|
const PEEK_QUEUE_INTERVAL = 30 * SECOND;
|
||||||
|
|
||||||
function rowHeight() {
|
|
||||||
return CALL_LIST_ITEM_ROW_HEIGHT;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSameOptions(
|
function isSameOptions(
|
||||||
a: CallHistoryFilterOptions,
|
a: CallHistoryFilterOptions,
|
||||||
b: CallHistoryFilterOptions
|
b: CallHistoryFilterOptions
|
||||||
|
@ -168,8 +166,12 @@ function isSameOptions(
|
||||||
return a.query === b.query && a.status === b.status;
|
return a.query === b.query && a.status === b.status;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SpecialRows = 'CreateCallLink' | 'EmptyState';
|
||||||
|
type Row = CallHistoryGroup | SpecialRows;
|
||||||
|
|
||||||
export function CallsList({
|
export function CallsList({
|
||||||
activeCall,
|
activeCall,
|
||||||
|
canCreateCallLinks,
|
||||||
getCallHistoryGroupsCount,
|
getCallHistoryGroupsCount,
|
||||||
getCallHistoryGroups,
|
getCallHistoryGroups,
|
||||||
callHistoryEdition,
|
callHistoryEdition,
|
||||||
|
@ -180,6 +182,7 @@ export function CallsList({
|
||||||
hangUpActiveCall,
|
hangUpActiveCall,
|
||||||
i18n,
|
i18n,
|
||||||
selectedCallHistoryGroup,
|
selectedCallHistoryGroup,
|
||||||
|
onCreateCallLink,
|
||||||
onOutgoingAudioCallInConversation,
|
onOutgoingAudioCallInConversation,
|
||||||
onOutgoingVideoCallInConversation,
|
onOutgoingVideoCallInConversation,
|
||||||
onChangeCallsTabSelectedView,
|
onChangeCallsTabSelectedView,
|
||||||
|
@ -190,7 +193,7 @@ export function CallsList({
|
||||||
const infiniteLoaderRef = useRef<InfiniteLoader>(null);
|
const infiniteLoaderRef = useRef<InfiniteLoader>(null);
|
||||||
const listRef = useRef<List>(null);
|
const listRef = useRef<List>(null);
|
||||||
const [queryInput, setQueryInput] = useState('');
|
const [queryInput, setQueryInput] = useState('');
|
||||||
const [status, setStatus] = useState(CallHistoryFilterStatus.All);
|
const [statusInput, setStatusInput] = useState(CallHistoryFilterStatus.All);
|
||||||
const [searchState, setSearchState] = useState(defaultInitState);
|
const [searchState, setSearchState] = useState(defaultInitState);
|
||||||
const [isLeaveCallDialogVisible, setIsLeaveCallDialogVisible] =
|
const [isLeaveCallDialogVisible, setIsLeaveCallDialogVisible] =
|
||||||
useState(false);
|
useState(false);
|
||||||
|
@ -200,6 +203,27 @@ export function CallsList({
|
||||||
const getCallHistoryGroupsCountRef = useRef(getCallHistoryGroupsCount);
|
const getCallHistoryGroupsCountRef = useRef(getCallHistoryGroupsCount);
|
||||||
const getCallHistoryGroupsRef = useRef(getCallHistoryGroups);
|
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>(
|
const searchStateItemsRef = useRef<ReadonlyArray<CallHistoryGroup> | null>(
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
|
@ -208,17 +232,14 @@ export function CallsList({
|
||||||
new Map()
|
new Map()
|
||||||
);
|
);
|
||||||
const inactiveCallLinksPeekedAtRef = useRef<Map<string, number>>(new Map());
|
const inactiveCallLinksPeekedAtRef = useRef<Map<string, number>>(new Map());
|
||||||
|
|
||||||
const peekQueueTimerRef = useRef<NodeJS.Timeout | null>(null);
|
const peekQueueTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
function clearPeekQueueTimer() {
|
|
||||||
if (peekQueueTimerRef.current != null) {
|
|
||||||
clearInterval(peekQueueTimerRef.current);
|
|
||||||
peekQueueTimerRef.current = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
clearPeekQueueTimer();
|
if (peekQueueTimerRef.current != null) {
|
||||||
|
clearInterval(peekQueueTimerRef.current);
|
||||||
|
peekQueueTimerRef.current = null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
@ -489,7 +510,7 @@ export function CallsList({
|
||||||
async function search() {
|
async function search() {
|
||||||
const options: CallHistoryFilterOptions = {
|
const options: CallHistoryFilterOptions = {
|
||||||
query: queryInput.toLowerCase().normalize().trim(),
|
query: queryInput.toLowerCase().normalize().trim(),
|
||||||
status,
|
status: statusInput,
|
||||||
};
|
};
|
||||||
|
|
||||||
let timer = setTimeout(() => {
|
let timer = setTimeout(() => {
|
||||||
|
@ -560,7 +581,7 @@ export function CallsList({
|
||||||
return () => {
|
return () => {
|
||||||
controller.abort();
|
controller.abort();
|
||||||
};
|
};
|
||||||
}, [queryInput, status, callHistoryEdition, enqueueCallPeeks]);
|
}, [queryInput, statusInput, callHistoryEdition, enqueueCallPeeks]);
|
||||||
|
|
||||||
const loadMoreRows = useCallback(
|
const loadMoreRows = useCallback(
|
||||||
async (props: IndexRange) => {
|
async (props: IndexRange) => {
|
||||||
|
@ -625,9 +646,72 @@ export function CallsList({
|
||||||
[searchState]
|
[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(
|
const rowRenderer = useCallback(
|
||||||
({ key, index, style }: ListRowProps) => {
|
({ 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 conversation = getConversationForItem(item);
|
||||||
const activeCallConversationId = activeCall?.conversationId;
|
const activeCallConversationId = activeCall?.conversationId;
|
||||||
|
|
||||||
|
@ -647,11 +731,7 @@ export function CallsList({
|
||||||
);
|
);
|
||||||
const isActiveVisible = Boolean(isCallButtonVisible && item && isActive);
|
const isActiveVisible = Boolean(isCallButtonVisible && item && isActive);
|
||||||
|
|
||||||
if (
|
if (searchPending || item == null || conversation == null) {
|
||||||
searchState.state === 'pending' ||
|
|
||||||
item == null ||
|
|
||||||
conversation == null
|
|
||||||
) {
|
|
||||||
return (
|
return (
|
||||||
<div key={key} style={style}>
|
<div key={key} style={style}>
|
||||||
<ListTile
|
<ListTile
|
||||||
|
@ -697,6 +777,7 @@ export function CallsList({
|
||||||
<div
|
<div
|
||||||
key={key}
|
key={key}
|
||||||
style={style}
|
style={style}
|
||||||
|
data-type={item.type}
|
||||||
className={classNames('CallsList__Item', {
|
className={classNames('CallsList__Item', {
|
||||||
'CallsList__Item--selected': isSelected,
|
'CallsList__Item--selected': isSelected,
|
||||||
'CallsList__Item--missed': wasMissed,
|
'CallsList__Item--missed': wasMissed,
|
||||||
|
@ -792,13 +873,16 @@ export function CallsList({
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
activeCall,
|
activeCall,
|
||||||
searchState,
|
rows,
|
||||||
|
searchStateQuery,
|
||||||
|
searchPending,
|
||||||
getCallLink,
|
getCallLink,
|
||||||
getConversationForItem,
|
getConversationForItem,
|
||||||
getIsCallActive,
|
getIsCallActive,
|
||||||
getIsInCall,
|
getIsInCall,
|
||||||
selectedCallHistoryGroup,
|
selectedCallHistoryGroup,
|
||||||
onChangeCallsTabSelectedView,
|
onChangeCallsTabSelectedView,
|
||||||
|
onCreateCallLink,
|
||||||
onOutgoingAudioCallInConversation,
|
onOutgoingAudioCallInConversation,
|
||||||
onOutgoingVideoCallInConversation,
|
onOutgoingVideoCallInConversation,
|
||||||
startCallLinkLobbyByRoomId,
|
startCallLinkLobbyByRoomId,
|
||||||
|
@ -819,18 +903,13 @@ export function CallsList({
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleStatusToggle = useCallback(() => {
|
const handleStatusToggle = useCallback(() => {
|
||||||
setStatus(prevStatus => {
|
setStatusInput(prevStatus => {
|
||||||
return prevStatus === CallHistoryFilterStatus.All
|
return prevStatus === CallHistoryFilterStatus.All
|
||||||
? CallHistoryFilterStatus.Missed
|
? CallHistoryFilterStatus.Missed
|
||||||
: CallHistoryFilterStatus.All;
|
: CallHistoryFilterStatus.All;
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const filteringByMissed = status === CallHistoryFilterStatus.Missed;
|
|
||||||
|
|
||||||
const hasEmptyResults = searchState.results?.count === 0;
|
|
||||||
const currentQuery = searchState.options?.query ?? '';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{isLeaveCallDialogVisible && (
|
{isLeaveCallDialogVisible && (
|
||||||
|
@ -874,10 +953,11 @@ export function CallsList({
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
className={classNames('CallsList__ToggleFilterByMissed', {
|
className={classNames('CallsList__ToggleFilterByMissed', {
|
||||||
'CallsList__ToggleFilterByMissed--pressed': filteringByMissed,
|
'CallsList__ToggleFilterByMissed--pressed':
|
||||||
|
statusInput === CallHistoryFilterStatus.Missed,
|
||||||
})}
|
})}
|
||||||
type="button"
|
type="button"
|
||||||
aria-pressed={filteringByMissed}
|
aria-pressed={statusInput === CallHistoryFilterStatus.Missed}
|
||||||
aria-roledescription={i18n(
|
aria-roledescription={i18n(
|
||||||
'icu:CallsList__ToggleFilterByMissed__RoleDescription'
|
'icu:CallsList__ToggleFilterByMissed__RoleDescription'
|
||||||
)}
|
)}
|
||||||
|
@ -890,22 +970,6 @@ export function CallsList({
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</NavSidebarSearchHeader>
|
</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>
|
<SizeObserver>
|
||||||
{(ref, size) => {
|
{(ref, size) => {
|
||||||
return (
|
return (
|
||||||
|
@ -915,7 +979,7 @@ export function CallsList({
|
||||||
ref={infiniteLoaderRef}
|
ref={infiniteLoaderRef}
|
||||||
isRowLoaded={isRowLoaded}
|
isRowLoaded={isRowLoaded}
|
||||||
loadMoreRows={loadMoreRows}
|
loadMoreRows={loadMoreRows}
|
||||||
rowCount={searchState.results?.count}
|
rowCount={rowCount}
|
||||||
minimumBatchSize={100}
|
minimumBatchSize={100}
|
||||||
threshold={30}
|
threshold={30}
|
||||||
>
|
>
|
||||||
|
@ -923,13 +987,14 @@ export function CallsList({
|
||||||
return (
|
return (
|
||||||
<List
|
<List
|
||||||
className={classNames('CallsList__List', {
|
className={classNames('CallsList__List', {
|
||||||
'CallsList__List--loading':
|
'CallsList__List--disableScrolling':
|
||||||
searchState.state === 'pending',
|
searchState.results == null ||
|
||||||
|
searchState.results.count === 0,
|
||||||
})}
|
})}
|
||||||
ref={refMerger(listRef, registerChild)}
|
ref={refMerger(listRef, registerChild)}
|
||||||
width={size.width}
|
width={size.width}
|
||||||
height={size.height}
|
height={size.height}
|
||||||
rowCount={searchState.results?.count ?? 0}
|
rowCount={rowCount}
|
||||||
rowHeight={rowHeight}
|
rowHeight={rowHeight}
|
||||||
rowRenderer={rowRenderer}
|
rowRenderer={rowRenderer}
|
||||||
onRowsRendered={onRowsRendered}
|
onRowsRendered={onRowsRendered}
|
||||||
|
|
|
@ -41,6 +41,7 @@ type CallsTabProps = Readonly<{
|
||||||
pagination: CallHistoryPagination
|
pagination: CallHistoryPagination
|
||||||
) => Promise<Array<CallHistoryGroup>>;
|
) => Promise<Array<CallHistoryGroup>>;
|
||||||
callHistoryEdition: number;
|
callHistoryEdition: number;
|
||||||
|
canCreateCallLinks: boolean;
|
||||||
getAdhocCall: (roomId: string) => CallStateType | undefined;
|
getAdhocCall: (roomId: string) => CallStateType | undefined;
|
||||||
getCall: (id: string) => CallStateType | undefined;
|
getCall: (id: string) => CallStateType | undefined;
|
||||||
getCallLink: (id: string) => CallLinkType | undefined;
|
getCallLink: (id: string) => CallLinkType | undefined;
|
||||||
|
@ -53,6 +54,7 @@ type CallsTabProps = Readonly<{
|
||||||
onClearCallHistory: () => void;
|
onClearCallHistory: () => void;
|
||||||
onMarkCallHistoryRead: (conversationId: string, callId: string) => void;
|
onMarkCallHistoryRead: (conversationId: string, callId: string) => void;
|
||||||
onToggleNavTabsCollapse: (navTabsCollapsed: boolean) => void;
|
onToggleNavTabsCollapse: (navTabsCollapsed: boolean) => void;
|
||||||
|
onCreateCallLink: () => void;
|
||||||
onOutgoingAudioCallInConversation: (conversationId: string) => void;
|
onOutgoingAudioCallInConversation: (conversationId: string) => void;
|
||||||
onOutgoingVideoCallInConversation: (conversationId: string) => void;
|
onOutgoingVideoCallInConversation: (conversationId: string) => void;
|
||||||
peekNotConnectedGroupCall: (options: PeekNotConnectedGroupCallType) => void;
|
peekNotConnectedGroupCall: (options: PeekNotConnectedGroupCallType) => void;
|
||||||
|
@ -93,6 +95,7 @@ export function CallsTab({
|
||||||
getCallHistoryGroupsCount,
|
getCallHistoryGroupsCount,
|
||||||
getCallHistoryGroups,
|
getCallHistoryGroups,
|
||||||
callHistoryEdition,
|
callHistoryEdition,
|
||||||
|
canCreateCallLinks,
|
||||||
getAdhocCall,
|
getAdhocCall,
|
||||||
getCall,
|
getCall,
|
||||||
getCallLink,
|
getCallLink,
|
||||||
|
@ -105,6 +108,7 @@ export function CallsTab({
|
||||||
onClearCallHistory,
|
onClearCallHistory,
|
||||||
onMarkCallHistoryRead,
|
onMarkCallHistoryRead,
|
||||||
onToggleNavTabsCollapse,
|
onToggleNavTabsCollapse,
|
||||||
|
onCreateCallLink,
|
||||||
onOutgoingAudioCallInConversation,
|
onOutgoingAudioCallInConversation,
|
||||||
onOutgoingVideoCallInConversation,
|
onOutgoingVideoCallInConversation,
|
||||||
peekNotConnectedGroupCall,
|
peekNotConnectedGroupCall,
|
||||||
|
@ -257,6 +261,7 @@ export function CallsTab({
|
||||||
<CallsList
|
<CallsList
|
||||||
key={CallsTabSidebarView.CallsListView}
|
key={CallsTabSidebarView.CallsListView}
|
||||||
activeCall={activeCall}
|
activeCall={activeCall}
|
||||||
|
canCreateCallLinks={canCreateCallLinks}
|
||||||
getCallHistoryGroupsCount={getCallHistoryGroupsCount}
|
getCallHistoryGroupsCount={getCallHistoryGroupsCount}
|
||||||
getCallHistoryGroups={getCallHistoryGroups}
|
getCallHistoryGroups={getCallHistoryGroups}
|
||||||
callHistoryEdition={callHistoryEdition}
|
callHistoryEdition={callHistoryEdition}
|
||||||
|
@ -268,6 +273,7 @@ export function CallsTab({
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
selectedCallHistoryGroup={selectedView?.callHistoryGroup ?? null}
|
selectedCallHistoryGroup={selectedView?.callHistoryGroup ?? null}
|
||||||
onChangeCallsTabSelectedView={updateSelectedView}
|
onChangeCallsTabSelectedView={updateSelectedView}
|
||||||
|
onCreateCallLink={onCreateCallLink}
|
||||||
onOutgoingAudioCallInConversation={
|
onOutgoingAudioCallInConversation={
|
||||||
handleOutgoingAudioCallInConversation
|
handleOutgoingAudioCallInConversation
|
||||||
}
|
}
|
||||||
|
|
|
@ -29,6 +29,9 @@ export type PropsType = {
|
||||||
// AddUserToAnotherGroupModal
|
// AddUserToAnotherGroupModal
|
||||||
addUserToAnotherGroupModalContactId: string | undefined;
|
addUserToAnotherGroupModalContactId: string | undefined;
|
||||||
renderAddUserToAnotherGroup: () => JSX.Element;
|
renderAddUserToAnotherGroup: () => JSX.Element;
|
||||||
|
// CallLinkEditModal
|
||||||
|
callLinkEditModalRoomId: string | null;
|
||||||
|
renderCallLinkEditModal: () => JSX.Element;
|
||||||
// ContactModal
|
// ContactModal
|
||||||
contactModalState: ContactModalStateType | undefined;
|
contactModalState: ContactModalStateType | undefined;
|
||||||
renderContactModal: () => JSX.Element;
|
renderContactModal: () => JSX.Element;
|
||||||
|
@ -102,6 +105,9 @@ export function GlobalModalContainer({
|
||||||
// AddUserToAnotherGroupModal
|
// AddUserToAnotherGroupModal
|
||||||
addUserToAnotherGroupModalContactId,
|
addUserToAnotherGroupModalContactId,
|
||||||
renderAddUserToAnotherGroup,
|
renderAddUserToAnotherGroup,
|
||||||
|
// CallLinkEditModal
|
||||||
|
callLinkEditModalRoomId,
|
||||||
|
renderCallLinkEditModal,
|
||||||
// ContactModal
|
// ContactModal
|
||||||
contactModalState,
|
contactModalState,
|
||||||
renderContactModal,
|
renderContactModal,
|
||||||
|
@ -164,7 +170,8 @@ export function GlobalModalContainer({
|
||||||
// We want the following dialogs to show in this order:
|
// We want the following dialogs to show in this order:
|
||||||
// 1. Errors
|
// 1. Errors
|
||||||
// 2. Safety Number Changes
|
// 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
|
// Errors
|
||||||
if (errorModalProps) {
|
if (errorModalProps) {
|
||||||
|
@ -176,12 +183,21 @@ export function GlobalModalContainer({
|
||||||
return renderSendAnywayDialog();
|
return renderSendAnywayDialog();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Forward Modal
|
||||||
|
if (forwardMessagesProps) {
|
||||||
|
return renderForwardMessagesModal();
|
||||||
|
}
|
||||||
|
|
||||||
// The Rest
|
// The Rest
|
||||||
|
|
||||||
if (addUserToAnotherGroupModalContactId) {
|
if (addUserToAnotherGroupModalContactId) {
|
||||||
return renderAddUserToAnotherGroup();
|
return renderAddUserToAnotherGroup();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (callLinkEditModalRoomId) {
|
||||||
|
return renderCallLinkEditModal();
|
||||||
|
}
|
||||||
|
|
||||||
if (editHistoryMessages) {
|
if (editHistoryMessages) {
|
||||||
return renderEditHistoryMessagesModal();
|
return renderEditHistoryMessagesModal();
|
||||||
}
|
}
|
||||||
|
@ -194,10 +210,6 @@ export function GlobalModalContainer({
|
||||||
return renderDeleteMessagesModal();
|
return renderDeleteMessagesModal();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (forwardMessagesProps) {
|
|
||||||
return renderForwardMessagesModal();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (messageRequestActionsConfirmationProps) {
|
if (messageRequestActionsConfirmationProps) {
|
||||||
return renderMessageRequestActionsConfirmation();
|
return renderMessageRequestActionsConfirmation();
|
||||||
}
|
}
|
||||||
|
|
|
@ -32,6 +32,7 @@ export type PropsType = {
|
||||||
maxLengthCount?: number;
|
maxLengthCount?: number;
|
||||||
moduleClassName?: string;
|
moduleClassName?: string;
|
||||||
onChange: (value: string) => unknown;
|
onChange: (value: string) => unknown;
|
||||||
|
onBlur?: () => unknown;
|
||||||
onEnter?: () => unknown;
|
onEnter?: () => unknown;
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
value?: string;
|
value?: string;
|
||||||
|
@ -76,6 +77,7 @@ export const Input = forwardRef<
|
||||||
maxLengthCount = 0,
|
maxLengthCount = 0,
|
||||||
moduleClassName,
|
moduleClassName,
|
||||||
onChange,
|
onChange,
|
||||||
|
onBlur,
|
||||||
onEnter,
|
onEnter,
|
||||||
placeholder,
|
placeholder,
|
||||||
value = '',
|
value = '',
|
||||||
|
@ -214,6 +216,7 @@ export const Input = forwardRef<
|
||||||
id,
|
id,
|
||||||
spellCheck: !disableSpellcheck,
|
spellCheck: !disableSpellcheck,
|
||||||
onChange: handleChange,
|
onChange: handleChange,
|
||||||
|
onBlur,
|
||||||
onKeyDown: handleKeyDown,
|
onKeyDown: handleKeyDown,
|
||||||
onPaste: handlePaste,
|
onPaste: handlePaste,
|
||||||
placeholder,
|
placeholder,
|
||||||
|
|
|
@ -45,6 +45,13 @@ import { uniqBy, noop, compact } from 'lodash';
|
||||||
|
|
||||||
import Long from 'long';
|
import Long from 'long';
|
||||||
import type { CallLinkAuthCredentialPresentation } from '@signalapp/libsignal-client/zkgroup';
|
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 {
|
import type {
|
||||||
ActionsType as CallingReduxActionsType,
|
ActionsType as CallingReduxActionsType,
|
||||||
GroupCallParticipantInfoType,
|
GroupCallParticipantInfoType,
|
||||||
|
@ -135,13 +142,20 @@ import {
|
||||||
getRoomIdFromRootKey,
|
getRoomIdFromRootKey,
|
||||||
getCallLinkAuthCredentialPresentation,
|
getCallLinkAuthCredentialPresentation,
|
||||||
toAdminKeyBytes,
|
toAdminKeyBytes,
|
||||||
|
callLinkRestrictionsToRingRTC,
|
||||||
|
callLinkStateFromRingRTC,
|
||||||
} from '../util/callLinks';
|
} from '../util/callLinks';
|
||||||
import { isAdhocCallingEnabled } from '../util/isAdhocCallingEnabled';
|
import { isAdhocCallingEnabled } from '../util/isAdhocCallingEnabled';
|
||||||
import {
|
import {
|
||||||
conversationJobQueue,
|
conversationJobQueue,
|
||||||
conversationQueueJobEnum,
|
conversationQueueJobEnum,
|
||||||
} from '../jobs/conversationJobQueue';
|
} from '../jobs/conversationJobQueue';
|
||||||
import type { ReadCallLinkState } from '../types/CallLink';
|
import type {
|
||||||
|
CallLinkType,
|
||||||
|
CallLinkStateType,
|
||||||
|
ReadCallLinkState,
|
||||||
|
} from '../types/CallLink';
|
||||||
|
import { CallLinkRestrictions } from '../types/CallLink';
|
||||||
|
|
||||||
const {
|
const {
|
||||||
processGroupCallRingCancellation,
|
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({
|
async readCallLink({
|
||||||
callLinkRootKey,
|
callLinkRootKey,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
|
@ -1348,9 +1518,7 @@ export class CallingClass {
|
||||||
const callLinkRootKey = CallLinkRootKey.parse(rootKey);
|
const callLinkRootKey = CallLinkRootKey.parse(rootKey);
|
||||||
const authCredentialPresentation =
|
const authCredentialPresentation =
|
||||||
await getCallLinkAuthCredentialPresentation(callLinkRootKey);
|
await getCallLinkAuthCredentialPresentation(callLinkRootKey);
|
||||||
const adminPasskey = adminKey
|
const adminPasskey = adminKey ? toAdminKeyBytes(adminKey) : undefined;
|
||||||
? Buffer.from(toAdminKeyBytes(adminKey))
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
// RingRTC reuses the same type GroupCall between Adhoc and Group calls.
|
// RingRTC reuses the same type GroupCall between Adhoc and Group calls.
|
||||||
const groupCall = this.connectCallLinkCall({
|
const groupCall = this.connectCallLinkCall({
|
||||||
|
|
|
@ -707,7 +707,7 @@ export type DataInterface = {
|
||||||
updateCallLinkState(
|
updateCallLinkState(
|
||||||
roomId: string,
|
roomId: string,
|
||||||
callLinkState: CallLinkStateType
|
callLinkState: CallLinkStateType
|
||||||
): Promise<void>;
|
): Promise<CallLinkType>;
|
||||||
migrateConversationMessages: (
|
migrateConversationMessages: (
|
||||||
obsoleteId: string,
|
obsoleteId: string,
|
||||||
currentId: string
|
currentId: string
|
||||||
|
|
|
@ -92,7 +92,7 @@ export async function insertCallLink(callLink: CallLinkType): Promise<void> {
|
||||||
export async function updateCallLinkState(
|
export async function updateCallLinkState(
|
||||||
roomId: string,
|
roomId: string,
|
||||||
callLinkState: CallLinkStateType
|
callLinkState: CallLinkStateType
|
||||||
): Promise<void> {
|
): Promise<CallLinkType> {
|
||||||
const { name, restrictions, expiration, revoked } = callLinkState;
|
const { name, restrictions, expiration, revoked } = callLinkState;
|
||||||
const db = await getWritableInstance();
|
const db = await getWritableInstance();
|
||||||
const restrictionsValue = callLinkRestrictionsSchema.parse(restrictions);
|
const restrictionsValue = callLinkRestrictionsSchema.parse(restrictions);
|
||||||
|
@ -103,9 +103,12 @@ export async function updateCallLinkState(
|
||||||
restrictions = ${restrictionsValue},
|
restrictions = ${restrictionsValue},
|
||||||
expiration = ${expiration},
|
expiration = ${expiration},
|
||||||
revoked = ${revoked ? 1 : 0}
|
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(
|
export async function updateCallLinkAdminKeyByRoomId(
|
||||||
|
|
|
@ -115,7 +115,9 @@ function markCallsTabViewed(): ThunkAction<
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function addCallHistory(callHistory: CallHistoryDetails): CallHistoryAdd {
|
export function addCallHistory(
|
||||||
|
callHistory: CallHistoryDetails
|
||||||
|
): CallHistoryAdd {
|
||||||
return {
|
return {
|
||||||
type: CALL_HISTORY_ADD,
|
type: CALL_HISTORY_ADD,
|
||||||
payload: callHistory,
|
payload: callHistory,
|
||||||
|
|
|
@ -13,6 +13,7 @@ import {
|
||||||
GroupCallEndReason,
|
GroupCallEndReason,
|
||||||
type Reaction as CallReaction,
|
type Reaction as CallReaction,
|
||||||
} from '@signalapp/ringrtc';
|
} from '@signalapp/ringrtc';
|
||||||
|
import { v4 as generateUuid } from 'uuid';
|
||||||
import { getOwn } from '../../util/getOwn';
|
import { getOwn } from '../../util/getOwn';
|
||||||
import * as Errors from '../../types/errors';
|
import * as Errors from '../../types/errors';
|
||||||
import { getIntl, getPlatform } from '../selectors/user';
|
import { getIntl, getPlatform } from '../selectors/user';
|
||||||
|
@ -31,7 +32,11 @@ import type {
|
||||||
PresentedSource,
|
PresentedSource,
|
||||||
PresentableSource,
|
PresentableSource,
|
||||||
} from '../../types/Calling';
|
} from '../../types/Calling';
|
||||||
import type { CallLinkStateType, CallLinkType } from '../../types/CallLink';
|
import type {
|
||||||
|
CallLinkRestrictions,
|
||||||
|
CallLinkStateType,
|
||||||
|
CallLinkType,
|
||||||
|
} from '../../types/CallLink';
|
||||||
import {
|
import {
|
||||||
CALLING_REACTIONS_LIFETIME,
|
CALLING_REACTIONS_LIFETIME,
|
||||||
MAX_CALLING_REACTIONS,
|
MAX_CALLING_REACTIONS,
|
||||||
|
@ -48,6 +53,7 @@ import { requestCameraPermissions } from '../../util/callingPermissions';
|
||||||
import {
|
import {
|
||||||
CALL_LINK_DEFAULT_STATE,
|
CALL_LINK_DEFAULT_STATE,
|
||||||
getRoomIdFromRootKey,
|
getRoomIdFromRootKey,
|
||||||
|
isCallLinksCreateEnabled,
|
||||||
toAdminKeyBytes,
|
toAdminKeyBytes,
|
||||||
} from '../../util/callLinks';
|
} from '../../util/callLinks';
|
||||||
import { sendCallLinkUpdateSync } from '../../util/sendCallLinkUpdateSync';
|
import { sendCallLinkUpdateSync } from '../../util/sendCallLinkUpdateSync';
|
||||||
|
@ -82,6 +88,14 @@ import { ButtonVariant } from '../../components/Button';
|
||||||
import { getConversationIdForLogging } from '../../util/idForLogging';
|
import { getConversationIdForLogging } from '../../util/idForLogging';
|
||||||
import dataInterface from '../../sql/Client';
|
import dataInterface from '../../sql/Client';
|
||||||
import { isAciString } from '../../util/isAciString';
|
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
|
// 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(
|
function startCallLinkLobbyByRoomId(
|
||||||
roomId: string
|
roomId: string
|
||||||
): StartCallLinkLobbyThunkActionType {
|
): StartCallLinkLobbyThunkActionType {
|
||||||
|
@ -1977,9 +2074,7 @@ const _startCallLinkLobby = async ({
|
||||||
0;
|
0;
|
||||||
|
|
||||||
const { adminKey } = getOwn(state.calling.callLinks, roomId) ?? {};
|
const { adminKey } = getOwn(state.calling.callLinks, roomId) ?? {};
|
||||||
const adminPasskey = adminKey
|
const adminPasskey = adminKey ? toAdminKeyBytes(adminKey) : undefined;
|
||||||
? Buffer.from(toAdminKeyBytes(adminKey))
|
|
||||||
: undefined;
|
|
||||||
const callLobbyData = await calling.startCallLinkLobby({
|
const callLobbyData = await calling.startCallLinkLobby({
|
||||||
callLinkRootKey,
|
callLinkRootKey,
|
||||||
adminPasskey,
|
adminPasskey,
|
||||||
|
@ -2182,6 +2277,7 @@ export const actions = {
|
||||||
changeCallView,
|
changeCallView,
|
||||||
changeIODevice,
|
changeIODevice,
|
||||||
closeNeedPermissionScreen,
|
closeNeedPermissionScreen,
|
||||||
|
createCallLink,
|
||||||
declineCall,
|
declineCall,
|
||||||
denyUser,
|
denyUser,
|
||||||
getPresentingSources,
|
getPresentingSources,
|
||||||
|
@ -2227,6 +2323,8 @@ export const actions = {
|
||||||
togglePip,
|
togglePip,
|
||||||
toggleScreenRecordingPermissionsDialog,
|
toggleScreenRecordingPermissionsDialog,
|
||||||
toggleSettings,
|
toggleSettings,
|
||||||
|
updateCallLinkName,
|
||||||
|
updateCallLinkRestrictions,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useCallingActions = (): BoundActionCreatorsMapObject<
|
export const useCallingActions = (): BoundActionCreatorsMapObject<
|
||||||
|
|
|
@ -45,6 +45,9 @@ import {
|
||||||
} from '../selectors/conversations';
|
} from '../selectors/conversations';
|
||||||
import { missingCaseError } from '../../util/missingCaseError';
|
import { missingCaseError } from '../../util/missingCaseError';
|
||||||
import { ForwardMessagesModalType } from '../../components/ForwardMessagesModal';
|
import { ForwardMessagesModalType } from '../../components/ForwardMessagesModal';
|
||||||
|
import type { CallLinkType } from '../../types/CallLink';
|
||||||
|
import type { LocalizerType } from '../../types/I18N';
|
||||||
|
import { linkCallRoute } from '../../util/signalRoutes';
|
||||||
|
|
||||||
// State
|
// State
|
||||||
|
|
||||||
|
@ -88,6 +91,7 @@ type MigrateToGV2PropsType = ReadonlyDeep<{
|
||||||
export type GlobalModalsStateType = ReadonlyDeep<{
|
export type GlobalModalsStateType = ReadonlyDeep<{
|
||||||
addUserToAnotherGroupModalContactId?: string;
|
addUserToAnotherGroupModalContactId?: string;
|
||||||
aboutContactModalContactId?: string;
|
aboutContactModalContactId?: string;
|
||||||
|
callLinkEditModalRoomId: string | null;
|
||||||
contactModalState?: ContactModalStateType;
|
contactModalState?: ContactModalStateType;
|
||||||
deleteMessagesProps?: DeleteMessagesPropsType;
|
deleteMessagesProps?: DeleteMessagesPropsType;
|
||||||
editHistoryMessages?: EditHistoryMessagesType;
|
editHistoryMessages?: EditHistoryMessagesType;
|
||||||
|
@ -139,6 +143,7 @@ export const TOGGLE_PROFILE_EDITOR_ERROR =
|
||||||
const TOGGLE_SAFETY_NUMBER_MODAL = 'globalModals/TOGGLE_SAFETY_NUMBER_MODAL';
|
const TOGGLE_SAFETY_NUMBER_MODAL = 'globalModals/TOGGLE_SAFETY_NUMBER_MODAL';
|
||||||
const TOGGLE_ADD_USER_TO_ANOTHER_GROUP_MODAL =
|
const TOGGLE_ADD_USER_TO_ANOTHER_GROUP_MODAL =
|
||||||
'globalModals/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_ABOUT_MODAL = 'globalModals/TOGGLE_ABOUT_MODAL';
|
||||||
const TOGGLE_SIGNAL_CONNECTIONS_MODAL =
|
const TOGGLE_SIGNAL_CONNECTIONS_MODAL =
|
||||||
'globalModals/TOGGLE_SIGNAL_CONNECTIONS_MODAL';
|
'globalModals/TOGGLE_SIGNAL_CONNECTIONS_MODAL';
|
||||||
|
@ -239,6 +244,11 @@ type ToggleAddUserToAnotherGroupModalActionType = ReadonlyDeep<{
|
||||||
payload: string | undefined;
|
payload: string | undefined;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
type ToggleCallLinkEditModalActionType = ReadonlyDeep<{
|
||||||
|
type: typeof TOGGLE_CALL_LINK_EDIT_MODAL;
|
||||||
|
payload: string | null;
|
||||||
|
}>;
|
||||||
|
|
||||||
type ToggleAboutContactModalActionType = ReadonlyDeep<{
|
type ToggleAboutContactModalActionType = ReadonlyDeep<{
|
||||||
type: typeof TOGGLE_ABOUT_MODAL;
|
type: typeof TOGGLE_ABOUT_MODAL;
|
||||||
payload: string | undefined;
|
payload: string | undefined;
|
||||||
|
@ -364,6 +374,7 @@ export type GlobalModalsActionType = ReadonlyDeep<
|
||||||
| StartMigrationToGV2ActionType
|
| StartMigrationToGV2ActionType
|
||||||
| ToggleAboutContactModalActionType
|
| ToggleAboutContactModalActionType
|
||||||
| ToggleAddUserToAnotherGroupModalActionType
|
| ToggleAddUserToAnotherGroupModalActionType
|
||||||
|
| ToggleCallLinkEditModalActionType
|
||||||
| ToggleConfirmationModalActionType
|
| ToggleConfirmationModalActionType
|
||||||
| ToggleDeleteMessagesModalActionType
|
| ToggleDeleteMessagesModalActionType
|
||||||
| ToggleForwardMessagesModalActionType
|
| ToggleForwardMessagesModalActionType
|
||||||
|
@ -395,6 +406,7 @@ export const actions = {
|
||||||
toggleEditNicknameAndNoteModal,
|
toggleEditNicknameAndNoteModal,
|
||||||
toggleMessageRequestActionsConfirmation,
|
toggleMessageRequestActionsConfirmation,
|
||||||
showGV2MigrationDialog,
|
showGV2MigrationDialog,
|
||||||
|
showShareCallLinkViaSignal,
|
||||||
showShortcutGuideModal,
|
showShortcutGuideModal,
|
||||||
showStickerPackPreview,
|
showStickerPackPreview,
|
||||||
showStoriesSettings,
|
showStoriesSettings,
|
||||||
|
@ -402,6 +414,7 @@ export const actions = {
|
||||||
showWhatsNewModal,
|
showWhatsNewModal,
|
||||||
toggleAboutContactModal,
|
toggleAboutContactModal,
|
||||||
toggleAddUserToAnotherGroupModal,
|
toggleAddUserToAnotherGroupModal,
|
||||||
|
toggleCallLinkEditModal,
|
||||||
toggleConfirmationModal,
|
toggleConfirmationModal,
|
||||||
toggleDeleteMessagesModal,
|
toggleDeleteMessagesModal,
|
||||||
toggleForwardMessagesModal,
|
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(
|
function toggleNotePreviewModal(
|
||||||
payload: NotePreviewModalPropsType | null
|
payload: NotePreviewModalPropsType | null
|
||||||
): ToggleNotePreviewModalActionType {
|
): ToggleNotePreviewModalActionType {
|
||||||
|
@ -656,6 +711,15 @@ function toggleAddUserToAnotherGroupModal(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleCallLinkEditModal(
|
||||||
|
roomId: string | null
|
||||||
|
): ToggleCallLinkEditModalActionType {
|
||||||
|
return {
|
||||||
|
type: TOGGLE_CALL_LINK_EDIT_MODAL,
|
||||||
|
payload: roomId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function toggleAboutContactModal(
|
function toggleAboutContactModal(
|
||||||
contactId?: string
|
contactId?: string
|
||||||
): ToggleAboutContactModalActionType {
|
): ToggleAboutContactModalActionType {
|
||||||
|
@ -871,6 +935,7 @@ function copyOverMessageAttributesIntoForwardMessages(
|
||||||
export function getEmptyState(): GlobalModalsStateType {
|
export function getEmptyState(): GlobalModalsStateType {
|
||||||
return {
|
return {
|
||||||
hasConfirmationModal: false,
|
hasConfirmationModal: false,
|
||||||
|
callLinkEditModalRoomId: null,
|
||||||
editNicknameAndNoteModalProps: null,
|
editNicknameAndNoteModalProps: null,
|
||||||
isProfileEditorVisible: false,
|
isProfileEditorVisible: false,
|
||||||
isShortcutGuideModalVisible: 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) {
|
if (action.type === TOGGLE_DELETE_MESSAGES_MODAL) {
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
|
|
|
@ -22,6 +22,11 @@ export const isShowingAnyModal = createSelector(
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const getCallLinkEditModalRoomId = createSelector(
|
||||||
|
getGlobalModalsState,
|
||||||
|
({ callLinkEditModalRoomId }) => callLinkEditModalRoomId
|
||||||
|
);
|
||||||
|
|
||||||
export const getContactModalState = createSelector(
|
export const getContactModalState = createSelector(
|
||||||
getGlobalModalsState,
|
getGlobalModalsState,
|
||||||
({ contactModalState }) => contactModalState
|
({ contactModalState }) => contactModalState
|
||||||
|
|
|
@ -10,8 +10,6 @@ import { useGlobalModalActions } from '../ducks/globalModals';
|
||||||
import { useCallingActions } from '../ducks/calling';
|
import { useCallingActions } from '../ducks/calling';
|
||||||
import * as log from '../../logging/log';
|
import * as log from '../../logging/log';
|
||||||
import { strictAssert } from '../../util/assert';
|
import { strictAssert } from '../../util/assert';
|
||||||
import { linkCallRoute } from '../../util/signalRoutes';
|
|
||||||
import { ForwardMessagesModalType } from '../../components/ForwardMessagesModal';
|
|
||||||
|
|
||||||
export type SmartCallLinkDetailsProps = Readonly<{
|
export type SmartCallLinkDetailsProps = Readonly<{
|
||||||
roomId: string;
|
roomId: string;
|
||||||
|
@ -25,40 +23,14 @@ export const SmartCallLinkDetails = memo(function SmartCallLinkDetails({
|
||||||
const i18n = useSelector(getIntl);
|
const i18n = useSelector(getIntl);
|
||||||
const callLinkSelector = useSelector(getCallLinkSelector);
|
const callLinkSelector = useSelector(getCallLinkSelector);
|
||||||
const { startCallLinkLobby } = useCallingActions();
|
const { startCallLinkLobby } = useCallingActions();
|
||||||
const { toggleForwardMessagesModal } = useGlobalModalActions();
|
const { showShareCallLinkViaSignal } = useGlobalModalActions();
|
||||||
|
|
||||||
const callLink = callLinkSelector(roomId);
|
const callLink = callLinkSelector(roomId);
|
||||||
|
|
||||||
const handleShareCallLinkViaSignal = useCallback(() => {
|
const handleShareCallLinkViaSignal = useCallback(() => {
|
||||||
strictAssert(callLink != null, 'callLink not found');
|
strictAssert(callLink != null, 'callLink not found');
|
||||||
const url = linkCallRoute
|
showShareCallLinkViaSignal(callLink, i18n);
|
||||||
.toWebUrl({
|
}, [callLink, i18n, showShareCallLinkViaSignal]);
|
||||||
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(() => {
|
const handleStartCallLinkLobby = useCallback(() => {
|
||||||
strictAssert(callLink != null, 'callLink not found');
|
strictAssert(callLink != null, 'callLink not found');
|
||||||
|
|
101
ts/state/smart/CallLinkEditModal.tsx
Normal file
101
ts/state/smart/CallLinkEditModal.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
|
@ -1,6 +1,6 @@
|
||||||
// Copyright 2023 Signal Messenger, LLC
|
// Copyright 2023 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// 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 { useSelector } from 'react-redux';
|
||||||
import { useItemsActions } from '../ducks/items';
|
import { useItemsActions } from '../ducks/items';
|
||||||
import {
|
import {
|
||||||
|
@ -40,6 +40,8 @@ import { getOtherTabsUnreadStats } from '../selectors/nav';
|
||||||
import { SmartCallLinkDetails } from './CallLinkDetails';
|
import { SmartCallLinkDetails } from './CallLinkDetails';
|
||||||
import type { CallLinkType } from '../../types/CallLink';
|
import type { CallLinkType } from '../../types/CallLink';
|
||||||
import { filterCallLinks } from '../../util/filterCallLinks';
|
import { filterCallLinks } from '../../util/filterCallLinks';
|
||||||
|
import { useGlobalModalActions } from '../ducks/globalModals';
|
||||||
|
import { isCallLinksCreateEnabled } from '../../util/callLinks';
|
||||||
|
|
||||||
function getCallHistoryFilter({
|
function getCallHistoryFilter({
|
||||||
allCallLinks,
|
allCallLinks,
|
||||||
|
@ -151,7 +153,12 @@ export const SmartCallsTab = memo(function SmartCallsTab() {
|
||||||
const hasFailedStorySends = useSelector(getHasAnyFailedStorySends);
|
const hasFailedStorySends = useSelector(getHasAnyFailedStorySends);
|
||||||
const otherTabsUnreadStats = useSelector(getOtherTabsUnreadStats);
|
const otherTabsUnreadStats = useSelector(getOtherTabsUnreadStats);
|
||||||
|
|
||||||
|
const canCreateCallLinks = useMemo(() => {
|
||||||
|
return isCallLinksCreateEnabled();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
createCallLink,
|
||||||
hangUpActiveCall,
|
hangUpActiveCall,
|
||||||
onOutgoingAudioCallInConversation,
|
onOutgoingAudioCallInConversation,
|
||||||
onOutgoingVideoCallInConversation,
|
onOutgoingVideoCallInConversation,
|
||||||
|
@ -164,6 +171,7 @@ export const SmartCallsTab = memo(function SmartCallsTab() {
|
||||||
markCallHistoryRead,
|
markCallHistoryRead,
|
||||||
markCallsTabViewed,
|
markCallsTabViewed,
|
||||||
} = useCallHistoryActions();
|
} = useCallHistoryActions();
|
||||||
|
const { toggleCallLinkEditModal } = useGlobalModalActions();
|
||||||
|
|
||||||
const getCallHistoryGroupsCount = useCallback(
|
const getCallHistoryGroupsCount = useCallback(
|
||||||
async (options: CallHistoryFilterOptions) => {
|
async (options: CallHistoryFilterOptions) => {
|
||||||
|
@ -207,6 +215,12 @@ export const SmartCallsTab = memo(function SmartCallsTab() {
|
||||||
[allCallLinks, allConversations, regionCode]
|
[allCallLinks, allConversations, regionCode]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleCreateCallLink = useCallback(() => {
|
||||||
|
createCallLink(roomId => {
|
||||||
|
toggleCallLinkEditModal(roomId);
|
||||||
|
});
|
||||||
|
}, [createCallLink, toggleCallLinkEditModal]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
markCallsTabViewed();
|
markCallsTabViewed();
|
||||||
}, [markCallsTabViewed]);
|
}, [markCallsTabViewed]);
|
||||||
|
@ -223,6 +237,7 @@ export const SmartCallsTab = memo(function SmartCallsTab() {
|
||||||
getCall={getCall}
|
getCall={getCall}
|
||||||
getCallLink={getCallLink}
|
getCallLink={getCallLink}
|
||||||
callHistoryEdition={callHistoryEdition}
|
callHistoryEdition={callHistoryEdition}
|
||||||
|
canCreateCallLinks={canCreateCallLinks}
|
||||||
hangUpActiveCall={hangUpActiveCall}
|
hangUpActiveCall={hangUpActiveCall}
|
||||||
hasFailedStorySends={hasFailedStorySends}
|
hasFailedStorySends={hasFailedStorySends}
|
||||||
hasPendingUpdate={hasPendingUpdate}
|
hasPendingUpdate={hasPendingUpdate}
|
||||||
|
@ -231,6 +246,7 @@ export const SmartCallsTab = memo(function SmartCallsTab() {
|
||||||
onClearCallHistory={clearCallHistory}
|
onClearCallHistory={clearCallHistory}
|
||||||
onMarkCallHistoryRead={markCallHistoryRead}
|
onMarkCallHistoryRead={markCallHistoryRead}
|
||||||
onToggleNavTabsCollapse={toggleNavTabsCollapse}
|
onToggleNavTabsCollapse={toggleNavTabsCollapse}
|
||||||
|
onCreateCallLink={handleCreateCallLink}
|
||||||
onOutgoingAudioCallInConversation={onOutgoingAudioCallInConversation}
|
onOutgoingAudioCallInConversation={onOutgoingAudioCallInConversation}
|
||||||
onOutgoingVideoCallInConversation={onOutgoingVideoCallInConversation}
|
onOutgoingVideoCallInConversation={onOutgoingVideoCallInConversation}
|
||||||
peekNotConnectedGroupCall={peekNotConnectedGroupCall}
|
peekNotConnectedGroupCall={peekNotConnectedGroupCall}
|
||||||
|
|
|
@ -26,6 +26,11 @@ import { SmartMessageRequestActionsConfirmation } from './MessageRequestActionsC
|
||||||
import { getGlobalModalsState } from '../selectors/globalModals';
|
import { getGlobalModalsState } from '../selectors/globalModals';
|
||||||
import { SmartEditNicknameAndNoteModal } from './EditNicknameAndNoteModal';
|
import { SmartEditNicknameAndNoteModal } from './EditNicknameAndNoteModal';
|
||||||
import { SmartNotePreviewModal } from './NotePreviewModal';
|
import { SmartNotePreviewModal } from './NotePreviewModal';
|
||||||
|
import { SmartCallLinkEditModal } from './CallLinkEditModal';
|
||||||
|
|
||||||
|
function renderCallLinkEditModal(): JSX.Element {
|
||||||
|
return <SmartCallLinkEditModal />;
|
||||||
|
}
|
||||||
|
|
||||||
function renderEditHistoryMessagesModal(): JSX.Element {
|
function renderEditHistoryMessagesModal(): JSX.Element {
|
||||||
return <SmartEditHistoryMessagesModal />;
|
return <SmartEditHistoryMessagesModal />;
|
||||||
|
@ -90,6 +95,7 @@ export const SmartGlobalModalContainer = memo(
|
||||||
const {
|
const {
|
||||||
aboutContactModalContactId,
|
aboutContactModalContactId,
|
||||||
addUserToAnotherGroupModalContactId,
|
addUserToAnotherGroupModalContactId,
|
||||||
|
callLinkEditModalRoomId,
|
||||||
contactModalState,
|
contactModalState,
|
||||||
deleteMessagesProps,
|
deleteMessagesProps,
|
||||||
editHistoryMessages,
|
editHistoryMessages,
|
||||||
|
@ -168,6 +174,7 @@ export const SmartGlobalModalContainer = memo(
|
||||||
addUserToAnotherGroupModalContactId={
|
addUserToAnotherGroupModalContactId={
|
||||||
addUserToAnotherGroupModalContactId
|
addUserToAnotherGroupModalContactId
|
||||||
}
|
}
|
||||||
|
callLinkEditModalRoomId={callLinkEditModalRoomId}
|
||||||
contactModalState={contactModalState}
|
contactModalState={contactModalState}
|
||||||
editHistoryMessages={editHistoryMessages}
|
editHistoryMessages={editHistoryMessages}
|
||||||
editNicknameAndNoteModalProps={editNicknameAndNoteModalProps}
|
editNicknameAndNoteModalProps={editNicknameAndNoteModalProps}
|
||||||
|
@ -190,6 +197,7 @@ export const SmartGlobalModalContainer = memo(
|
||||||
isWhatsNewVisible={isWhatsNewVisible}
|
isWhatsNewVisible={isWhatsNewVisible}
|
||||||
renderAboutContactModal={renderAboutContactModal}
|
renderAboutContactModal={renderAboutContactModal}
|
||||||
renderAddUserToAnotherGroup={renderAddUserToAnotherGroup}
|
renderAddUserToAnotherGroup={renderAddUserToAnotherGroup}
|
||||||
|
renderCallLinkEditModal={renderCallLinkEditModal}
|
||||||
renderContactModal={renderContactModal}
|
renderContactModal={renderContactModal}
|
||||||
renderEditHistoryMessagesModal={renderEditHistoryMessagesModal}
|
renderEditHistoryMessagesModal={renderEditHistoryMessagesModal}
|
||||||
renderEditNicknameAndNoteModal={renderEditNicknameAndNoteModal}
|
renderEditNicknameAndNoteModal={renderEditNicknameAndNoteModal}
|
||||||
|
|
|
@ -645,6 +645,7 @@ export class Bootstrap {
|
||||||
storageProfile: 'mock',
|
storageProfile: 'mock',
|
||||||
serverUrl: url,
|
serverUrl: url,
|
||||||
storageUrl: url,
|
storageUrl: url,
|
||||||
|
sfuUrl: url,
|
||||||
cdn: {
|
cdn: {
|
||||||
'0': url,
|
'0': url,
|
||||||
'2': url,
|
'2': url,
|
||||||
|
|
64
ts/test-mock/calling/callLinkAdmin_test.ts
Normal file
64
ts/test-mock/calling/callLinkAdmin_test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
|
@ -570,6 +570,7 @@ const URL_CALLS = {
|
||||||
backupMedia: 'v1/archives/media',
|
backupMedia: 'v1/archives/media',
|
||||||
backupMediaBatch: 'v1/archives/media/batch',
|
backupMediaBatch: 'v1/archives/media/batch',
|
||||||
backupMediaDelete: 'v1/archives/media/delete',
|
backupMediaDelete: 'v1/archives/media/delete',
|
||||||
|
callLinkCreateAuth: 'v1/call-link/create-auth',
|
||||||
registration: 'v1/registration',
|
registration: 'v1/registration',
|
||||||
registerCapabilities: 'v1/devices/capabilities',
|
registerCapabilities: 'v1/devices/capabilities',
|
||||||
reportMessage: 'v1/messages/report',
|
reportMessage: 'v1/messages/report',
|
||||||
|
@ -666,7 +667,7 @@ type AjaxOptionsType = {
|
||||||
basicAuth?: string;
|
basicAuth?: string;
|
||||||
call: keyof typeof URL_CALLS;
|
call: keyof typeof URL_CALLS;
|
||||||
contentType?: string;
|
contentType?: string;
|
||||||
data?: Uint8Array | Buffer | Uint8Array | string;
|
data?: Buffer | Uint8Array | string;
|
||||||
disableSessionResumption?: boolean;
|
disableSessionResumption?: boolean;
|
||||||
headers?: HeaderListType;
|
headers?: HeaderListType;
|
||||||
host?: string;
|
host?: string;
|
||||||
|
@ -1148,6 +1149,14 @@ export type GetBackupInfoResponseType = z.infer<
|
||||||
typeof getBackupInfoResponseSchema
|
typeof getBackupInfoResponseSchema
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
export type CallLinkCreateAuthResponseType = Readonly<{
|
||||||
|
credential: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export const callLinkCreateAuthResponseSchema = z.object({
|
||||||
|
credential: z.string(),
|
||||||
|
}) satisfies z.ZodSchema<CallLinkCreateAuthResponseType>;
|
||||||
|
|
||||||
const StickerPackUploadAttributesSchema = z.object({
|
const StickerPackUploadAttributesSchema = z.object({
|
||||||
acl: z.string(),
|
acl: z.string(),
|
||||||
algorithm: z.string(),
|
algorithm: z.string(),
|
||||||
|
@ -1384,6 +1393,9 @@ export type WebAPIType = {
|
||||||
options: BackupListMediaOptionsType
|
options: BackupListMediaOptionsType
|
||||||
) => Promise<BackupListMediaResponseType>;
|
) => Promise<BackupListMediaResponseType>;
|
||||||
backupDeleteMedia: (options: BackupDeleteMediaOptionsType) => Promise<void>;
|
backupDeleteMedia: (options: BackupDeleteMediaOptionsType) => Promise<void>;
|
||||||
|
callLinkCreateAuth: (
|
||||||
|
requestBase64: string
|
||||||
|
) => Promise<CallLinkCreateAuthResponseType>;
|
||||||
setPhoneNumberDiscoverability: (newValue: boolean) => Promise<void>;
|
setPhoneNumberDiscoverability: (newValue: boolean) => Promise<void>;
|
||||||
updateDeviceName: (deviceName: string) => Promise<void>;
|
updateDeviceName: (deviceName: string) => Promise<void>;
|
||||||
uploadAvatar: (
|
uploadAvatar: (
|
||||||
|
@ -1666,6 +1678,7 @@ export function initialize({
|
||||||
checkAccountExistence,
|
checkAccountExistence,
|
||||||
checkSockets,
|
checkSockets,
|
||||||
createAccount,
|
createAccount,
|
||||||
|
callLinkCreateAuth,
|
||||||
createFetchForAttachmentUpload,
|
createFetchForAttachmentUpload,
|
||||||
confirmUsername,
|
confirmUsername,
|
||||||
createGroup,
|
createGroup,
|
||||||
|
@ -2959,6 +2972,18 @@ export function initialize({
|
||||||
return backupListMediaResponseSchema.parse(res);
|
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) {
|
async function setPhoneNumberDiscoverability(newValue: boolean) {
|
||||||
await _ajax({
|
await _ajax({
|
||||||
call: 'phoneNumberDiscoverability',
|
call: 'phoneNumberDiscoverability',
|
||||||
|
|
|
@ -3,6 +3,7 @@
|
||||||
import type { ReadonlyDeep } from 'type-fest';
|
import type { ReadonlyDeep } from 'type-fest';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import type { ConversationType } from '../state/ducks/conversations';
|
import type { ConversationType } from '../state/ducks/conversations';
|
||||||
|
import { safeParseInteger } from '../util/numbers';
|
||||||
|
|
||||||
export enum CallLinkUpdateSyncType {
|
export enum CallLinkUpdateSyncType {
|
||||||
Update = 'Update',
|
Update = 'Update',
|
||||||
|
@ -28,9 +29,9 @@ export enum CallLinkRestrictions {
|
||||||
export const callLinkRestrictionsSchema = z.nativeEnum(CallLinkRestrictions);
|
export const callLinkRestrictionsSchema = z.nativeEnum(CallLinkRestrictions);
|
||||||
|
|
||||||
export function toCallLinkRestrictions(
|
export function toCallLinkRestrictions(
|
||||||
restrictions: number
|
restrictions: number | string
|
||||||
): CallLinkRestrictions {
|
): CallLinkRestrictions {
|
||||||
return callLinkRestrictionsSchema.parse(restrictions);
|
return callLinkRestrictionsSchema.parse(safeParseInteger(restrictions));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -1,7 +1,13 @@
|
||||||
// Copyright 2024 Signal Messenger, LLC
|
// Copyright 2024 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// 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 { Aci } from '@signalapp/libsignal-client';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import * as RemoteConfig from '../RemoteConfig';
|
||||||
import type { CallLinkAuthCredentialPresentation } from './zkgroup';
|
import type { CallLinkAuthCredentialPresentation } from './zkgroup';
|
||||||
import {
|
import {
|
||||||
CallLinkAuthCredential,
|
CallLinkAuthCredential,
|
||||||
|
@ -15,6 +21,7 @@ import type {
|
||||||
CallLinkConversationType,
|
CallLinkConversationType,
|
||||||
CallLinkType,
|
CallLinkType,
|
||||||
CallLinkRecord,
|
CallLinkRecord,
|
||||||
|
CallLinkStateType,
|
||||||
} from '../types/CallLink';
|
} from '../types/CallLink';
|
||||||
import {
|
import {
|
||||||
callLinkRecordSchema,
|
callLinkRecordSchema,
|
||||||
|
@ -22,6 +29,7 @@ import {
|
||||||
toCallLinkRestrictions,
|
toCallLinkRestrictions,
|
||||||
} from '../types/CallLink';
|
} from '../types/CallLink';
|
||||||
import type { LocalizerType } from '../types/Util';
|
import type { LocalizerType } from '../types/Util';
|
||||||
|
import { isTestOrMockEnvironment } from '../environment';
|
||||||
|
|
||||||
export const CALL_LINK_DEFAULT_STATE = {
|
export const CALL_LINK_DEFAULT_STATE = {
|
||||||
name: '',
|
name: '',
|
||||||
|
@ -30,6 +38,13 @@ export const CALL_LINK_DEFAULT_STATE = {
|
||||||
expiration: null,
|
expiration: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function isCallLinksCreateEnabled(): boolean {
|
||||||
|
if (isTestOrMockEnvironment()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return RemoteConfig.getValue('desktop.calling.adhoc.create') === 'TRUE';
|
||||||
|
}
|
||||||
|
|
||||||
export function getRoomIdFromRootKey(rootKey: CallLinkRootKey): string {
|
export function getRoomIdFromRootKey(rootKey: CallLinkRootKey): string {
|
||||||
return rootKey.deriveRoomId().toString('hex');
|
return rootKey.deriveRoomId().toString('hex');
|
||||||
}
|
}
|
||||||
|
@ -112,14 +127,39 @@ export function fromRootKeyBytes(rootKey: Uint8Array): string {
|
||||||
return CallLinkRootKey.fromBytes(rootKey as Buffer).toString();
|
return CallLinkRootKey.fromBytes(rootKey as Buffer).toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toAdminKeyBytes(adminKey: string): Uint8Array {
|
export function toAdminKeyBytes(adminKey: string): Buffer {
|
||||||
return Bytes.fromBase64(adminKey);
|
return Buffer.from(adminKey, 'base64');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fromAdminKeyBytes(adminKey: Uint8Array): string {
|
export function fromAdminKeyBytes(adminKey: Uint8Array): string {
|
||||||
return Bytes.toBase64(adminKey);
|
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
|
* DB record conversions
|
||||||
*/
|
*/
|
||||||
|
|
59
ts/util/numbers.ts
Normal file
59
ts/util/numbers.ts
Normal 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);
|
||||||
|
}
|
|
@ -37,3 +37,7 @@ export function urlPathFromComponents(
|
||||||
): string {
|
): string {
|
||||||
return `/${components.filter(Boolean).map(encodeURIComponent).join('/')}`;
|
return `/${components.filter(Boolean).map(encodeURIComponent).join('/')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatUrlWithoutProtocol(url: Readonly<URL>): string {
|
||||||
|
return `${url.hostname}${url.pathname}${url.search}${url.hash}`;
|
||||||
|
}
|
||||||
|
|
49
yarn.lock
49
yarn.lock
|
@ -4011,10 +4011,10 @@
|
||||||
type-fest "^3.5.0"
|
type-fest "^3.5.0"
|
||||||
uuid "^8.3.0"
|
uuid "^8.3.0"
|
||||||
|
|
||||||
"@signalapp/mock-server@6.5.0":
|
"@signalapp/mock-server@6.6.0":
|
||||||
version "6.5.0"
|
version "6.6.0"
|
||||||
resolved "https://registry.yarnpkg.com/@signalapp/mock-server/-/mock-server-6.5.0.tgz#0fa420ff2d7386770b3c8dfe6f57be425816a130"
|
resolved "https://registry.yarnpkg.com/@signalapp/mock-server/-/mock-server-6.6.0.tgz#9abce9c7e634ef4e3a316cdf165965500010bd92"
|
||||||
integrity sha512-QuEYX9EFFaPIvQzGlHkgfrnpnhs+Q8jOwk2UuE+5txJNXezrQnq1nRFChG+M/XAv0aSbc7thiq8iBxwpN2F2EA==
|
integrity sha512-zeLz9YikLaCQfWgSy2XDeEMdLWUTpyGOteSucD1BLcmv54J2eysk7ppDJns3H13opmF4Qj1Xmy5VftG3V3QKow==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@signalapp/libsignal-client" "^0.45.0"
|
"@signalapp/libsignal-client" "^0.45.0"
|
||||||
debug "^4.3.2"
|
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"
|
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001541.tgz"
|
||||||
integrity sha512-bLOsqxDgTqUBkzxbNlSBt8annkDpQB9NdzdTbO2ooJ+eC/IQcvDspDc058g84ejCelF7vHUx57KIOjEecOHXaw==
|
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"
|
version "1.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/nop/-/nop-1.0.0.tgz#cb46cf7e01574aa6390858149f66897afe53c9ca"
|
resolved "https://registry.yarnpkg.com/nop/-/nop-1.0.0.tgz#cb46cf7e01574aa6390858149f66897afe53c9ca"
|
||||||
|
|
||||||
|
@ -9028,10 +9028,6 @@ dmg-builder@24.6.3:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
dmg-license "^1.0.11"
|
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:
|
dns-equal@^1.0.0:
|
||||||
version "1.0.0"
|
version "1.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/dns-equal/-/dns-equal-1.0.0.tgz#b39e7f1da6eb0a75ba9c17324b34753c47e0654d"
|
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"
|
strip-json-comments "^3.1.0"
|
||||||
underscore "~1.13.2"
|
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:
|
jsesc@^1.3.0:
|
||||||
version "1.3.0"
|
version "1.3.0"
|
||||||
resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-1.3.0.tgz#46c3fec8c1892b12b0833db9bc7622176dbab34b"
|
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"
|
char-regex "^2.0.0"
|
||||||
strip-ansi "^7.0.1"
|
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"
|
version "4.2.3"
|
||||||
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
|
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
|
||||||
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
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"
|
is-fullwidth-code-point "^3.0.0"
|
||||||
strip-ansi "^6.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:
|
string-width@^5.0.1, string-width@^5.1.2:
|
||||||
version "5.1.2"
|
version "5.1.2"
|
||||||
resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794"
|
resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794"
|
||||||
|
@ -18860,7 +18843,7 @@ string_decoder@~1.1.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
safe-buffer "~5.1.0"
|
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"
|
version "6.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
|
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
|
||||||
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
||||||
|
@ -18895,13 +18878,6 @@ strip-ansi@^6.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
ansi-regex "^5.0.0"
|
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:
|
strip-ansi@^7.0.1:
|
||||||
version "7.1.0"
|
version "7.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45"
|
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"
|
resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.2.1.tgz#46fc150c17d826b86a008e5a4508656777e9c343"
|
||||||
integrity sha512-ILEIE97kDZvF9Wb9f6h5aXK4swSlKGUcOEGiIYb2OOu/IrDU9iwj0fD//SsA6E5ibwJxpEvhullJY4Sl4GcpAw==
|
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"
|
version "7.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
|
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
|
||||||
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
|
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
|
||||||
|
@ -20588,15 +20564,6 @@ wrap-ansi@^6.2.0:
|
||||||
string-width "^4.1.0"
|
string-width "^4.1.0"
|
||||||
strip-ansi "^6.0.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:
|
wrap-ansi@^8.1.0:
|
||||||
version "8.1.0"
|
version "8.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"
|
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"
|
||||||
|
|
Loading…
Reference in a new issue