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",
"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"

View file

@ -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,

View file

@ -160,8 +160,3 @@
height: 1px;
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 {
&::after {
@include details-icon('../images/icons/v3/link/link.svg');

View file

@ -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';

View file

@ -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>

View file

@ -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={

View file

@ -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>

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';
export enum IconType {
'approveAllMembers' = 'approveAllMembers',
'block' = 'block',
'edit' = 'edit',
'unblock' = 'unblock',

View file

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

View file

@ -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

View file

@ -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());

View file

@ -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}

View file

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

View file

@ -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);

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 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)]),

View file

@ -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 {

View file

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