Add editing to call details pane
This commit is contained in:
parent
95209689a8
commit
cee2788654
22 changed files with 330 additions and 118 deletions
|
@ -7289,6 +7289,18 @@
|
|||
"messageformat": "Join",
|
||||
"description": "Call History > Call Link Details > Join Button"
|
||||
},
|
||||
"icu:CallLinkDetails__AddCallNameLabel": {
|
||||
"messageformat": "Add call name",
|
||||
"description": "Call History > Call Link Details > Add Call Name Button > Label"
|
||||
},
|
||||
"icu:CallLinkDetails__EditCallNameLabel": {
|
||||
"messageformat": "Edit call name",
|
||||
"description": "Call History > Call Link Details > Edit Call Name Button > Label"
|
||||
},
|
||||
"icu:CallLinkDetails__ApproveAllMembersLabel": {
|
||||
"messageformat": "Approve all members",
|
||||
"description": "Call History > Call Link Details > Approve All Members > Label"
|
||||
},
|
||||
"icu:CallLinkDetails__CopyLink": {
|
||||
"messageformat": "Copy link",
|
||||
"description": "Call History > Call Link Details > Copy Link Button"
|
||||
|
@ -7309,15 +7321,19 @@
|
|||
"messageformat": "Add call name",
|
||||
"description": "Call Link Edit Modal > Add Call Name Button > Label"
|
||||
},
|
||||
"icu:CallLinkEditModal__EditCallNameLabel": {
|
||||
"messageformat": "Edit call name",
|
||||
"description": "Call Link Edit Modal > Edit Call Name 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": {
|
||||
"icu:CallLinkRestrictionsSelect__Option--Off": {
|
||||
"messageformat": "Off",
|
||||
"description": "Call Link Edit Modal > Approve All Members Checkbox > Option > Off"
|
||||
},
|
||||
"icu:CallLinkEditModal__ApproveAllMembers__Option--On": {
|
||||
"icu:CallLinkRestrictionsSelect__Option--On": {
|
||||
"messageformat": "On",
|
||||
"description": "Call Link Edit Modal > Approve All Members Checkbox > Option > On"
|
||||
},
|
||||
|
@ -7325,6 +7341,10 @@
|
|||
"messageformat": "Add call name",
|
||||
"description": "Call Link Add Name Modal > Title"
|
||||
},
|
||||
"icu:CallLinkAddNameModal__Title--Edit": {
|
||||
"messageformat": "Edit call name",
|
||||
"description": "Call Link Add Name Modal (When editing existing name) > Title"
|
||||
},
|
||||
"icu:CallLinkAddNameModal__NameLabel": {
|
||||
"messageformat": "Call name",
|
||||
"description": "Call Link Add Name Modal > Name Input > Label"
|
||||
|
|
|
@ -13,7 +13,7 @@ import { strictAssert } from '../ts/util/assert';
|
|||
import type { LoggerType } from '../ts/types/Logging';
|
||||
import { handleAttachmentRequest } from './attachment_channel';
|
||||
|
||||
export const FAKE_DEFAULT_LOCALE = 'en-x-ignore'; // -x- is an extension space for attaching other metadata to the locale
|
||||
export const FAKE_DEFAULT_LOCALE = 'und'; // 'und' is the BCP 47 subtag for "undetermined"
|
||||
|
||||
strictAssert(
|
||||
new Intl.Locale(FAKE_DEFAULT_LOCALE).toString() === FAKE_DEFAULT_LOCALE,
|
||||
|
|
|
@ -160,8 +160,3 @@
|
|||
height: 1px;
|
||||
background: $color-black-alpha-12;
|
||||
}
|
||||
|
||||
// Overriding default style
|
||||
.CallLinkEditModal__RowSelect.module-select select {
|
||||
min-width: 0;
|
||||
}
|
||||
|
|
7
stylesheets/components/CallLinkRestrictionsSelect.scss
Normal file
7
stylesheets/components/CallLinkRestrictionsSelect.scss
Normal file
|
@ -0,0 +1,7 @@
|
|||
// Copyright 2024 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
// Overriding default style
|
||||
.CallLinkRestrictionsSelect.module-select select {
|
||||
min-width: 0;
|
||||
}
|
|
@ -153,6 +153,14 @@
|
|||
}
|
||||
}
|
||||
|
||||
&--approveAllMembers {
|
||||
&::after {
|
||||
@include details-icon(
|
||||
'../images/icons/v3/person/person-check-compact.svg'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
&--link {
|
||||
&::after {
|
||||
@include details-icon('../images/icons/v3/link/link.svg');
|
||||
|
|
|
@ -53,6 +53,7 @@
|
|||
@import './components/CallLinkAddNameModal.scss';
|
||||
@import './components/CallLinkDetails.scss';
|
||||
@import './components/CallLinkEditModal.scss';
|
||||
@import './components/CallLinkRestrictionsSelect.scss';
|
||||
@import './components/CallingRaisedHandsList.scss';
|
||||
@import './components/CallingRaisedHandsToasts.scss';
|
||||
@import './components/CallingReactionsToasts.scss';
|
||||
|
|
|
@ -1,13 +1,16 @@
|
|||
// Copyright 2024 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { v4 as generateUuid } from 'uuid';
|
||||
import { Modal } from './Modal';
|
||||
import type { LocalizerType } from '../types/I18N';
|
||||
import { Button, ButtonVariant } from './Button';
|
||||
import { Avatar, AvatarSize } from './Avatar';
|
||||
import { Input } from './Input';
|
||||
import type { CallLinkType } from '../types/CallLink';
|
||||
import {
|
||||
CallLinkNameMaxByteLength,
|
||||
type CallLinkType,
|
||||
} from '../types/CallLink';
|
||||
import { getColorForCallLink } from '../util/getColorForCallLink';
|
||||
|
||||
export type CallLinkAddNameModalProps = Readonly<{
|
||||
|
@ -27,18 +30,25 @@ export function CallLinkAddNameModal({
|
|||
const [nameId] = useState(() => generateUuid());
|
||||
const [nameInput, setNameInput] = useState(callLink.name);
|
||||
|
||||
const parsedForm = useMemo(() => {
|
||||
const name = nameInput.trim();
|
||||
if (name === callLink.name) {
|
||||
return null;
|
||||
}
|
||||
return { name };
|
||||
}, [nameInput, callLink]);
|
||||
|
||||
const handleNameInputChange = useCallback((nextNameInput: string) => {
|
||||
setNameInput(nextNameInput);
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
const nameValue = nameInput.trim();
|
||||
if (nameValue === callLink.name) {
|
||||
if (parsedForm == null) {
|
||||
return;
|
||||
}
|
||||
onUpdateCallLinkName(nameValue);
|
||||
onUpdateCallLinkName(parsedForm.name);
|
||||
onClose();
|
||||
}, [nameInput, callLink, onUpdateCallLinkName, onClose]);
|
||||
}, [parsedForm, onUpdateCallLinkName, onClose]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
|
@ -47,7 +57,11 @@ export function CallLinkAddNameModal({
|
|||
hasXButton
|
||||
noEscapeClose
|
||||
noMouseClose
|
||||
title={i18n('icu:CallLinkAddNameModal__Title')}
|
||||
title={
|
||||
callLink.name === ''
|
||||
? i18n('icu:CallLinkAddNameModal__Title')
|
||||
: i18n('icu:CallLinkAddNameModal__Title--Edit')
|
||||
}
|
||||
onClose={onClose}
|
||||
moduleClassName="CallLinkAddNameModal"
|
||||
modalFooter={
|
||||
|
@ -55,7 +69,12 @@ export function CallLinkAddNameModal({
|
|||
<Button onClick={onClose} variant={ButtonVariant.Secondary}>
|
||||
{i18n('icu:cancel')}
|
||||
</Button>
|
||||
<Button type="submit" form={formId} variant={ButtonVariant.Primary}>
|
||||
<Button
|
||||
type="submit"
|
||||
form={formId}
|
||||
variant={ButtonVariant.Primary}
|
||||
aria-disabled={parsedForm == null}
|
||||
>
|
||||
{i18n('icu:save')}
|
||||
</Button>
|
||||
</>
|
||||
|
@ -93,6 +112,7 @@ export function CallLinkAddNameModal({
|
|||
autoFocus
|
||||
onChange={handleNameInputChange}
|
||||
moduleClassName="CallLinkAddNameModal__Input"
|
||||
maxByteCount={CallLinkNameMaxByteLength}
|
||||
/>
|
||||
</form>
|
||||
</Modal>
|
||||
|
|
|
@ -10,13 +10,15 @@ import {
|
|||
IconType,
|
||||
} from './conversation/conversation-details/ConversationDetailsIcon';
|
||||
import { PanelRow } from './conversation/conversation-details/PanelRow';
|
||||
import type { CallLinkType } from '../types/CallLink';
|
||||
import type { CallLinkRestrictions, CallLinkType } from '../types/CallLink';
|
||||
import { linkCallRoute } from '../util/signalRoutes';
|
||||
import { drop } from '../util/drop';
|
||||
import { Avatar, AvatarSize } from './Avatar';
|
||||
import { Button, ButtonSize, ButtonVariant } from './Button';
|
||||
import { copyCallLink } from '../util/copyLinksWithToast';
|
||||
import { getColorForCallLink } from '../util/getColorForCallLink';
|
||||
import { isCallLinkAdmin } from '../util/callLinks';
|
||||
import { CallLinkRestrictionsSelect } from './CallLinkRestrictionsSelect';
|
||||
|
||||
function toUrlWithoutProtocol(url: URL): string {
|
||||
return `${url.hostname}${url.pathname}${url.search}${url.hash}`;
|
||||
|
@ -26,16 +28,20 @@ export type CallLinkDetailsProps = Readonly<{
|
|||
callHistoryGroup: CallHistoryGroup;
|
||||
callLink: CallLinkType;
|
||||
i18n: LocalizerType;
|
||||
onOpenCallLinkAddNameModal: () => void;
|
||||
onStartCallLinkLobby: () => void;
|
||||
onShareCallLinkViaSignal: () => void;
|
||||
onUpdateCallLinkRestrictions: (restrictions: CallLinkRestrictions) => void;
|
||||
}>;
|
||||
|
||||
export function CallLinkDetails({
|
||||
callHistoryGroup,
|
||||
callLink,
|
||||
i18n,
|
||||
onOpenCallLinkAddNameModal,
|
||||
onStartCallLinkLobby,
|
||||
onShareCallLinkViaSignal,
|
||||
onUpdateCallLinkRestrictions,
|
||||
}: CallLinkDetailsProps): JSX.Element {
|
||||
const webUrl = linkCallRoute.toWebUrl({
|
||||
key: callLink.rootKey,
|
||||
|
@ -80,6 +86,40 @@ export function CallLinkDetails({
|
|||
callHistoryGroup={callHistoryGroup}
|
||||
i18n={i18n}
|
||||
/>
|
||||
{isCallLinkAdmin(callLink) && (
|
||||
<PanelSection>
|
||||
<PanelRow
|
||||
icon={
|
||||
<ConversationDetailsIcon
|
||||
ariaLabel={i18n('icu:CallLinkDetails__AddCallNameLabel')}
|
||||
icon={IconType.edit}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
callLink.name === ''
|
||||
? i18n('icu:CallLinkDetails__AddCallNameLabel')
|
||||
: i18n('icu:CallLinkDetails__EditCallNameLabel')
|
||||
}
|
||||
onClick={onOpenCallLinkAddNameModal}
|
||||
/>
|
||||
<PanelRow
|
||||
icon={
|
||||
<ConversationDetailsIcon
|
||||
ariaLabel={i18n('icu:CallLinkDetails__ApproveAllMembersLabel')}
|
||||
icon={IconType.approveAllMembers}
|
||||
/>
|
||||
}
|
||||
label={i18n('icu:CallLinkDetails__ApproveAllMembersLabel')}
|
||||
right={
|
||||
<CallLinkRestrictionsSelect
|
||||
i18n={i18n}
|
||||
value={callLink.restrictions}
|
||||
onChange={onUpdateCallLinkRestrictions}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</PanelSection>
|
||||
)}
|
||||
<PanelSection>
|
||||
<PanelRow
|
||||
icon={
|
||||
|
|
|
@ -6,16 +6,13 @@ import React, { 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 { Select } from './Select';
|
||||
import type { CallLinkRestrictions } from '../types/CallLink';
|
||||
import { type CallLinkType } from '../types/CallLink';
|
||||
import { linkCallRoute } from '../util/signalRoutes';
|
||||
import { Button, ButtonSize, ButtonVariant } from './Button';
|
||||
import { Avatar, AvatarSize } from './Avatar';
|
||||
import { getColorForCallLink } from '../util/getColorForCallLink';
|
||||
import { CallLinkRestrictionsSelect } from './CallLinkRestrictionsSelect';
|
||||
|
||||
const CallLinkEditModalRowIconClasses = {
|
||||
Edit: 'CallLinkEditModal__RowIcon--Edit',
|
||||
|
@ -160,7 +157,11 @@ export function CallLinkEditModal({
|
|||
<RowButton onClick={onOpenCallLinkAddNameModal}>
|
||||
<Row>
|
||||
<RowIcon icon="Edit" />
|
||||
<RowText>{i18n('icu:CallLinkEditModal__AddCallNameLabel')}</RowText>
|
||||
<RowText>
|
||||
{callLink.name === ''
|
||||
? i18n('icu:CallLinkEditModal__AddCallNameLabel')
|
||||
: i18n('icu:CallLinkEditModal__EditCallNameLabel')}
|
||||
</RowText>
|
||||
</Row>
|
||||
</RowButton>
|
||||
|
||||
|
@ -171,27 +172,11 @@ export function CallLinkEditModal({
|
|||
{i18n('icu:CallLinkEditModal__InputLabel--ApproveAllMembers')}
|
||||
</label>
|
||||
</RowText>
|
||||
<Select
|
||||
<CallLinkRestrictionsSelect
|
||||
i18n={i18n}
|
||||
id={restrictionsId}
|
||||
value={String(callLink.restrictions)}
|
||||
moduleClassName="CallLinkEditModal__RowSelect"
|
||||
options={[
|
||||
{
|
||||
value: String(CallLinkRestrictions.None),
|
||||
text: i18n(
|
||||
'icu:CallLinkEditModal__ApproveAllMembers__Option--Off'
|
||||
),
|
||||
},
|
||||
{
|
||||
value: String(CallLinkRestrictions.AdminApproval),
|
||||
text: i18n(
|
||||
'icu:CallLinkEditModal__ApproveAllMembers__Option--On'
|
||||
),
|
||||
},
|
||||
]}
|
||||
onChange={value => {
|
||||
onUpdateCallLinkRestrictions(toCallLinkRestrictions(value));
|
||||
}}
|
||||
value={callLink.restrictions}
|
||||
onChange={onUpdateCallLinkRestrictions}
|
||||
/>
|
||||
</Row>
|
||||
|
||||
|
|
44
ts/components/CallLinkRestrictionsSelect.tsx
Normal file
44
ts/components/CallLinkRestrictionsSelect.tsx
Normal file
|
@ -0,0 +1,44 @@
|
|||
// Copyright 2024 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
import React from 'react';
|
||||
import {
|
||||
CallLinkRestrictions,
|
||||
toCallLinkRestrictions,
|
||||
} from '../types/CallLink';
|
||||
import type { LocalizerType } from '../types/I18N';
|
||||
import { Select } from './Select';
|
||||
|
||||
export type CallLinkRestrictionsSelectProps = Readonly<{
|
||||
i18n: LocalizerType;
|
||||
id?: string;
|
||||
value: CallLinkRestrictions;
|
||||
onChange: (value: CallLinkRestrictions) => void;
|
||||
}>;
|
||||
|
||||
export function CallLinkRestrictionsSelect({
|
||||
i18n,
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
}: CallLinkRestrictionsSelectProps): JSX.Element {
|
||||
return (
|
||||
<Select
|
||||
id={id}
|
||||
value={String(value)}
|
||||
moduleClassName="CallLinkRestrictionsSelect"
|
||||
options={[
|
||||
{
|
||||
value: String(CallLinkRestrictions.None),
|
||||
text: i18n('icu:CallLinkRestrictionsSelect__Option--Off'),
|
||||
},
|
||||
{
|
||||
value: String(CallLinkRestrictions.AdminApproval),
|
||||
text: i18n('icu:CallLinkRestrictionsSelect__Option--On'),
|
||||
},
|
||||
]}
|
||||
onChange={nextValue => {
|
||||
onChange(toCallLinkRestrictions(nextValue));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -8,6 +8,7 @@ import { Spinner } from '../../Spinner';
|
|||
import { bemGenerator } from './util';
|
||||
|
||||
export enum IconType {
|
||||
'approveAllMembers' = 'approveAllMembers',
|
||||
'block' = 'block',
|
||||
'edit' = 'edit',
|
||||
'unblock' = 'unblock',
|
||||
|
|
|
@ -583,9 +583,8 @@ async function getCallLinkPreview(
|
|||
}
|
||||
|
||||
const callLinkRootKey = CallLinkRootKey.parse(parsedUrl.args.key);
|
||||
const readResult = await calling.readCallLink({ callLinkRootKey });
|
||||
const { callLinkState } = readResult;
|
||||
if (!callLinkState || callLinkState.revoked) {
|
||||
const callLinkState = await calling.readCallLink(callLinkRootKey);
|
||||
if (callLinkState == null || callLinkState.revoked) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
|
@ -6,7 +6,6 @@ import { ipcRenderer } from 'electron';
|
|||
import type {
|
||||
AudioDevice,
|
||||
CallId,
|
||||
CallLinkState as RingRTCCallLinkState,
|
||||
DeviceId,
|
||||
GroupCallObserver,
|
||||
PeekInfo,
|
||||
|
@ -151,11 +150,7 @@ import {
|
|||
conversationJobQueue,
|
||||
conversationQueueJobEnum,
|
||||
} from '../jobs/conversationJobQueue';
|
||||
import type {
|
||||
CallLinkType,
|
||||
CallLinkStateType,
|
||||
ReadCallLinkState,
|
||||
} from '../types/CallLink';
|
||||
import type { CallLinkType, CallLinkStateType } from '../types/CallLink';
|
||||
import { CallLinkRestrictions } from '../types/CallLink';
|
||||
import { getConversationIdForLogging } from '../util/idForLogging';
|
||||
import { sendCallLinkUpdateSync } from '../util/sendCallLinkUpdateSync';
|
||||
|
@ -767,20 +762,9 @@ export class CallingClass {
|
|||
return callLinkStateFromRingRTC(result.value);
|
||||
}
|
||||
|
||||
async readCallLink({
|
||||
callLinkRootKey,
|
||||
}: Readonly<{
|
||||
callLinkRootKey: CallLinkRootKey;
|
||||
}>): Promise<
|
||||
| {
|
||||
callLinkState: ReadCallLinkState;
|
||||
errorStatusCode: undefined;
|
||||
}
|
||||
| {
|
||||
callLinkState: undefined;
|
||||
errorStatusCode: number;
|
||||
}
|
||||
> {
|
||||
async readCallLink(
|
||||
callLinkRootKey: CallLinkRootKey
|
||||
): Promise<CallLinkStateType | null> {
|
||||
if (!this._sfuUrl) {
|
||||
throw new Error('readCallLink() missing SFU URL; not handling call link');
|
||||
}
|
||||
|
@ -796,18 +780,15 @@ export class CallingClass {
|
|||
callLinkRootKey
|
||||
);
|
||||
if (!result.success) {
|
||||
log.warn(`${logId}: failed`);
|
||||
return {
|
||||
callLinkState: undefined,
|
||||
errorStatusCode: result.errorStatusCode,
|
||||
};
|
||||
log.warn(`${logId}: failed with status ${result.errorStatusCode}`);
|
||||
if (result.errorStatusCode === 404) {
|
||||
return null;
|
||||
}
|
||||
throw new Error(`Failed to read call link: ${result.errorStatusCode}`);
|
||||
}
|
||||
|
||||
log.info(`${logId}: success`);
|
||||
return {
|
||||
callLinkState: this.formatCallLinkStateForRedux(result.value),
|
||||
errorStatusCode: undefined,
|
||||
};
|
||||
return callLinkStateFromRingRTC(result.value);
|
||||
}
|
||||
|
||||
async startCallLinkLobby({
|
||||
|
@ -1754,18 +1735,6 @@ export class CallingClass {
|
|||
};
|
||||
}
|
||||
|
||||
public formatCallLinkStateForRedux(
|
||||
callLinkState: RingRTCCallLinkState
|
||||
): ReadCallLinkState {
|
||||
const { name, restrictions, expiration, revoked } = callLinkState;
|
||||
return {
|
||||
name,
|
||||
restrictions,
|
||||
expiration: expiration.getTime(),
|
||||
revoked,
|
||||
};
|
||||
}
|
||||
|
||||
public getGroupCallVideoFrameSource(
|
||||
conversationId: string,
|
||||
demuxId: number
|
||||
|
|
|
@ -1407,18 +1407,15 @@ function handleCallLinkUpdate(
|
|||
const roomId = getRoomIdFromRootKey(callLinkRootKey);
|
||||
const logId = `handleCallLinkUpdate(${roomId})`;
|
||||
|
||||
const readResult = await calling.readCallLink({
|
||||
callLinkRootKey,
|
||||
});
|
||||
const freshCallLinkState = await calling.readCallLink(callLinkRootKey);
|
||||
|
||||
// Only give up when server confirms the call link is gone. If we fail to fetch
|
||||
// state due to unexpected errors, continue to save rootKey and adminKey.
|
||||
if (readResult.errorStatusCode === 404) {
|
||||
if (freshCallLinkState == null) {
|
||||
log.info(`${logId}: Call link not found, ignoring`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { callLinkState: freshCallLinkState } = readResult;
|
||||
const existingCallLink = await DataReader.getCallLinkByRoomId(roomId);
|
||||
const existingCallLinkState = pick(existingCallLink, [
|
||||
'name',
|
||||
|
@ -2070,9 +2067,17 @@ const _startCallLinkLobby = async ({
|
|||
return;
|
||||
}
|
||||
|
||||
const readResult = await calling.readCallLink({ callLinkRootKey });
|
||||
const { callLinkState } = readResult;
|
||||
if (!callLinkState) {
|
||||
let callLinkState: CallLinkStateType | null = null;
|
||||
try {
|
||||
callLinkState = await calling.readCallLink(callLinkRootKey);
|
||||
} catch (error) {
|
||||
log.error(
|
||||
'startCallLinkLobby: Error fetching call link state',
|
||||
Errors.toLogFormat(error)
|
||||
);
|
||||
}
|
||||
|
||||
if (callLinkState == null) {
|
||||
const i18n = getIntl(getState());
|
||||
dispatch({
|
||||
type: SHOW_ERROR_MODAL,
|
||||
|
@ -2086,6 +2091,7 @@ const _startCallLinkLobby = async ({
|
|||
}
|
||||
if (
|
||||
callLinkState.revoked ||
|
||||
callLinkState.expiration == null ||
|
||||
callLinkState.expiration < new Date().getTime()
|
||||
) {
|
||||
const i18n = getIntl(getState());
|
||||
|
|
|
@ -9,7 +9,10 @@ import { getIntl } from '../selectors/user';
|
|||
import { useGlobalModalActions } from '../ducks/globalModals';
|
||||
import { getCallLinkAddNameModalRoomId } from '../selectors/globalModals';
|
||||
import { strictAssert } from '../../util/assert';
|
||||
import { isCallLinksCreateEnabled } from '../../util/callLinks';
|
||||
import {
|
||||
isCallLinkAdmin,
|
||||
isCallLinksCreateEnabled,
|
||||
} from '../../util/callLinks';
|
||||
import { CallLinkAddNameModal } from '../../components/CallLinkAddNameModal';
|
||||
|
||||
export const SmartCallLinkAddNameModal = memo(
|
||||
|
@ -48,6 +51,8 @@ export const SmartCallLinkAddNameModal = memo(
|
|||
return null;
|
||||
}
|
||||
|
||||
strictAssert(isCallLinkAdmin(callLink), 'User is not an admin');
|
||||
|
||||
return (
|
||||
<CallLinkAddNameModal
|
||||
i18n={i18n}
|
||||
|
|
|
@ -10,6 +10,7 @@ import { useGlobalModalActions } from '../ducks/globalModals';
|
|||
import { useCallingActions } from '../ducks/calling';
|
||||
import * as log from '../../logging/log';
|
||||
import { strictAssert } from '../../util/assert';
|
||||
import type { CallLinkRestrictions } from '../../types/CallLink';
|
||||
|
||||
export type SmartCallLinkDetailsProps = Readonly<{
|
||||
roomId: string;
|
||||
|
@ -22,11 +23,17 @@ export const SmartCallLinkDetails = memo(function SmartCallLinkDetails({
|
|||
}: SmartCallLinkDetailsProps) {
|
||||
const i18n = useSelector(getIntl);
|
||||
const callLinkSelector = useSelector(getCallLinkSelector);
|
||||
const { startCallLinkLobby } = useCallingActions();
|
||||
const { showShareCallLinkViaSignal } = useGlobalModalActions();
|
||||
const { startCallLinkLobby, updateCallLinkRestrictions } =
|
||||
useCallingActions();
|
||||
const { toggleCallLinkAddNameModal, showShareCallLinkViaSignal } =
|
||||
useGlobalModalActions();
|
||||
|
||||
const callLink = callLinkSelector(roomId);
|
||||
|
||||
const handleOpenCallLinkAddNameModal = useCallback(() => {
|
||||
toggleCallLinkAddNameModal(roomId);
|
||||
}, [roomId, toggleCallLinkAddNameModal]);
|
||||
|
||||
const handleShareCallLinkViaSignal = useCallback(() => {
|
||||
strictAssert(callLink != null, 'callLink not found');
|
||||
showShareCallLinkViaSignal(callLink, i18n);
|
||||
|
@ -37,6 +44,13 @@ export const SmartCallLinkDetails = memo(function SmartCallLinkDetails({
|
|||
startCallLinkLobby({ rootKey: callLink.rootKey });
|
||||
}, [callLink, startCallLinkLobby]);
|
||||
|
||||
const handleUpdateCallLinkRestrictions = useCallback(
|
||||
(newRestrictions: CallLinkRestrictions) => {
|
||||
updateCallLinkRestrictions(roomId, newRestrictions);
|
||||
},
|
||||
[roomId, updateCallLinkRestrictions]
|
||||
);
|
||||
|
||||
if (callLink == null) {
|
||||
log.error(`SmartCallLinkDetails: callLink not found for room ${roomId}`);
|
||||
return null;
|
||||
|
@ -47,8 +61,10 @@ export const SmartCallLinkDetails = memo(function SmartCallLinkDetails({
|
|||
callHistoryGroup={callHistoryGroup}
|
||||
callLink={callLink}
|
||||
i18n={i18n}
|
||||
onOpenCallLinkAddNameModal={handleOpenCallLinkAddNameModal}
|
||||
onStartCallLinkLobby={handleStartCallLinkLobby}
|
||||
onShareCallLinkViaSignal={handleShareCallLinkViaSignal}
|
||||
onUpdateCallLinkRestrictions={handleUpdateCallLinkRestrictions}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
|
|
@ -1335,10 +1335,7 @@ describe('calling duck', () => {
|
|||
beforeEach(function (this: Mocha.Context) {
|
||||
this.callingServiceReadCallLink = this.sandbox
|
||||
.stub(callingService, 'readCallLink')
|
||||
.resolves({
|
||||
callLinkState: getCallLinkState(FAKE_CALL_LINK),
|
||||
errorStatusCode: undefined,
|
||||
});
|
||||
.resolves(getCallLinkState(FAKE_CALL_LINK));
|
||||
});
|
||||
|
||||
const doAction = async (
|
||||
|
@ -1423,10 +1420,7 @@ describe('calling duck', () => {
|
|||
beforeEach(function (this: Mocha.Context) {
|
||||
this.callingServiceReadCallLink = this.sandbox
|
||||
.stub(callingService, 'readCallLink')
|
||||
.resolves({
|
||||
callLinkState,
|
||||
errorStatusCode: undefined,
|
||||
});
|
||||
.resolves(callLinkState);
|
||||
this.callingServiceStartCallLinkLobby = this.sandbox
|
||||
.stub(callingService, 'startCallLinkLobby')
|
||||
.resolves(callLobbyData);
|
||||
|
|
39
ts/test-node/util/unicodeSlice_test.ts
Normal file
39
ts/test-node/util/unicodeSlice_test.ts
Normal file
|
@ -0,0 +1,39 @@
|
|||
// Copyright 2024 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import { unicodeSlice } from '../../util/unicodeSlice';
|
||||
import { byteLength } from '../../Bytes';
|
||||
|
||||
describe('unicodeSlice()', () => {
|
||||
function test(
|
||||
title: string,
|
||||
input: string,
|
||||
begin: number,
|
||||
end: number,
|
||||
expected: string,
|
||||
expectedSize: number
|
||||
): void {
|
||||
it(title, () => {
|
||||
const result = unicodeSlice(input, begin, end);
|
||||
assert.strictEqual(result, expected);
|
||||
assert.strictEqual(byteLength(result), expectedSize);
|
||||
});
|
||||
}
|
||||
|
||||
test('one-byte chars', '123456', 2, 4, '34', 2);
|
||||
test('past max length', '123456', 0, 100, '123456', 6);
|
||||
test('end before start', '123456', 5, 1, '', 0);
|
||||
test('negative start', '123456', -5, 4, '1234', 4);
|
||||
test('negative end', '123456', 0, -5, '', 0);
|
||||
test('end at start', '123456', 3, 3, '', 0);
|
||||
|
||||
test('multi-byte char', 'x€x', 1, 4, '€', 3);
|
||||
test('multi-byte char slice before end', '€', 1, 3, '', 0);
|
||||
test('multi-byte char slice after start', '€', 2, 4, '', 0);
|
||||
|
||||
test('emoji', 'x👩👩👧👦x', 1, 26, '👩👩👧👦', 25);
|
||||
test('emoji slice before end', 'x👩👩👧👦x', 1, 25, '', 0);
|
||||
test('emoji slice after start', 'x👩👩👧👦x', 2, 26, '', 0);
|
||||
test('emoji slice capture around', 'x👩👩👧👦x', 0, 27, 'x👩👩👧👦x', 27);
|
||||
});
|
|
@ -4,6 +4,7 @@ import type { ReadonlyDeep } from 'type-fest';
|
|||
import { z } from 'zod';
|
||||
import type { ConversationType } from '../state/ducks/conversations';
|
||||
import { safeParseInteger } from '../util/numbers';
|
||||
import { byteLength } from '../Bytes';
|
||||
|
||||
export enum CallLinkUpdateSyncType {
|
||||
Update = 'Update',
|
||||
|
@ -15,6 +16,16 @@ export type CallLinkUpdateData = Readonly<{
|
|||
adminKey: Uint8Array | undefined;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Names
|
||||
*/
|
||||
|
||||
export const CallLinkNameMaxByteLength = 120;
|
||||
|
||||
export const callLinkNameSchema = z.string().refine(input => {
|
||||
return byteLength(input) <= 120;
|
||||
});
|
||||
|
||||
/**
|
||||
* Restrictions
|
||||
*/
|
||||
|
@ -56,13 +67,6 @@ export type CallLinkStateType = Pick<
|
|||
'name' | 'restrictions' | 'revoked' | 'expiration'
|
||||
>;
|
||||
|
||||
export type ReadCallLinkState = Readonly<{
|
||||
name: string;
|
||||
restrictions: CallLinkRestrictions;
|
||||
revoked: boolean;
|
||||
expiration: number;
|
||||
}>;
|
||||
|
||||
// Ephemeral conversation-like type to satisfy components
|
||||
export type CallLinkConversationType = ReadonlyDeep<
|
||||
Omit<ConversationType, 'type'> & {
|
||||
|
@ -89,7 +93,7 @@ export const callLinkRecordSchema = z.object({
|
|||
rootKey: z.instanceof(Uint8Array).nullable(),
|
||||
adminKey: z.instanceof(Uint8Array).nullable(),
|
||||
// state
|
||||
name: z.string(),
|
||||
name: callLinkNameSchema,
|
||||
restrictions: callLinkRestrictionsSchema,
|
||||
expiration: z.number().int().nullable(),
|
||||
revoked: z.union([z.literal(1), z.literal(0)]),
|
||||
|
|
|
@ -25,6 +25,7 @@ import type {
|
|||
CallLinkStateType,
|
||||
} from '../types/CallLink';
|
||||
import {
|
||||
CallLinkNameMaxByteLength,
|
||||
callLinkRecordSchema,
|
||||
CallLinkRestrictions,
|
||||
toCallLinkRestrictions,
|
||||
|
@ -32,6 +33,7 @@ import {
|
|||
import type { LocalizerType } from '../types/Util';
|
||||
import { isTestOrMockEnvironment } from '../environment';
|
||||
import { getColorForCallLink } from './getColorForCallLink';
|
||||
import { unicodeSlice } from './unicodeSlice';
|
||||
import {
|
||||
AdhocCallStatus,
|
||||
CallDirection,
|
||||
|
@ -153,7 +155,7 @@ export function callLinkStateFromRingRTC(
|
|||
state: RingRTCCallLinkState
|
||||
): CallLinkStateType {
|
||||
return {
|
||||
name: state.name,
|
||||
name: unicodeSlice(state.name, 0, CallLinkNameMaxByteLength),
|
||||
restrictions: toCallLinkRestrictions(state.restrictions),
|
||||
revoked: state.revoked,
|
||||
expiration: state.expiration.getTime(),
|
||||
|
@ -213,6 +215,10 @@ export function callLinkFromRecord(record: CallLinkRecord): CallLinkType {
|
|||
};
|
||||
}
|
||||
|
||||
export function isCallLinkAdmin(callLink: CallLinkType): boolean {
|
||||
return callLink.adminKey != null;
|
||||
}
|
||||
|
||||
export function toCallHistoryFromUnusedCallLink(
|
||||
callLink: CallLinkType
|
||||
): CallHistoryDetails {
|
||||
|
|
|
@ -49,9 +49,9 @@ export async function onCallLinkUpdateSync(
|
|||
// TODO: DESKTOP-6951
|
||||
log.warn(`${logId}: Deleting call links is not supported`);
|
||||
}
|
||||
|
||||
confirm();
|
||||
} catch (err) {
|
||||
log.error(`${logId}: Failed to process`, Errors.toLogFormat(err));
|
||||
}
|
||||
|
||||
confirm();
|
||||
}
|
||||
|
|
53
ts/util/unicodeSlice.ts
Normal file
53
ts/util/unicodeSlice.ts
Normal file
|
@ -0,0 +1,53 @@
|
|||
// Copyright 2024 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
let cachedSegmenter: Intl.Segmenter;
|
||||
|
||||
/**
|
||||
* Slice a string by bytes into a valid Unicode string.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* unicodeSlice('123456', 2, 4); // => '34'
|
||||
* // '€' is 3 bytes, slicing it at 2 bytes would result in an invalid character
|
||||
* unicodeSlice('€', 0, 2); // => ''
|
||||
* // Each emoji is 4 bytes, with zero-width joiner of 3 bytes
|
||||
* unicodeSlice('👩👩👧👦', 0, 18); // => '👩👩👧'
|
||||
* ```
|
||||
*/
|
||||
export function unicodeSlice(
|
||||
input: string,
|
||||
begin: number,
|
||||
end: number
|
||||
): string {
|
||||
// Until https://chromium-review.googlesource.com/c/v8/v8/+/4190519 is merged,
|
||||
// we should limit the input size to avoid allocating tons of memory.
|
||||
// This should be longer than any max length we'd expect to slice.
|
||||
const slice = input.slice(0, 5e7); // 50MB
|
||||
|
||||
// 'und' is the BCP 47 subtag for "undetermined"
|
||||
// Unicode's CLDR doesn't have any special rules for granularity 'grapheme'
|
||||
// in any language, so we don't need to rely on loading any locale data.
|
||||
cachedSegmenter ??= new Intl.Segmenter('und', { granularity: 'grapheme' });
|
||||
|
||||
const graphemes = cachedSegmenter.segment(slice);
|
||||
|
||||
let result = '';
|
||||
let byteOffset = 0;
|
||||
|
||||
for (const grapheme of graphemes) {
|
||||
const graphemeByteLength = Buffer.byteLength(grapheme.segment);
|
||||
const startsBefore = byteOffset < begin;
|
||||
byteOffset += graphemeByteLength;
|
||||
const endsAfter = byteOffset > end;
|
||||
if (startsBefore) {
|
||||
continue;
|
||||
}
|
||||
if (endsAfter) {
|
||||
break;
|
||||
}
|
||||
result += grapheme.segment;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue