Add editing to call details pane

This commit is contained in:
Jamie Kyle 2024-07-30 11:39:24 -07:00 committed by GitHub
parent 95209689a8
commit cee2788654
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 330 additions and 118 deletions

View file

@ -7289,6 +7289,18 @@
"messageformat": "Join", "messageformat": "Join",
"description": "Call History > Call Link Details > Join Button" "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": { "icu:CallLinkDetails__CopyLink": {
"messageformat": "Copy link", "messageformat": "Copy link",
"description": "Call History > Call Link Details > Copy Link Button" "description": "Call History > Call Link Details > Copy Link Button"
@ -7309,15 +7321,19 @@
"messageformat": "Add call name", "messageformat": "Add call name",
"description": "Call Link Edit Modal > Add Call Name Button > Label" "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": { "icu:CallLinkEditModal__InputLabel--ApproveAllMembers": {
"messageformat": "Approve all members", "messageformat": "Approve all members",
"description": "Call Link Edit Modal > Approve All Members Checkbox > Label" "description": "Call Link Edit Modal > Approve All Members Checkbox > Label"
}, },
"icu:CallLinkEditModal__ApproveAllMembers__Option--Off": { "icu:CallLinkRestrictionsSelect__Option--Off": {
"messageformat": "Off", "messageformat": "Off",
"description": "Call Link Edit Modal > Approve All Members Checkbox > Option > Off" "description": "Call Link Edit Modal > Approve All Members Checkbox > Option > Off"
}, },
"icu:CallLinkEditModal__ApproveAllMembers__Option--On": { "icu:CallLinkRestrictionsSelect__Option--On": {
"messageformat": "On", "messageformat": "On",
"description": "Call Link Edit Modal > Approve All Members Checkbox > Option > On" "description": "Call Link Edit Modal > Approve All Members Checkbox > Option > On"
}, },
@ -7325,6 +7341,10 @@
"messageformat": "Add call name", "messageformat": "Add call name",
"description": "Call Link Add Name Modal > Title" "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": { "icu:CallLinkAddNameModal__NameLabel": {
"messageformat": "Call name", "messageformat": "Call name",
"description": "Call Link Add Name Modal > Name Input > Label" "description": "Call Link Add Name Modal > Name Input > Label"

View file

@ -13,7 +13,7 @@ import { strictAssert } from '../ts/util/assert';
import type { LoggerType } from '../ts/types/Logging'; import type { LoggerType } from '../ts/types/Logging';
import { handleAttachmentRequest } from './attachment_channel'; 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( strictAssert(
new Intl.Locale(FAKE_DEFAULT_LOCALE).toString() === FAKE_DEFAULT_LOCALE, new Intl.Locale(FAKE_DEFAULT_LOCALE).toString() === FAKE_DEFAULT_LOCALE,

View file

@ -160,8 +160,3 @@
height: 1px; height: 1px;
background: $color-black-alpha-12; background: $color-black-alpha-12;
} }
// Overriding default style
.CallLinkEditModal__RowSelect.module-select select {
min-width: 0;
}

View 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;
}

View file

@ -153,6 +153,14 @@
} }
} }
&--approveAllMembers {
&::after {
@include details-icon(
'../images/icons/v3/person/person-check-compact.svg'
);
}
}
&--link { &--link {
&::after { &::after {
@include details-icon('../images/icons/v3/link/link.svg'); @include details-icon('../images/icons/v3/link/link.svg');

View file

@ -53,6 +53,7 @@
@import './components/CallLinkAddNameModal.scss'; @import './components/CallLinkAddNameModal.scss';
@import './components/CallLinkDetails.scss'; @import './components/CallLinkDetails.scss';
@import './components/CallLinkEditModal.scss'; @import './components/CallLinkEditModal.scss';
@import './components/CallLinkRestrictionsSelect.scss';
@import './components/CallingRaisedHandsList.scss'; @import './components/CallingRaisedHandsList.scss';
@import './components/CallingRaisedHandsToasts.scss'; @import './components/CallingRaisedHandsToasts.scss';
@import './components/CallingReactionsToasts.scss'; @import './components/CallingReactionsToasts.scss';

View file

@ -1,13 +1,16 @@
// 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 React, { useCallback, useState } from 'react'; import React, { useCallback, useMemo, useState } from 'react';
import { v4 as generateUuid } from 'uuid'; import { v4 as generateUuid } from 'uuid';
import { Modal } from './Modal'; import { Modal } from './Modal';
import type { LocalizerType } from '../types/I18N'; import type { LocalizerType } from '../types/I18N';
import { Button, ButtonVariant } from './Button'; import { Button, ButtonVariant } from './Button';
import { Avatar, AvatarSize } from './Avatar'; import { Avatar, AvatarSize } from './Avatar';
import { Input } from './Input'; import { Input } from './Input';
import type { CallLinkType } from '../types/CallLink'; import {
CallLinkNameMaxByteLength,
type CallLinkType,
} from '../types/CallLink';
import { getColorForCallLink } from '../util/getColorForCallLink'; import { getColorForCallLink } from '../util/getColorForCallLink';
export type CallLinkAddNameModalProps = Readonly<{ export type CallLinkAddNameModalProps = Readonly<{
@ -27,18 +30,25 @@ export function CallLinkAddNameModal({
const [nameId] = useState(() => generateUuid()); const [nameId] = useState(() => generateUuid());
const [nameInput, setNameInput] = useState(callLink.name); 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) => { const handleNameInputChange = useCallback((nextNameInput: string) => {
setNameInput(nextNameInput); setNameInput(nextNameInput);
}, []); }, []);
const handleSubmit = useCallback(() => { const handleSubmit = useCallback(() => {
const nameValue = nameInput.trim(); if (parsedForm == null) {
if (nameValue === callLink.name) {
return; return;
} }
onUpdateCallLinkName(nameValue); onUpdateCallLinkName(parsedForm.name);
onClose(); onClose();
}, [nameInput, callLink, onUpdateCallLinkName, onClose]); }, [parsedForm, onUpdateCallLinkName, onClose]);
return ( return (
<Modal <Modal
@ -47,7 +57,11 @@ export function CallLinkAddNameModal({
hasXButton hasXButton
noEscapeClose noEscapeClose
noMouseClose noMouseClose
title={i18n('icu:CallLinkAddNameModal__Title')} title={
callLink.name === ''
? i18n('icu:CallLinkAddNameModal__Title')
: i18n('icu:CallLinkAddNameModal__Title--Edit')
}
onClose={onClose} onClose={onClose}
moduleClassName="CallLinkAddNameModal" moduleClassName="CallLinkAddNameModal"
modalFooter={ modalFooter={
@ -55,7 +69,12 @@ export function CallLinkAddNameModal({
<Button onClick={onClose} variant={ButtonVariant.Secondary}> <Button onClick={onClose} variant={ButtonVariant.Secondary}>
{i18n('icu:cancel')} {i18n('icu:cancel')}
</Button> </Button>
<Button type="submit" form={formId} variant={ButtonVariant.Primary}> <Button
type="submit"
form={formId}
variant={ButtonVariant.Primary}
aria-disabled={parsedForm == null}
>
{i18n('icu:save')} {i18n('icu:save')}
</Button> </Button>
</> </>
@ -93,6 +112,7 @@ export function CallLinkAddNameModal({
autoFocus autoFocus
onChange={handleNameInputChange} onChange={handleNameInputChange}
moduleClassName="CallLinkAddNameModal__Input" moduleClassName="CallLinkAddNameModal__Input"
maxByteCount={CallLinkNameMaxByteLength}
/> />
</form> </form>
</Modal> </Modal>

View file

@ -10,13 +10,15 @@ import {
IconType, IconType,
} from './conversation/conversation-details/ConversationDetailsIcon'; } from './conversation/conversation-details/ConversationDetailsIcon';
import { PanelRow } from './conversation/conversation-details/PanelRow'; 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 { linkCallRoute } from '../util/signalRoutes';
import { drop } from '../util/drop'; import { drop } from '../util/drop';
import { Avatar, AvatarSize } from './Avatar'; import { Avatar, AvatarSize } from './Avatar';
import { Button, ButtonSize, ButtonVariant } from './Button'; import { Button, ButtonSize, ButtonVariant } from './Button';
import { copyCallLink } from '../util/copyLinksWithToast'; import { copyCallLink } from '../util/copyLinksWithToast';
import { getColorForCallLink } from '../util/getColorForCallLink'; import { getColorForCallLink } from '../util/getColorForCallLink';
import { isCallLinkAdmin } from '../util/callLinks';
import { CallLinkRestrictionsSelect } from './CallLinkRestrictionsSelect';
function toUrlWithoutProtocol(url: URL): string { function toUrlWithoutProtocol(url: URL): string {
return `${url.hostname}${url.pathname}${url.search}${url.hash}`; return `${url.hostname}${url.pathname}${url.search}${url.hash}`;
@ -26,16 +28,20 @@ export type CallLinkDetailsProps = Readonly<{
callHistoryGroup: CallHistoryGroup; callHistoryGroup: CallHistoryGroup;
callLink: CallLinkType; callLink: CallLinkType;
i18n: LocalizerType; i18n: LocalizerType;
onOpenCallLinkAddNameModal: () => void;
onStartCallLinkLobby: () => void; onStartCallLinkLobby: () => void;
onShareCallLinkViaSignal: () => void; onShareCallLinkViaSignal: () => void;
onUpdateCallLinkRestrictions: (restrictions: CallLinkRestrictions) => void;
}>; }>;
export function CallLinkDetails({ export function CallLinkDetails({
callHistoryGroup, callHistoryGroup,
callLink, callLink,
i18n, i18n,
onOpenCallLinkAddNameModal,
onStartCallLinkLobby, onStartCallLinkLobby,
onShareCallLinkViaSignal, onShareCallLinkViaSignal,
onUpdateCallLinkRestrictions,
}: CallLinkDetailsProps): JSX.Element { }: CallLinkDetailsProps): JSX.Element {
const webUrl = linkCallRoute.toWebUrl({ const webUrl = linkCallRoute.toWebUrl({
key: callLink.rootKey, key: callLink.rootKey,
@ -80,6 +86,40 @@ export function CallLinkDetails({
callHistoryGroup={callHistoryGroup} callHistoryGroup={callHistoryGroup}
i18n={i18n} 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> <PanelSection>
<PanelRow <PanelRow
icon={ icon={

View file

@ -6,16 +6,13 @@ import React, { useMemo, useState } from 'react';
import { v4 as generateUuid } from 'uuid'; import { v4 as generateUuid } from 'uuid';
import { Modal } from './Modal'; import { Modal } from './Modal';
import type { LocalizerType } from '../types/I18N'; import type { LocalizerType } from '../types/I18N';
import { import type { CallLinkRestrictions } from '../types/CallLink';
CallLinkRestrictions, import { type CallLinkType } from '../types/CallLink';
toCallLinkRestrictions,
type CallLinkType,
} from '../types/CallLink';
import { Select } from './Select';
import { linkCallRoute } from '../util/signalRoutes'; import { linkCallRoute } from '../util/signalRoutes';
import { Button, ButtonSize, ButtonVariant } from './Button'; import { Button, ButtonSize, ButtonVariant } from './Button';
import { Avatar, AvatarSize } from './Avatar'; import { Avatar, AvatarSize } from './Avatar';
import { getColorForCallLink } from '../util/getColorForCallLink'; import { getColorForCallLink } from '../util/getColorForCallLink';
import { CallLinkRestrictionsSelect } from './CallLinkRestrictionsSelect';
const CallLinkEditModalRowIconClasses = { const CallLinkEditModalRowIconClasses = {
Edit: 'CallLinkEditModal__RowIcon--Edit', Edit: 'CallLinkEditModal__RowIcon--Edit',
@ -160,7 +157,11 @@ export function CallLinkEditModal({
<RowButton onClick={onOpenCallLinkAddNameModal}> <RowButton onClick={onOpenCallLinkAddNameModal}>
<Row> <Row>
<RowIcon icon="Edit" /> <RowIcon icon="Edit" />
<RowText>{i18n('icu:CallLinkEditModal__AddCallNameLabel')}</RowText> <RowText>
{callLink.name === ''
? i18n('icu:CallLinkEditModal__AddCallNameLabel')
: i18n('icu:CallLinkEditModal__EditCallNameLabel')}
</RowText>
</Row> </Row>
</RowButton> </RowButton>
@ -171,27 +172,11 @@ export function CallLinkEditModal({
{i18n('icu:CallLinkEditModal__InputLabel--ApproveAllMembers')} {i18n('icu:CallLinkEditModal__InputLabel--ApproveAllMembers')}
</label> </label>
</RowText> </RowText>
<Select <CallLinkRestrictionsSelect
i18n={i18n}
id={restrictionsId} id={restrictionsId}
value={String(callLink.restrictions)} value={callLink.restrictions}
moduleClassName="CallLinkEditModal__RowSelect" onChange={onUpdateCallLinkRestrictions}
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));
}}
/> />
</Row> </Row>

View 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));
}}
/>
);
}

View file

@ -8,6 +8,7 @@ import { Spinner } from '../../Spinner';
import { bemGenerator } from './util'; import { bemGenerator } from './util';
export enum IconType { export enum IconType {
'approveAllMembers' = 'approveAllMembers',
'block' = 'block', 'block' = 'block',
'edit' = 'edit', 'edit' = 'edit',
'unblock' = 'unblock', 'unblock' = 'unblock',

View file

@ -583,9 +583,8 @@ async function getCallLinkPreview(
} }
const callLinkRootKey = CallLinkRootKey.parse(parsedUrl.args.key); const callLinkRootKey = CallLinkRootKey.parse(parsedUrl.args.key);
const readResult = await calling.readCallLink({ callLinkRootKey }); const callLinkState = await calling.readCallLink(callLinkRootKey);
const { callLinkState } = readResult; if (callLinkState == null || callLinkState.revoked) {
if (!callLinkState || callLinkState.revoked) {
return null; return null;
} }

View file

@ -6,7 +6,6 @@ import { ipcRenderer } from 'electron';
import type { import type {
AudioDevice, AudioDevice,
CallId, CallId,
CallLinkState as RingRTCCallLinkState,
DeviceId, DeviceId,
GroupCallObserver, GroupCallObserver,
PeekInfo, PeekInfo,
@ -151,11 +150,7 @@ import {
conversationJobQueue, conversationJobQueue,
conversationQueueJobEnum, conversationQueueJobEnum,
} from '../jobs/conversationJobQueue'; } from '../jobs/conversationJobQueue';
import type { import type { CallLinkType, CallLinkStateType } from '../types/CallLink';
CallLinkType,
CallLinkStateType,
ReadCallLinkState,
} from '../types/CallLink';
import { CallLinkRestrictions } from '../types/CallLink'; import { CallLinkRestrictions } from '../types/CallLink';
import { getConversationIdForLogging } from '../util/idForLogging'; import { getConversationIdForLogging } from '../util/idForLogging';
import { sendCallLinkUpdateSync } from '../util/sendCallLinkUpdateSync'; import { sendCallLinkUpdateSync } from '../util/sendCallLinkUpdateSync';
@ -767,20 +762,9 @@ export class CallingClass {
return callLinkStateFromRingRTC(result.value); return callLinkStateFromRingRTC(result.value);
} }
async readCallLink({ async readCallLink(
callLinkRootKey, callLinkRootKey: CallLinkRootKey
}: Readonly<{ ): Promise<CallLinkStateType | null> {
callLinkRootKey: CallLinkRootKey;
}>): Promise<
| {
callLinkState: ReadCallLinkState;
errorStatusCode: undefined;
}
| {
callLinkState: undefined;
errorStatusCode: number;
}
> {
if (!this._sfuUrl) { if (!this._sfuUrl) {
throw new Error('readCallLink() missing SFU URL; not handling call link'); throw new Error('readCallLink() missing SFU URL; not handling call link');
} }
@ -796,18 +780,15 @@ export class CallingClass {
callLinkRootKey callLinkRootKey
); );
if (!result.success) { if (!result.success) {
log.warn(`${logId}: failed`); log.warn(`${logId}: failed with status ${result.errorStatusCode}`);
return { if (result.errorStatusCode === 404) {
callLinkState: undefined, return null;
errorStatusCode: result.errorStatusCode, }
}; throw new Error(`Failed to read call link: ${result.errorStatusCode}`);
} }
log.info(`${logId}: success`); log.info(`${logId}: success`);
return { return callLinkStateFromRingRTC(result.value);
callLinkState: this.formatCallLinkStateForRedux(result.value),
errorStatusCode: undefined,
};
} }
async startCallLinkLobby({ 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( public getGroupCallVideoFrameSource(
conversationId: string, conversationId: string,
demuxId: number demuxId: number

View file

@ -1407,18 +1407,15 @@ function handleCallLinkUpdate(
const roomId = getRoomIdFromRootKey(callLinkRootKey); const roomId = getRoomIdFromRootKey(callLinkRootKey);
const logId = `handleCallLinkUpdate(${roomId})`; const logId = `handleCallLinkUpdate(${roomId})`;
const readResult = await calling.readCallLink({ const freshCallLinkState = await calling.readCallLink(callLinkRootKey);
callLinkRootKey,
});
// Only give up when server confirms the call link is gone. If we fail to fetch // 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. // 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`); log.info(`${logId}: Call link not found, ignoring`);
return; return;
} }
const { callLinkState: freshCallLinkState } = readResult;
const existingCallLink = await DataReader.getCallLinkByRoomId(roomId); const existingCallLink = await DataReader.getCallLinkByRoomId(roomId);
const existingCallLinkState = pick(existingCallLink, [ const existingCallLinkState = pick(existingCallLink, [
'name', 'name',
@ -2070,9 +2067,17 @@ const _startCallLinkLobby = async ({
return; return;
} }
const readResult = await calling.readCallLink({ callLinkRootKey }); let callLinkState: CallLinkStateType | null = null;
const { callLinkState } = readResult; try {
if (!callLinkState) { 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()); const i18n = getIntl(getState());
dispatch({ dispatch({
type: SHOW_ERROR_MODAL, type: SHOW_ERROR_MODAL,
@ -2086,6 +2091,7 @@ const _startCallLinkLobby = async ({
} }
if ( if (
callLinkState.revoked || callLinkState.revoked ||
callLinkState.expiration == null ||
callLinkState.expiration < new Date().getTime() callLinkState.expiration < new Date().getTime()
) { ) {
const i18n = getIntl(getState()); const i18n = getIntl(getState());

View file

@ -9,7 +9,10 @@ import { getIntl } from '../selectors/user';
import { useGlobalModalActions } from '../ducks/globalModals'; import { useGlobalModalActions } from '../ducks/globalModals';
import { getCallLinkAddNameModalRoomId } from '../selectors/globalModals'; import { getCallLinkAddNameModalRoomId } from '../selectors/globalModals';
import { strictAssert } from '../../util/assert'; import { strictAssert } from '../../util/assert';
import { isCallLinksCreateEnabled } from '../../util/callLinks'; import {
isCallLinkAdmin,
isCallLinksCreateEnabled,
} from '../../util/callLinks';
import { CallLinkAddNameModal } from '../../components/CallLinkAddNameModal'; import { CallLinkAddNameModal } from '../../components/CallLinkAddNameModal';
export const SmartCallLinkAddNameModal = memo( export const SmartCallLinkAddNameModal = memo(
@ -48,6 +51,8 @@ export const SmartCallLinkAddNameModal = memo(
return null; return null;
} }
strictAssert(isCallLinkAdmin(callLink), 'User is not an admin');
return ( return (
<CallLinkAddNameModal <CallLinkAddNameModal
i18n={i18n} i18n={i18n}

View file

@ -10,6 +10,7 @@ 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 type { CallLinkRestrictions } from '../../types/CallLink';
export type SmartCallLinkDetailsProps = Readonly<{ export type SmartCallLinkDetailsProps = Readonly<{
roomId: string; roomId: string;
@ -22,11 +23,17 @@ export const SmartCallLinkDetails = memo(function SmartCallLinkDetails({
}: SmartCallLinkDetailsProps) { }: SmartCallLinkDetailsProps) {
const i18n = useSelector(getIntl); const i18n = useSelector(getIntl);
const callLinkSelector = useSelector(getCallLinkSelector); const callLinkSelector = useSelector(getCallLinkSelector);
const { startCallLinkLobby } = useCallingActions(); const { startCallLinkLobby, updateCallLinkRestrictions } =
const { showShareCallLinkViaSignal } = useGlobalModalActions(); useCallingActions();
const { toggleCallLinkAddNameModal, showShareCallLinkViaSignal } =
useGlobalModalActions();
const callLink = callLinkSelector(roomId); const callLink = callLinkSelector(roomId);
const handleOpenCallLinkAddNameModal = useCallback(() => {
toggleCallLinkAddNameModal(roomId);
}, [roomId, toggleCallLinkAddNameModal]);
const handleShareCallLinkViaSignal = useCallback(() => { const handleShareCallLinkViaSignal = useCallback(() => {
strictAssert(callLink != null, 'callLink not found'); strictAssert(callLink != null, 'callLink not found');
showShareCallLinkViaSignal(callLink, i18n); showShareCallLinkViaSignal(callLink, i18n);
@ -37,6 +44,13 @@ export const SmartCallLinkDetails = memo(function SmartCallLinkDetails({
startCallLinkLobby({ rootKey: callLink.rootKey }); startCallLinkLobby({ rootKey: callLink.rootKey });
}, [callLink, startCallLinkLobby]); }, [callLink, startCallLinkLobby]);
const handleUpdateCallLinkRestrictions = useCallback(
(newRestrictions: CallLinkRestrictions) => {
updateCallLinkRestrictions(roomId, newRestrictions);
},
[roomId, updateCallLinkRestrictions]
);
if (callLink == null) { if (callLink == null) {
log.error(`SmartCallLinkDetails: callLink not found for room ${roomId}`); log.error(`SmartCallLinkDetails: callLink not found for room ${roomId}`);
return null; return null;
@ -47,8 +61,10 @@ export const SmartCallLinkDetails = memo(function SmartCallLinkDetails({
callHistoryGroup={callHistoryGroup} callHistoryGroup={callHistoryGroup}
callLink={callLink} callLink={callLink}
i18n={i18n} i18n={i18n}
onOpenCallLinkAddNameModal={handleOpenCallLinkAddNameModal}
onStartCallLinkLobby={handleStartCallLinkLobby} onStartCallLinkLobby={handleStartCallLinkLobby}
onShareCallLinkViaSignal={handleShareCallLinkViaSignal} onShareCallLinkViaSignal={handleShareCallLinkViaSignal}
onUpdateCallLinkRestrictions={handleUpdateCallLinkRestrictions}
/> />
); );
}); });

View file

@ -1335,10 +1335,7 @@ describe('calling duck', () => {
beforeEach(function (this: Mocha.Context) { beforeEach(function (this: Mocha.Context) {
this.callingServiceReadCallLink = this.sandbox this.callingServiceReadCallLink = this.sandbox
.stub(callingService, 'readCallLink') .stub(callingService, 'readCallLink')
.resolves({ .resolves(getCallLinkState(FAKE_CALL_LINK));
callLinkState: getCallLinkState(FAKE_CALL_LINK),
errorStatusCode: undefined,
});
}); });
const doAction = async ( const doAction = async (
@ -1423,10 +1420,7 @@ describe('calling duck', () => {
beforeEach(function (this: Mocha.Context) { beforeEach(function (this: Mocha.Context) {
this.callingServiceReadCallLink = this.sandbox this.callingServiceReadCallLink = this.sandbox
.stub(callingService, 'readCallLink') .stub(callingService, 'readCallLink')
.resolves({ .resolves(callLinkState);
callLinkState,
errorStatusCode: undefined,
});
this.callingServiceStartCallLinkLobby = this.sandbox this.callingServiceStartCallLinkLobby = this.sandbox
.stub(callingService, 'startCallLinkLobby') .stub(callingService, 'startCallLinkLobby')
.resolves(callLobbyData); .resolves(callLobbyData);

View 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);
});

View file

@ -4,6 +4,7 @@ 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'; import { safeParseInteger } from '../util/numbers';
import { byteLength } from '../Bytes';
export enum CallLinkUpdateSyncType { export enum CallLinkUpdateSyncType {
Update = 'Update', Update = 'Update',
@ -15,6 +16,16 @@ export type CallLinkUpdateData = Readonly<{
adminKey: Uint8Array | undefined; adminKey: Uint8Array | undefined;
}>; }>;
/**
* Names
*/
export const CallLinkNameMaxByteLength = 120;
export const callLinkNameSchema = z.string().refine(input => {
return byteLength(input) <= 120;
});
/** /**
* Restrictions * Restrictions
*/ */
@ -56,13 +67,6 @@ export type CallLinkStateType = Pick<
'name' | 'restrictions' | 'revoked' | 'expiration' 'name' | 'restrictions' | 'revoked' | 'expiration'
>; >;
export type ReadCallLinkState = Readonly<{
name: string;
restrictions: CallLinkRestrictions;
revoked: boolean;
expiration: number;
}>;
// Ephemeral conversation-like type to satisfy components // Ephemeral conversation-like type to satisfy components
export type CallLinkConversationType = ReadonlyDeep< export type CallLinkConversationType = ReadonlyDeep<
Omit<ConversationType, 'type'> & { Omit<ConversationType, 'type'> & {
@ -89,7 +93,7 @@ export const callLinkRecordSchema = z.object({
rootKey: z.instanceof(Uint8Array).nullable(), rootKey: z.instanceof(Uint8Array).nullable(),
adminKey: z.instanceof(Uint8Array).nullable(), adminKey: z.instanceof(Uint8Array).nullable(),
// state // state
name: z.string(), name: callLinkNameSchema,
restrictions: callLinkRestrictionsSchema, restrictions: callLinkRestrictionsSchema,
expiration: z.number().int().nullable(), expiration: z.number().int().nullable(),
revoked: z.union([z.literal(1), z.literal(0)]), revoked: z.union([z.literal(1), z.literal(0)]),

View file

@ -25,6 +25,7 @@ import type {
CallLinkStateType, CallLinkStateType,
} from '../types/CallLink'; } from '../types/CallLink';
import { import {
CallLinkNameMaxByteLength,
callLinkRecordSchema, callLinkRecordSchema,
CallLinkRestrictions, CallLinkRestrictions,
toCallLinkRestrictions, toCallLinkRestrictions,
@ -32,6 +33,7 @@ import {
import type { LocalizerType } from '../types/Util'; import type { LocalizerType } from '../types/Util';
import { isTestOrMockEnvironment } from '../environment'; import { isTestOrMockEnvironment } from '../environment';
import { getColorForCallLink } from './getColorForCallLink'; import { getColorForCallLink } from './getColorForCallLink';
import { unicodeSlice } from './unicodeSlice';
import { import {
AdhocCallStatus, AdhocCallStatus,
CallDirection, CallDirection,
@ -153,7 +155,7 @@ export function callLinkStateFromRingRTC(
state: RingRTCCallLinkState state: RingRTCCallLinkState
): CallLinkStateType { ): CallLinkStateType {
return { return {
name: state.name, name: unicodeSlice(state.name, 0, CallLinkNameMaxByteLength),
restrictions: toCallLinkRestrictions(state.restrictions), restrictions: toCallLinkRestrictions(state.restrictions),
revoked: state.revoked, revoked: state.revoked,
expiration: state.expiration.getTime(), 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( export function toCallHistoryFromUnusedCallLink(
callLink: CallLinkType callLink: CallLinkType
): CallHistoryDetails { ): CallHistoryDetails {

View file

@ -49,9 +49,9 @@ export async function onCallLinkUpdateSync(
// TODO: DESKTOP-6951 // TODO: DESKTOP-6951
log.warn(`${logId}: Deleting call links is not supported`); log.warn(`${logId}: Deleting call links is not supported`);
} }
confirm();
} catch (err) { } catch (err) {
log.error(`${logId}: Failed to process`, Errors.toLogFormat(err)); log.error(`${logId}: Failed to process`, Errors.toLogFormat(err));
} }
confirm();
} }

53
ts/util/unicodeSlice.ts Normal file
View 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;
}