From cee27886540a3f612fe71a6b4455ae18e656c017 Mon Sep 17 00:00:00 2001 From: Jamie Kyle <113370520+jamiebuilds-signal@users.noreply.github.com> Date: Tue, 30 Jul 2024 11:39:24 -0700 Subject: [PATCH] Add editing to call details pane --- _locales/en/messages.json | 24 ++++++++- app/spell_check.ts | 2 +- stylesheets/components/CallLinkEditModal.scss | 5 -- .../CallLinkRestrictionsSelect.scss | 7 +++ .../components/ConversationDetails.scss | 8 +++ stylesheets/manifest.scss | 1 + ts/components/CallLinkAddNameModal.tsx | 36 ++++++++++--- ts/components/CallLinkDetails.tsx | 42 ++++++++++++++- ts/components/CallLinkEditModal.tsx | 39 +++++--------- ts/components/CallLinkRestrictionsSelect.tsx | 44 +++++++++++++++ .../ConversationDetailsIcon.tsx | 1 + ts/services/LinkPreview.ts | 5 +- ts/services/calling.ts | 51 ++++-------------- ts/state/ducks/calling.ts | 22 +++++--- ts/state/smart/CallLinkAddNameModal.tsx | 7 ++- ts/state/smart/CallLinkDetails.tsx | 20 ++++++- ts/test-electron/state/ducks/calling_test.ts | 10 +--- ts/test-node/util/unicodeSlice_test.ts | 39 ++++++++++++++ ts/types/CallLink.ts | 20 ++++--- ts/util/callLinks.ts | 8 ++- ts/util/onCallLinkUpdateSync.ts | 4 +- ts/util/unicodeSlice.ts | 53 +++++++++++++++++++ 22 files changed, 330 insertions(+), 118 deletions(-) create mode 100644 stylesheets/components/CallLinkRestrictionsSelect.scss create mode 100644 ts/components/CallLinkRestrictionsSelect.tsx create mode 100644 ts/test-node/util/unicodeSlice_test.ts create mode 100644 ts/util/unicodeSlice.ts diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 4466aea2e4..3171e2f389 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -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" diff --git a/app/spell_check.ts b/app/spell_check.ts index d644f58c19..4b512bc4ed 100644 --- a/app/spell_check.ts +++ b/app/spell_check.ts @@ -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, diff --git a/stylesheets/components/CallLinkEditModal.scss b/stylesheets/components/CallLinkEditModal.scss index 9e76ddd040..5a405ab087 100644 --- a/stylesheets/components/CallLinkEditModal.scss +++ b/stylesheets/components/CallLinkEditModal.scss @@ -160,8 +160,3 @@ height: 1px; background: $color-black-alpha-12; } - -// Overriding default style -.CallLinkEditModal__RowSelect.module-select select { - min-width: 0; -} diff --git a/stylesheets/components/CallLinkRestrictionsSelect.scss b/stylesheets/components/CallLinkRestrictionsSelect.scss new file mode 100644 index 0000000000..4bef16e2fe --- /dev/null +++ b/stylesheets/components/CallLinkRestrictionsSelect.scss @@ -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; +} diff --git a/stylesheets/components/ConversationDetails.scss b/stylesheets/components/ConversationDetails.scss index da7cccdaf4..53fa84d143 100644 --- a/stylesheets/components/ConversationDetails.scss +++ b/stylesheets/components/ConversationDetails.scss @@ -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'); diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index 42536f0046..54b673b8a9 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -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'; diff --git a/ts/components/CallLinkAddNameModal.tsx b/ts/components/CallLinkAddNameModal.tsx index 7b817a55e9..f983c72736 100644 --- a/ts/components/CallLinkAddNameModal.tsx +++ b/ts/components/CallLinkAddNameModal.tsx @@ -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 ( {i18n('icu:cancel')} - @@ -93,6 +112,7 @@ export function CallLinkAddNameModal({ autoFocus onChange={handleNameInputChange} moduleClassName="CallLinkAddNameModal__Input" + maxByteCount={CallLinkNameMaxByteLength} /> diff --git a/ts/components/CallLinkDetails.tsx b/ts/components/CallLinkDetails.tsx index 92ad777c87..e6c4591adb 100644 --- a/ts/components/CallLinkDetails.tsx +++ b/ts/components/CallLinkDetails.tsx @@ -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) && ( + + + } + label={ + callLink.name === '' + ? i18n('icu:CallLinkDetails__AddCallNameLabel') + : i18n('icu:CallLinkDetails__EditCallNameLabel') + } + onClick={onOpenCallLinkAddNameModal} + /> + + } + label={i18n('icu:CallLinkDetails__ApproveAllMembersLabel')} + right={ + + } + /> + + )} - {i18n('icu:CallLinkEditModal__AddCallNameLabel')} + + {callLink.name === '' + ? i18n('icu:CallLinkEditModal__AddCallNameLabel') + : i18n('icu:CallLinkEditModal__EditCallNameLabel')} + @@ -171,27 +172,11 @@ export function CallLinkEditModal({ {i18n('icu:CallLinkEditModal__InputLabel--ApproveAllMembers')} - { + onChange(toCallLinkRestrictions(nextValue)); + }} + /> + ); +} diff --git a/ts/components/conversation/conversation-details/ConversationDetailsIcon.tsx b/ts/components/conversation/conversation-details/ConversationDetailsIcon.tsx index 6854840020..97cea2c68c 100644 --- a/ts/components/conversation/conversation-details/ConversationDetailsIcon.tsx +++ b/ts/components/conversation/conversation-details/ConversationDetailsIcon.tsx @@ -8,6 +8,7 @@ import { Spinner } from '../../Spinner'; import { bemGenerator } from './util'; export enum IconType { + 'approveAllMembers' = 'approveAllMembers', 'block' = 'block', 'edit' = 'edit', 'unblock' = 'unblock', diff --git a/ts/services/LinkPreview.ts b/ts/services/LinkPreview.ts index d1b1f460e5..ad8174f832 100644 --- a/ts/services/LinkPreview.ts +++ b/ts/services/LinkPreview.ts @@ -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; } diff --git a/ts/services/calling.ts b/ts/services/calling.ts index 7d7f70ff41..c5b75c2e91 100644 --- a/ts/services/calling.ts +++ b/ts/services/calling.ts @@ -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 { 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 diff --git a/ts/state/ducks/calling.ts b/ts/state/ducks/calling.ts index 4a7d8988a3..616f6552b8 100644 --- a/ts/state/ducks/calling.ts +++ b/ts/state/ducks/calling.ts @@ -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()); diff --git a/ts/state/smart/CallLinkAddNameModal.tsx b/ts/state/smart/CallLinkAddNameModal.tsx index a799c7c5af..026ad42ad9 100644 --- a/ts/state/smart/CallLinkAddNameModal.tsx +++ b/ts/state/smart/CallLinkAddNameModal.tsx @@ -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 ( { + 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} /> ); }); diff --git a/ts/test-electron/state/ducks/calling_test.ts b/ts/test-electron/state/ducks/calling_test.ts index 57b01a2554..16f33ef2db 100644 --- a/ts/test-electron/state/ducks/calling_test.ts +++ b/ts/test-electron/state/ducks/calling_test.ts @@ -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); diff --git a/ts/test-node/util/unicodeSlice_test.ts b/ts/test-node/util/unicodeSlice_test.ts new file mode 100644 index 0000000000..af675a01fe --- /dev/null +++ b/ts/test-node/util/unicodeSlice_test.ts @@ -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); +}); diff --git a/ts/types/CallLink.ts b/ts/types/CallLink.ts index 9a866f6a12..b2b4b44bb1 100644 --- a/ts/types/CallLink.ts +++ b/ts/types/CallLink.ts @@ -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 & { @@ -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)]), diff --git a/ts/util/callLinks.ts b/ts/util/callLinks.ts index 60b356901a..0269c3f655 100644 --- a/ts/util/callLinks.ts +++ b/ts/util/callLinks.ts @@ -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 { diff --git a/ts/util/onCallLinkUpdateSync.ts b/ts/util/onCallLinkUpdateSync.ts index ac9e80961e..9507e5f806 100644 --- a/ts/util/onCallLinkUpdateSync.ts +++ b/ts/util/onCallLinkUpdateSync.ts @@ -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(); } diff --git a/ts/util/unicodeSlice.ts b/ts/util/unicodeSlice.ts new file mode 100644 index 0000000000..74c8d89b9e --- /dev/null +++ b/ts/util/unicodeSlice.ts @@ -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; +}