Merge branch 'main' into HEAD
This commit is contained in:
commit
d57d0cea19
1135 changed files with 264116 additions and 302492 deletions
|
@ -1,119 +1,207 @@
|
|||
// Copyright 2020 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { unlinkSync } from 'fs';
|
||||
import { unlinkSync, createReadStream, createWriteStream } from 'fs';
|
||||
import { open } from 'fs/promises';
|
||||
import {
|
||||
createDecipheriv,
|
||||
createCipheriv,
|
||||
createHash,
|
||||
createHmac,
|
||||
randomBytes,
|
||||
} from 'crypto';
|
||||
import type { Decipher, Hash, Hmac } from 'crypto';
|
||||
import type { TransformCallback } from 'stream';
|
||||
import { Transform } from 'stream';
|
||||
import { createCipheriv, createHash, createHmac, randomBytes } from 'crypto';
|
||||
import type { Hash } from 'crypto';
|
||||
import { PassThrough, Transform, type Writable, Readable } from 'stream';
|
||||
import { pipeline } from 'stream/promises';
|
||||
import { ensureFile } from 'fs-extra';
|
||||
import * as log from './logging/log';
|
||||
import { HashType, CipherType } from './types/Crypto';
|
||||
import { createName, getRelativePath } from './windows/attachments';
|
||||
import { constantTimeEqual, getAttachmentSizeBucket } from './Crypto';
|
||||
import { Environment } from './environment';
|
||||
import type { AttachmentType } from './types/Attachment';
|
||||
import type { ContextType } from './types/Message2';
|
||||
import {
|
||||
HashType,
|
||||
CipherType,
|
||||
IV_LENGTH,
|
||||
KEY_LENGTH,
|
||||
MAC_LENGTH,
|
||||
} from './types/Crypto';
|
||||
import { constantTimeEqual } from './Crypto';
|
||||
import { createName, getRelativePath } from './util/attachmentPath';
|
||||
import { appendPaddingStream, logPadSize } from './util/logPadding';
|
||||
import { prependStream } from './util/prependStream';
|
||||
import { appendMacStream } from './util/appendMacStream';
|
||||
import { finalStream } from './util/finalStream';
|
||||
import { getIvAndDecipher } from './util/getIvAndDecipher';
|
||||
import { getMacAndUpdateHmac } from './util/getMacAndUpdateHmac';
|
||||
import { trimPadding } from './util/trimPadding';
|
||||
import { strictAssert } from './util/assert';
|
||||
import * as Errors from './types/errors';
|
||||
import { isNotNil } from './util/isNotNil';
|
||||
import { missingCaseError } from './util/missingCaseError';
|
||||
import { getEnvironment, Environment } from './environment';
|
||||
import { toBase64 } from './Bytes';
|
||||
|
||||
// This file was split from ts/Crypto.ts because it pulls things in from node, and
|
||||
// too many things pull in Crypto.ts, so it broke storybook.
|
||||
|
||||
const IV_LENGTH = 16;
|
||||
const KEY_LENGTH = 32;
|
||||
const DIGEST_LENGTH = 32;
|
||||
const DIGEST_LENGTH = MAC_LENGTH;
|
||||
const HEX_DIGEST_LENGTH = DIGEST_LENGTH * 2;
|
||||
const ATTACHMENT_MAC_LENGTH = 32;
|
||||
const ATTACHMENT_MAC_LENGTH = MAC_LENGTH;
|
||||
|
||||
export class ReencryptedDigestMismatchError extends Error {}
|
||||
|
||||
/** @private */
|
||||
export const KEY_SET_LENGTH = KEY_LENGTH + ATTACHMENT_MAC_LENGTH;
|
||||
export const KEY_SET_LENGTH = KEY_LENGTH + MAC_LENGTH;
|
||||
|
||||
export function _generateAttachmentIv(): Uint8Array {
|
||||
return randomBytes(IV_LENGTH);
|
||||
}
|
||||
|
||||
export function generateAttachmentKeys(): Uint8Array {
|
||||
return randomBytes(KEY_SET_LENGTH);
|
||||
}
|
||||
|
||||
export type EncryptedAttachmentV2 = {
|
||||
path: string;
|
||||
digest: Uint8Array;
|
||||
iv: Uint8Array;
|
||||
plaintextHash: string;
|
||||
ciphertextSize: number;
|
||||
};
|
||||
|
||||
export type ReencryptedAttachmentV2 = {
|
||||
path: string;
|
||||
iv: string;
|
||||
plaintextHash: string;
|
||||
localKey: string;
|
||||
version: 2;
|
||||
};
|
||||
|
||||
export type DecryptedAttachmentV2 = {
|
||||
path: string;
|
||||
iv: Uint8Array;
|
||||
plaintextHash: string;
|
||||
};
|
||||
|
||||
export type PlaintextSourceType =
|
||||
| { data: Uint8Array }
|
||||
| { stream: Readable }
|
||||
| { absolutePath: string };
|
||||
|
||||
export type HardcodedIVForEncryptionType =
|
||||
| {
|
||||
reason: 'test';
|
||||
iv: Uint8Array;
|
||||
}
|
||||
| {
|
||||
reason: 'reencrypting-for-backup';
|
||||
iv: Uint8Array;
|
||||
digestToMatch: Uint8Array;
|
||||
};
|
||||
|
||||
type EncryptAttachmentV2PropsType = {
|
||||
plaintext: PlaintextSourceType;
|
||||
keys: Readonly<Uint8Array>;
|
||||
dangerousIv?: HardcodedIVForEncryptionType;
|
||||
dangerousTestOnlySkipPadding?: boolean;
|
||||
getAbsoluteAttachmentPath: (relativePath: string) => string;
|
||||
};
|
||||
|
||||
export async function encryptAttachmentV2ToDisk(
|
||||
args: EncryptAttachmentV2PropsType
|
||||
): Promise<EncryptedAttachmentV2 & { path: string }> {
|
||||
// Create random output file
|
||||
const relativeTargetPath = getRelativePath(createName());
|
||||
const absoluteTargetPath = args.getAbsoluteAttachmentPath(relativeTargetPath);
|
||||
|
||||
await ensureFile(absoluteTargetPath);
|
||||
|
||||
let encryptResult: EncryptedAttachmentV2;
|
||||
|
||||
try {
|
||||
encryptResult = await encryptAttachmentV2({
|
||||
...args,
|
||||
sink: createWriteStream(absoluteTargetPath),
|
||||
});
|
||||
} catch (error) {
|
||||
safeUnlinkSync(absoluteTargetPath);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
...encryptResult,
|
||||
path: relativeTargetPath,
|
||||
};
|
||||
}
|
||||
export async function encryptAttachmentV2({
|
||||
keys,
|
||||
plaintextAbsolutePath,
|
||||
size,
|
||||
dangerousTestOnlyIv,
|
||||
}: {
|
||||
keys: Readonly<Uint8Array>;
|
||||
plaintextAbsolutePath: string;
|
||||
size: number;
|
||||
dangerousTestOnlyIv?: Readonly<Uint8Array>;
|
||||
plaintext,
|
||||
dangerousIv,
|
||||
dangerousTestOnlySkipPadding,
|
||||
sink,
|
||||
}: EncryptAttachmentV2PropsType & {
|
||||
sink?: Writable;
|
||||
}): Promise<EncryptedAttachmentV2> {
|
||||
const logId = 'encryptAttachmentV2';
|
||||
|
||||
// Create random output file
|
||||
const relativeTargetPath = getRelativePath(createName());
|
||||
const absoluteTargetPath =
|
||||
window.Signal.Migrations.getAbsoluteAttachmentPath(relativeTargetPath);
|
||||
|
||||
const { aesKey, macKey } = splitKeys(keys);
|
||||
|
||||
if (dangerousTestOnlyIv && window.getEnvironment() !== Environment.Test) {
|
||||
throw new Error(`${logId}: Used dangerousTestOnlyIv outside tests!`);
|
||||
if (dangerousIv) {
|
||||
if (dangerousIv.reason === 'test') {
|
||||
if (getEnvironment() !== Environment.Test) {
|
||||
throw new Error(
|
||||
`${logId}: Used dangerousIv with reason test outside tests!`
|
||||
);
|
||||
}
|
||||
} else if (dangerousIv.reason === 'reencrypting-for-backup') {
|
||||
strictAssert(
|
||||
dangerousIv.digestToMatch.byteLength === DIGEST_LENGTH,
|
||||
`${logId}: Must provide valid digest to match if providing iv for re-encryption`
|
||||
);
|
||||
log.info(
|
||||
`${logId}: using hardcoded iv because we are re-encrypting for backup`
|
||||
);
|
||||
} else {
|
||||
throw missingCaseError(dangerousIv);
|
||||
}
|
||||
}
|
||||
const iv = dangerousTestOnlyIv || _generateAttachmentIv();
|
||||
|
||||
if (dangerousTestOnlySkipPadding && getEnvironment() !== Environment.Test) {
|
||||
throw new Error(
|
||||
`${logId}: Used dangerousTestOnlySkipPadding outside tests!`
|
||||
);
|
||||
}
|
||||
|
||||
const iv = dangerousIv?.iv || _generateAttachmentIv();
|
||||
const plaintextHash = createHash(HashType.size256);
|
||||
const digest = createHash(HashType.size256);
|
||||
|
||||
let readFd;
|
||||
let writeFd;
|
||||
let ciphertextSize: number | undefined;
|
||||
let mac: Uint8Array | undefined;
|
||||
|
||||
try {
|
||||
try {
|
||||
readFd = await open(plaintextAbsolutePath, 'r');
|
||||
} catch (cause) {
|
||||
throw new Error(`${logId}: Read path doesn't exist`, { cause });
|
||||
}
|
||||
try {
|
||||
await ensureFile(absoluteTargetPath);
|
||||
writeFd = await open(absoluteTargetPath, 'w');
|
||||
} catch (cause) {
|
||||
throw new Error(`${logId}: Failed to create write path`, { cause });
|
||||
let source: Readable;
|
||||
if ('data' in plaintext) {
|
||||
source = Readable.from([Buffer.from(plaintext.data)]);
|
||||
} else if ('stream' in plaintext) {
|
||||
source = plaintext.stream;
|
||||
} else {
|
||||
source = createReadStream(plaintext.absolutePath);
|
||||
}
|
||||
|
||||
await pipeline(
|
||||
readFd.createReadStream(),
|
||||
peekAndUpdateHash(plaintextHash),
|
||||
appendPadding(size),
|
||||
createCipheriv(CipherType.AES256CBC, aesKey, iv),
|
||||
prependIv(iv),
|
||||
appendMac(macKey),
|
||||
peekAndUpdateHash(digest),
|
||||
writeFd.createWriteStream()
|
||||
[
|
||||
source,
|
||||
peekAndUpdateHash(plaintextHash),
|
||||
dangerousTestOnlySkipPadding ? undefined : appendPaddingStream(),
|
||||
createCipheriv(CipherType.AES256CBC, aesKey, iv),
|
||||
prependIv(iv),
|
||||
appendMacStream(macKey, macValue => {
|
||||
mac = macValue;
|
||||
}),
|
||||
peekAndUpdateHash(digest),
|
||||
measureSize(size => {
|
||||
ciphertextSize = size;
|
||||
}),
|
||||
sink ?? new PassThrough().resume(),
|
||||
].filter(isNotNil)
|
||||
);
|
||||
} catch (error) {
|
||||
log.error(
|
||||
`${logId}: Failed to encrypt attachment`,
|
||||
Errors.toLogFormat(error)
|
||||
);
|
||||
safeUnlinkSync(absoluteTargetPath);
|
||||
throw error;
|
||||
} finally {
|
||||
await Promise.all([readFd?.close(), writeFd?.close()]);
|
||||
}
|
||||
|
||||
const ourPlaintextHash = plaintextHash.digest('hex');
|
||||
|
@ -129,48 +217,75 @@ export async function encryptAttachmentV2({
|
|||
`${logId}: Failed to generate ourDigest!`
|
||||
);
|
||||
|
||||
strictAssert(ciphertextSize != null, 'Failed to measure ciphertext size!');
|
||||
strictAssert(mac != null, 'Failed to compute mac!');
|
||||
|
||||
if (dangerousIv?.reason === 'reencrypting-for-backup') {
|
||||
if (!constantTimeEqual(ourDigest, dangerousIv.digestToMatch)) {
|
||||
throw new ReencryptedDigestMismatchError(
|
||||
`${logId}: iv was hardcoded for backup re-encryption, but digest does not match`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
path: relativeTargetPath,
|
||||
digest: ourDigest,
|
||||
iv,
|
||||
plaintextHash: ourPlaintextHash,
|
||||
ciphertextSize,
|
||||
};
|
||||
}
|
||||
|
||||
export async function decryptAttachmentV2({
|
||||
ciphertextPath,
|
||||
id,
|
||||
keys,
|
||||
size,
|
||||
theirDigest,
|
||||
}: {
|
||||
ciphertextPath: string;
|
||||
id: string;
|
||||
keys: Readonly<Uint8Array>;
|
||||
size: number;
|
||||
theirDigest: Readonly<Uint8Array>;
|
||||
}): Promise<DecryptedAttachmentV2> {
|
||||
const logId = `decryptAttachmentV2(${id})`;
|
||||
type DecryptAttachmentToSinkOptionsType = Readonly<
|
||||
{
|
||||
ciphertextPath: string;
|
||||
idForLogging: string;
|
||||
size: number;
|
||||
outerEncryption?: {
|
||||
aesKey: Readonly<Uint8Array>;
|
||||
macKey: Readonly<Uint8Array>;
|
||||
};
|
||||
} & (
|
||||
| {
|
||||
type: 'standard';
|
||||
theirDigest: Readonly<Uint8Array>;
|
||||
}
|
||||
| {
|
||||
// No need to check integrity for locally reencrypted attachments, or for backup
|
||||
// thumbnails (since we created it)
|
||||
type: 'local' | 'backupThumbnail';
|
||||
theirDigest?: undefined;
|
||||
}
|
||||
) &
|
||||
(
|
||||
| {
|
||||
aesKey: Readonly<Uint8Array>;
|
||||
macKey: Readonly<Uint8Array>;
|
||||
}
|
||||
| {
|
||||
// The format used by most stored attachments
|
||||
keysBase64: string;
|
||||
}
|
||||
)
|
||||
>;
|
||||
|
||||
export type DecryptAttachmentOptionsType = DecryptAttachmentToSinkOptionsType &
|
||||
Readonly<{
|
||||
getAbsoluteAttachmentPath: (relativePath: string) => string;
|
||||
}>;
|
||||
|
||||
export async function decryptAttachmentV2(
|
||||
options: DecryptAttachmentOptionsType
|
||||
): Promise<DecryptedAttachmentV2> {
|
||||
const logId = `decryptAttachmentV2(${options.idForLogging})`;
|
||||
|
||||
// Create random output file
|
||||
const relativeTargetPath = getRelativePath(createName());
|
||||
const absoluteTargetPath =
|
||||
window.Signal.Migrations.getAbsoluteAttachmentPath(relativeTargetPath);
|
||||
options.getAbsoluteAttachmentPath(relativeTargetPath);
|
||||
|
||||
const { aesKey, macKey } = splitKeys(keys);
|
||||
|
||||
const digest = createHash(HashType.size256);
|
||||
const hmac = createHmac(HashType.size256, macKey);
|
||||
const plaintextHash = createHash(HashType.size256);
|
||||
let theirMac = null as Uint8Array | null; // TypeScript shenanigans
|
||||
|
||||
let readFd;
|
||||
let writeFd;
|
||||
try {
|
||||
try {
|
||||
readFd = await open(ciphertextPath, 'r');
|
||||
} catch (cause) {
|
||||
throw new Error(`${logId}: Read path doesn't exist`, { cause });
|
||||
}
|
||||
try {
|
||||
await ensureFile(absoluteTargetPath);
|
||||
writeFd = await open(absoluteTargetPath, 'w');
|
||||
|
@ -178,17 +293,231 @@ export async function decryptAttachmentV2({
|
|||
throw new Error(`${logId}: Failed to create write path`, { cause });
|
||||
}
|
||||
|
||||
await pipeline(
|
||||
readFd.createReadStream(),
|
||||
peekAndUpdateHash(digest),
|
||||
getMacAndUpdateHmac(hmac, theirMacValue => {
|
||||
theirMac = theirMacValue;
|
||||
}),
|
||||
getIvAndDecipher(aesKey),
|
||||
trimPadding(size),
|
||||
peekAndUpdateHash(plaintextHash),
|
||||
const result = await decryptAttachmentV2ToSink(
|
||||
options,
|
||||
writeFd.createWriteStream()
|
||||
);
|
||||
|
||||
return {
|
||||
...result,
|
||||
path: relativeTargetPath,
|
||||
};
|
||||
} catch (error) {
|
||||
log.error(
|
||||
`${logId}: Failed to decrypt attachment to disk`,
|
||||
Errors.toLogFormat(error)
|
||||
);
|
||||
safeUnlinkSync(absoluteTargetPath);
|
||||
throw error;
|
||||
} finally {
|
||||
await writeFd?.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function decryptAttachmentV2ToSink(
|
||||
options: DecryptAttachmentToSinkOptionsType,
|
||||
sink: Writable
|
||||
): Promise<Omit<DecryptedAttachmentV2, 'path'>> {
|
||||
const { idForLogging, ciphertextPath, outerEncryption } = options;
|
||||
|
||||
let aesKey: Uint8Array;
|
||||
let macKey: Uint8Array;
|
||||
|
||||
if ('aesKey' in options) {
|
||||
({ aesKey, macKey } = options);
|
||||
} else {
|
||||
const { keysBase64 } = options;
|
||||
const keys = Buffer.from(keysBase64, 'base64');
|
||||
|
||||
({ aesKey, macKey } = splitKeys(keys));
|
||||
}
|
||||
|
||||
const logId = `decryptAttachmentV2(${idForLogging})`;
|
||||
|
||||
const digest = createHash(HashType.size256);
|
||||
const hmac = createHmac(HashType.size256, macKey);
|
||||
const plaintextHash = createHash(HashType.size256);
|
||||
let theirMac: Uint8Array | undefined;
|
||||
|
||||
// When downloading from backup there is an outer encryption layer; in that case we
|
||||
// need to decrypt the outer layer and check its MAC
|
||||
let theirOuterMac: Uint8Array | undefined;
|
||||
const outerHmac = outerEncryption
|
||||
? createHmac(HashType.size256, outerEncryption.macKey)
|
||||
: undefined;
|
||||
|
||||
const maybeOuterEncryptionGetIvAndDecipher = outerEncryption
|
||||
? getIvAndDecipher(outerEncryption.aesKey)
|
||||
: undefined;
|
||||
|
||||
const maybeOuterEncryptionGetMacAndUpdateMac = outerHmac
|
||||
? getMacAndUpdateHmac(outerHmac, theirOuterMacValue => {
|
||||
theirOuterMac = theirOuterMacValue;
|
||||
})
|
||||
: undefined;
|
||||
|
||||
let readFd;
|
||||
let iv: Uint8Array | undefined;
|
||||
try {
|
||||
try {
|
||||
readFd = await open(ciphertextPath, 'r');
|
||||
} catch (cause) {
|
||||
throw new Error(`${logId}: Read path doesn't exist`, { cause });
|
||||
}
|
||||
|
||||
await pipeline(
|
||||
[
|
||||
readFd.createReadStream(),
|
||||
maybeOuterEncryptionGetMacAndUpdateMac,
|
||||
maybeOuterEncryptionGetIvAndDecipher,
|
||||
peekAndUpdateHash(digest),
|
||||
getMacAndUpdateHmac(hmac, theirMacValue => {
|
||||
theirMac = theirMacValue;
|
||||
}),
|
||||
getIvAndDecipher(aesKey, theirIv => {
|
||||
iv = theirIv;
|
||||
}),
|
||||
trimPadding(options.size),
|
||||
peekAndUpdateHash(plaintextHash),
|
||||
finalStream(() => {
|
||||
const ourMac = hmac.digest();
|
||||
const ourDigest = digest.digest();
|
||||
|
||||
strictAssert(
|
||||
ourMac.byteLength === ATTACHMENT_MAC_LENGTH,
|
||||
`${logId}: Failed to generate ourMac!`
|
||||
);
|
||||
strictAssert(
|
||||
theirMac != null && theirMac.byteLength === ATTACHMENT_MAC_LENGTH,
|
||||
`${logId}: Failed to find theirMac!`
|
||||
);
|
||||
strictAssert(
|
||||
ourDigest.byteLength === DIGEST_LENGTH,
|
||||
`${logId}: Failed to generate ourDigest!`
|
||||
);
|
||||
|
||||
if (!constantTimeEqual(ourMac, theirMac)) {
|
||||
throw new Error(`${logId}: Bad MAC`);
|
||||
}
|
||||
|
||||
const { type } = options;
|
||||
switch (type) {
|
||||
case 'local':
|
||||
case 'backupThumbnail':
|
||||
// Skip digest check
|
||||
break;
|
||||
case 'standard':
|
||||
if (!constantTimeEqual(ourDigest, options.theirDigest)) {
|
||||
throw new Error(`${logId}: Bad digest`);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
throw missingCaseError(type);
|
||||
}
|
||||
|
||||
if (!outerEncryption) {
|
||||
return;
|
||||
}
|
||||
|
||||
strictAssert(outerHmac, 'outerHmac must exist');
|
||||
|
||||
const ourOuterMac = outerHmac.digest();
|
||||
strictAssert(
|
||||
ourOuterMac.byteLength === ATTACHMENT_MAC_LENGTH,
|
||||
`${logId}: Failed to generate ourOuterMac!`
|
||||
);
|
||||
strictAssert(
|
||||
theirOuterMac != null &&
|
||||
theirOuterMac.byteLength === ATTACHMENT_MAC_LENGTH,
|
||||
`${logId}: Failed to find theirOuterMac!`
|
||||
);
|
||||
|
||||
if (!constantTimeEqual(ourOuterMac, theirOuterMac)) {
|
||||
throw new Error(`${logId}: Bad outer encryption MAC`);
|
||||
}
|
||||
}),
|
||||
sink,
|
||||
].filter(isNotNil)
|
||||
);
|
||||
} catch (error) {
|
||||
// These errors happen when canceling fetch from `attachment://` urls,
|
||||
// ignore them to avoid noise in the logs.
|
||||
if (
|
||||
error.name === 'AbortError' ||
|
||||
error.code === 'ERR_STREAM_PREMATURE_CLOSE'
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
log.error(
|
||||
`${logId}: Failed to decrypt attachment`,
|
||||
Errors.toLogFormat(error)
|
||||
);
|
||||
throw error;
|
||||
} finally {
|
||||
await readFd?.close();
|
||||
}
|
||||
|
||||
const ourPlaintextHash = plaintextHash.digest('hex');
|
||||
strictAssert(
|
||||
ourPlaintextHash.length === HEX_DIGEST_LENGTH,
|
||||
`${logId}: Failed to generate file hash!`
|
||||
);
|
||||
|
||||
strictAssert(
|
||||
iv != null && iv.byteLength === IV_LENGTH,
|
||||
`${logId}: failed to find their iv`
|
||||
);
|
||||
|
||||
return {
|
||||
iv,
|
||||
plaintextHash: ourPlaintextHash,
|
||||
};
|
||||
}
|
||||
|
||||
export async function decryptAndReencryptLocally(
|
||||
options: DecryptAttachmentOptionsType
|
||||
): Promise<ReencryptedAttachmentV2> {
|
||||
const { idForLogging } = options;
|
||||
|
||||
const logId = `reencryptAttachmentV2(${idForLogging})`;
|
||||
|
||||
// Create random output file
|
||||
const relativeTargetPath = getRelativePath(createName());
|
||||
const absoluteTargetPath =
|
||||
options.getAbsoluteAttachmentPath(relativeTargetPath);
|
||||
|
||||
let writeFd;
|
||||
try {
|
||||
try {
|
||||
await ensureFile(absoluteTargetPath);
|
||||
writeFd = await open(absoluteTargetPath, 'w');
|
||||
} catch (cause) {
|
||||
throw new Error(`${logId}: Failed to create write path`, { cause });
|
||||
}
|
||||
|
||||
const keys = generateKeys();
|
||||
|
||||
const passthrough = new PassThrough();
|
||||
const [result] = await Promise.all([
|
||||
decryptAttachmentV2ToSink(options, passthrough),
|
||||
await encryptAttachmentV2({
|
||||
keys,
|
||||
plaintext: {
|
||||
stream: passthrough,
|
||||
},
|
||||
sink: createWriteStream(absoluteTargetPath),
|
||||
getAbsoluteAttachmentPath: options.getAbsoluteAttachmentPath,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
...result,
|
||||
localKey: toBase64(keys),
|
||||
iv: toBase64(result.iv),
|
||||
path: relativeTargetPath,
|
||||
version: 2,
|
||||
};
|
||||
} catch (error) {
|
||||
log.error(
|
||||
`${logId}: Failed to decrypt attachment`,
|
||||
|
@ -197,48 +526,19 @@ export async function decryptAttachmentV2({
|
|||
safeUnlinkSync(absoluteTargetPath);
|
||||
throw error;
|
||||
} finally {
|
||||
await Promise.all([readFd?.close(), writeFd?.close()]);
|
||||
await writeFd?.close();
|
||||
}
|
||||
|
||||
const ourMac = hmac.digest();
|
||||
const ourDigest = digest.digest();
|
||||
const ourPlaintextHash = plaintextHash.digest('hex');
|
||||
|
||||
strictAssert(
|
||||
ourMac.byteLength === ATTACHMENT_MAC_LENGTH,
|
||||
`${logId}: Failed to generate ourMac!`
|
||||
);
|
||||
strictAssert(
|
||||
theirMac != null && theirMac.byteLength === ATTACHMENT_MAC_LENGTH,
|
||||
`${logId}: Failed to find theirMac!`
|
||||
);
|
||||
strictAssert(
|
||||
ourDigest.byteLength === DIGEST_LENGTH,
|
||||
`${logId}: Failed to generate ourDigest!`
|
||||
);
|
||||
strictAssert(
|
||||
ourPlaintextHash.length === HEX_DIGEST_LENGTH,
|
||||
`${logId}: Failed to generate file hash!`
|
||||
);
|
||||
|
||||
if (!constantTimeEqual(ourMac, theirMac)) {
|
||||
throw new Error(`${logId}: Bad MAC`);
|
||||
}
|
||||
|
||||
if (!constantTimeEqual(ourDigest, theirDigest)) {
|
||||
throw new Error(`${logId}: Bad digest`);
|
||||
}
|
||||
|
||||
return {
|
||||
path: relativeTargetPath,
|
||||
plaintextHash: ourPlaintextHash,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits the keys into aes and mac keys.
|
||||
*/
|
||||
function splitKeys(keys: Uint8Array) {
|
||||
|
||||
type AttachmentEncryptionKeysType = {
|
||||
aesKey: Uint8Array;
|
||||
macKey: Uint8Array;
|
||||
};
|
||||
export function splitKeys(keys: Uint8Array): AttachmentEncryptionKeysType {
|
||||
strictAssert(
|
||||
keys.byteLength === KEY_SET_LENGTH,
|
||||
`attachment keys must be ${KEY_SET_LENGTH} bytes, got ${keys.byteLength}`
|
||||
|
@ -248,6 +548,10 @@ function splitKeys(keys: Uint8Array) {
|
|||
return { aesKey, macKey };
|
||||
}
|
||||
|
||||
export function generateKeys(): Uint8Array {
|
||||
return randomBytes(KEY_SET_LENGTH);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a hash of the stream without modifying it.
|
||||
*/
|
||||
|
@ -264,191 +568,33 @@ function peekAndUpdateHash(hash: Hash) {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an hmac with the stream except for the last ATTACHMENT_MAC_LENGTH
|
||||
* bytes. The last ATTACHMENT_MAC_LENGTH bytes are passed to the callback.
|
||||
*/
|
||||
function getMacAndUpdateHmac(
|
||||
hmac: Hmac,
|
||||
onTheirMac: (theirMac: Uint8Array) => void
|
||||
) {
|
||||
// Because we don't have a view of the entire stream, we don't know when we're
|
||||
// at the end. We need to omit the last ATTACHMENT_MAC_LENGTH bytes from
|
||||
// `hmac.update` so we only push what we know is not the mac.
|
||||
let maybeMacBytes = Buffer.alloc(0);
|
||||
|
||||
function updateWithKnownNonMacBytes() {
|
||||
let knownNonMacBytes = null;
|
||||
if (maybeMacBytes.byteLength > ATTACHMENT_MAC_LENGTH) {
|
||||
knownNonMacBytes = maybeMacBytes.subarray(0, -ATTACHMENT_MAC_LENGTH);
|
||||
maybeMacBytes = maybeMacBytes.subarray(-ATTACHMENT_MAC_LENGTH);
|
||||
hmac.update(knownNonMacBytes);
|
||||
}
|
||||
return knownNonMacBytes;
|
||||
}
|
||||
|
||||
return new Transform({
|
||||
transform(chunk, _encoding, callback) {
|
||||
try {
|
||||
maybeMacBytes = Buffer.concat([maybeMacBytes, chunk]);
|
||||
const knownNonMac = updateWithKnownNonMacBytes();
|
||||
callback(null, knownNonMac);
|
||||
} catch (error) {
|
||||
callback(error);
|
||||
}
|
||||
},
|
||||
flush(callback) {
|
||||
try {
|
||||
onTheirMac(maybeMacBytes);
|
||||
callback(null, null);
|
||||
} catch (error) {
|
||||
callback(error);
|
||||
}
|
||||
},
|
||||
export function measureSize(onComplete: (size: number) => void): Transform {
|
||||
let totalBytes = 0;
|
||||
const passthrough = new PassThrough();
|
||||
passthrough.on('data', chunk => {
|
||||
totalBytes += chunk.length;
|
||||
});
|
||||
passthrough.on('end', () => {
|
||||
onComplete(totalBytes);
|
||||
});
|
||||
return passthrough;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the IV from the start of the stream and creates a decipher.
|
||||
* Then deciphers the rest of the stream.
|
||||
*/
|
||||
function getIvAndDecipher(aesKey: Uint8Array) {
|
||||
let maybeIvBytes: Buffer | null = Buffer.alloc(0);
|
||||
let decipher: Decipher | null = null;
|
||||
return new Transform({
|
||||
transform(chunk, _encoding, callback) {
|
||||
try {
|
||||
// If we've already initialized the decipher, just pass the chunk through.
|
||||
if (decipher != null) {
|
||||
callback(null, decipher.update(chunk));
|
||||
return;
|
||||
}
|
||||
export function getAttachmentCiphertextLength(plaintextLength: number): number {
|
||||
const paddedPlaintextSize = logPadSize(plaintextLength);
|
||||
|
||||
// Wait until we have enough bytes to get the iv to initialize the
|
||||
// decipher.
|
||||
maybeIvBytes = Buffer.concat([maybeIvBytes, chunk]);
|
||||
if (maybeIvBytes.byteLength < IV_LENGTH) {
|
||||
callback(null, null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Once we have enough bytes, initialize the decipher and pass the
|
||||
// remainder of the bytes through.
|
||||
const iv = maybeIvBytes.subarray(0, IV_LENGTH);
|
||||
const remainder = maybeIvBytes.subarray(IV_LENGTH);
|
||||
maybeIvBytes = null; // free memory
|
||||
decipher = createDecipheriv(CipherType.AES256CBC, aesKey, iv);
|
||||
callback(null, decipher.update(remainder));
|
||||
} catch (error) {
|
||||
callback(error);
|
||||
}
|
||||
},
|
||||
flush(callback) {
|
||||
try {
|
||||
strictAssert(decipher != null, 'decipher must be set');
|
||||
callback(null, decipher.final());
|
||||
} catch (error) {
|
||||
callback(error);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncates the stream to the target size.
|
||||
*/
|
||||
function trimPadding(size: number) {
|
||||
let total = 0;
|
||||
return new Transform({
|
||||
transform(chunk, _encoding, callback) {
|
||||
const chunkSize = chunk.byteLength;
|
||||
const sizeLeft = size - total;
|
||||
if (sizeLeft >= chunkSize) {
|
||||
total += chunkSize;
|
||||
callback(null, chunk);
|
||||
} else if (sizeLeft > 0) {
|
||||
total += sizeLeft;
|
||||
callback(null, chunk.subarray(0, sizeLeft));
|
||||
} else {
|
||||
callback(null, null);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function getAttachmentDownloadSize(size: number): number {
|
||||
return (
|
||||
// Multiply this by 1.05 to allow some variance
|
||||
getAttachmentSizeBucket(size) * 1.05 + IV_LENGTH + ATTACHMENT_MAC_LENGTH
|
||||
IV_LENGTH +
|
||||
getAesCbcCiphertextLength(paddedPlaintextSize) +
|
||||
ATTACHMENT_MAC_LENGTH
|
||||
);
|
||||
}
|
||||
|
||||
const PADDING_CHUNK_SIZE = 64 * 1024;
|
||||
|
||||
/**
|
||||
* Creates iterator that yields zero-filled padding chunks.
|
||||
*/
|
||||
function* generatePadding(size: number) {
|
||||
const targetLength = getAttachmentSizeBucket(size);
|
||||
const paddingSize = targetLength - size;
|
||||
const paddingChunks = Math.floor(paddingSize / PADDING_CHUNK_SIZE);
|
||||
const paddingChunk = new Uint8Array(PADDING_CHUNK_SIZE); // zero-filled
|
||||
const paddingRemainder = new Uint8Array(paddingSize % PADDING_CHUNK_SIZE);
|
||||
for (let i = 0; i < paddingChunks; i += 1) {
|
||||
yield paddingChunk;
|
||||
}
|
||||
if (paddingRemainder.byteLength > 0) {
|
||||
yield paddingRemainder;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends zero-padding to the stream to a target bucket size.
|
||||
*/
|
||||
function appendPadding(fileSize: number) {
|
||||
const iterator = generatePadding(fileSize);
|
||||
let bytesWritten = 0;
|
||||
let finalCallback: TransformCallback;
|
||||
|
||||
// Push as much padding as we can. If we reach the end
|
||||
// of the padding, call the callback.
|
||||
function pushPadding(transform: Transform) {
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
const result = iterator.next();
|
||||
if (result.done) {
|
||||
break;
|
||||
}
|
||||
const keepGoing = transform.push(result.value);
|
||||
if (!keepGoing) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
finalCallback();
|
||||
}
|
||||
|
||||
return new Transform({
|
||||
read(size) {
|
||||
// When in the process of pushing padding, we pause and wait for
|
||||
// read to be called again.
|
||||
if (finalCallback != null) {
|
||||
pushPadding(this);
|
||||
}
|
||||
// Always call _read, even if we're done.
|
||||
Transform.prototype._read.call(this, size);
|
||||
},
|
||||
transform(chunk, _encoding, callback) {
|
||||
bytesWritten += chunk.byteLength;
|
||||
// Once we reach the end of the file, start pushing padding.
|
||||
if (bytesWritten >= fileSize) {
|
||||
this.push(chunk);
|
||||
finalCallback = callback;
|
||||
pushPadding(this);
|
||||
return;
|
||||
}
|
||||
callback(null, chunk);
|
||||
},
|
||||
});
|
||||
export function getAesCbcCiphertextLength(plaintextLength: number): number {
|
||||
const AES_CBC_BLOCK_SIZE = 16;
|
||||
return (
|
||||
(1 + Math.floor(plaintextLength / AES_CBC_BLOCK_SIZE)) * AES_CBC_BLOCK_SIZE
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -459,99 +605,7 @@ function prependIv(iv: Uint8Array) {
|
|||
iv.byteLength === IV_LENGTH,
|
||||
`prependIv: iv should be ${IV_LENGTH} bytes, got ${iv.byteLength} bytes`
|
||||
);
|
||||
return new Transform({
|
||||
construct(callback) {
|
||||
this.push(iv);
|
||||
callback();
|
||||
},
|
||||
transform(chunk, _encoding, callback) {
|
||||
callback(null, chunk);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends the mac to the end of the stream.
|
||||
*/
|
||||
function appendMac(macKey: Uint8Array) {
|
||||
strictAssert(
|
||||
macKey.byteLength === KEY_LENGTH,
|
||||
`macKey should be ${KEY_LENGTH} bytes, got ${macKey.byteLength} bytes`
|
||||
);
|
||||
const hmac = createHmac(HashType.size256, macKey);
|
||||
return new Transform({
|
||||
transform(chunk, _encoding, callback) {
|
||||
try {
|
||||
hmac.update(chunk);
|
||||
callback(null, chunk);
|
||||
} catch (error) {
|
||||
callback(error);
|
||||
}
|
||||
},
|
||||
flush(callback) {
|
||||
try {
|
||||
callback(null, hmac.digest());
|
||||
} catch (error) {
|
||||
callback(error);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Called during message schema migration. New messages downloaded should have
|
||||
* plaintextHash added automatically during decryption / writing to file system.
|
||||
*/
|
||||
export async function addPlaintextHashToAttachment(
|
||||
attachment: AttachmentType,
|
||||
{ getAbsoluteAttachmentPath }: ContextType
|
||||
): Promise<AttachmentType> {
|
||||
if (!attachment.path) {
|
||||
return attachment;
|
||||
}
|
||||
|
||||
const plaintextHash = await getPlaintextHashForAttachmentOnDisk(
|
||||
getAbsoluteAttachmentPath(attachment.path)
|
||||
);
|
||||
|
||||
if (!plaintextHash) {
|
||||
log.error('addPlaintextHashToAttachment: Failed to generate hash');
|
||||
return attachment;
|
||||
}
|
||||
|
||||
return {
|
||||
...attachment,
|
||||
plaintextHash,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getPlaintextHashForAttachmentOnDisk(
|
||||
absolutePath: string
|
||||
): Promise<string | undefined> {
|
||||
let readFd;
|
||||
try {
|
||||
try {
|
||||
readFd = await open(absolutePath, 'r');
|
||||
} catch (error) {
|
||||
log.error('addPlaintextHashToAttachment: Target path does not exist');
|
||||
return undefined;
|
||||
}
|
||||
const hash = createHash(HashType.size256);
|
||||
await pipeline(readFd.createReadStream(), hash);
|
||||
const plaintextHash = hash.digest('hex');
|
||||
if (!plaintextHash) {
|
||||
log.error(
|
||||
'addPlaintextHashToAttachment: no hash generated from file; is the file empty?'
|
||||
);
|
||||
return;
|
||||
}
|
||||
return plaintextHash;
|
||||
} catch (error) {
|
||||
log.error('addPlaintextHashToAttachment: error during file read', error);
|
||||
return undefined;
|
||||
} finally {
|
||||
await readFd?.close();
|
||||
}
|
||||
return prependStream(iv);
|
||||
}
|
||||
|
||||
export function getPlaintextHashForInMemoryAttachment(
|
||||
|
|
|
@ -8,6 +8,9 @@ const bytes = globalThis.window?.SignalContext?.bytes || new Bytes();
|
|||
export function fromBase64(value: string): Uint8Array {
|
||||
return bytes.fromBase64(value);
|
||||
}
|
||||
export function fromBase64url(value: string): Uint8Array {
|
||||
return bytes.fromBase64url(value);
|
||||
}
|
||||
|
||||
export function fromHex(value: string): Uint8Array {
|
||||
return bytes.fromHex(value);
|
||||
|
@ -26,6 +29,10 @@ export function toBase64(data: Uint8Array): string {
|
|||
return bytes.toBase64(data);
|
||||
}
|
||||
|
||||
export function toBase64url(data: Uint8Array): string {
|
||||
return bytes.toBase64url(data);
|
||||
}
|
||||
|
||||
export function toHex(data: Uint8Array): string {
|
||||
return bytes.toHex(data);
|
||||
}
|
||||
|
|
25
ts/CI.ts
25
ts/CI.ts
|
@ -7,7 +7,8 @@ import type { IPCResponse as ChallengeResponseType } from './challenge';
|
|||
import type { MessageAttributesType } from './model-types.d';
|
||||
import * as log from './logging/log';
|
||||
import { explodePromise } from './util/explodePromise';
|
||||
import { ipcInvoke } from './sql/channels';
|
||||
import { AccessType, ipcInvoke } from './sql/channels';
|
||||
import { backupsService } from './services/backups';
|
||||
import { SECOND } from './util/durations';
|
||||
import { isSignalRoute } from './util/signalRoutes';
|
||||
import { strictAssert } from './util/assert';
|
||||
|
@ -16,6 +17,7 @@ type ResolveType = (data: unknown) => void;
|
|||
|
||||
export type CIType = {
|
||||
deviceName: string;
|
||||
backupData?: Uint8Array;
|
||||
getConversationId: (address: string | null) => string | null;
|
||||
getMessagesBySentAt(
|
||||
sentAt: number
|
||||
|
@ -31,9 +33,16 @@ export type CIType = {
|
|||
}
|
||||
) => unknown;
|
||||
openSignalRoute(url: string): Promise<void>;
|
||||
exportBackupToDisk(path: string): Promise<void>;
|
||||
unlink: () => void;
|
||||
};
|
||||
|
||||
export function getCI(deviceName: string): CIType {
|
||||
export type GetCIOptionsType = Readonly<{
|
||||
deviceName: string;
|
||||
backupData?: Uint8Array;
|
||||
}>;
|
||||
|
||||
export function getCI({ deviceName, backupData }: GetCIOptionsType): CIType {
|
||||
const eventListeners = new Map<string, Array<ResolveType>>();
|
||||
const completedEvents = new Map<string, Array<unknown>>();
|
||||
|
||||
|
@ -119,6 +128,7 @@ export function getCI(deviceName: string): CIType {
|
|||
|
||||
async function getMessagesBySentAt(sentAt: number) {
|
||||
const messages = await ipcInvoke<ReadonlyArray<MessageAttributesType>>(
|
||||
AccessType.Read,
|
||||
'getMessagesBySentAt',
|
||||
[sentAt]
|
||||
);
|
||||
|
@ -150,8 +160,17 @@ export function getCI(deviceName: string): CIType {
|
|||
document.body.removeChild(a);
|
||||
}
|
||||
|
||||
async function exportBackupToDisk(path: string) {
|
||||
await backupsService.exportToDisk(path);
|
||||
}
|
||||
|
||||
function unlink() {
|
||||
window.Whisper.events.trigger('unlinkAndDisconnect');
|
||||
}
|
||||
|
||||
return {
|
||||
deviceName,
|
||||
backupData,
|
||||
getConversationId,
|
||||
getMessagesBySentAt,
|
||||
handleEvent,
|
||||
|
@ -159,5 +178,7 @@ export function getCI(deviceName: string): CIType {
|
|||
solveChallenge,
|
||||
waitForEvent,
|
||||
openSignalRoute,
|
||||
exportBackupToDisk,
|
||||
unlink,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@ import { v4 as uuid } from 'uuid';
|
|||
import { incrementMessageCounter } from '../util/incrementMessageCounter';
|
||||
import { ReadStatus } from '../messages/MessageReadStatus';
|
||||
import { SendStatus } from '../messages/MessageSendState';
|
||||
import { DataWriter } from '../sql/Client';
|
||||
import { BodyRange } from '../types/BodyRange';
|
||||
import { strictAssert } from '../util/assert';
|
||||
import { MINUTE } from '../util/durations';
|
||||
|
@ -50,7 +51,7 @@ export async function populateConversationWithMessages({
|
|||
);
|
||||
|
||||
log.info(`${logId}: destroying all messages in ${conversationId}`);
|
||||
await conversation.destroyMessages();
|
||||
await conversation.destroyMessages({ source: 'local-delete' });
|
||||
|
||||
log.info(`${logId}: adding ${messageCount} messages to ${conversationId}`);
|
||||
let timestamp = Date.now();
|
||||
|
@ -86,13 +87,13 @@ export async function populateConversationWithMessages({
|
|||
timestamp += 1;
|
||||
}
|
||||
|
||||
await window.Signal.Data.saveMessages(messages, {
|
||||
await DataWriter.saveMessages(messages, {
|
||||
forceSave: true,
|
||||
ourAci,
|
||||
});
|
||||
|
||||
conversation.set('active_at', Date.now());
|
||||
await window.Signal.Data.updateConversation(conversation.attributes);
|
||||
await DataWriter.updateConversation(conversation.attributes);
|
||||
log.info(`${logId}: populating conversation complete`);
|
||||
}
|
||||
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
import { debounce, pick, uniq, without } from 'lodash';
|
||||
import PQueue from 'p-queue';
|
||||
import { v4 as generateUuid } from 'uuid';
|
||||
import { batch as batchDispatch } from 'react-redux';
|
||||
|
||||
import type {
|
||||
ConversationModelCollectionType,
|
||||
|
@ -13,10 +14,10 @@ import type {
|
|||
} from './model-types.d';
|
||||
import type { ConversationModel } from './models/conversations';
|
||||
|
||||
import dataInterface from './sql/Client';
|
||||
import { DataReader, DataWriter } from './sql/Client';
|
||||
import * as log from './logging/log';
|
||||
import * as Errors from './types/errors';
|
||||
import { getContactId } from './messages/helpers';
|
||||
import { getAuthorId } from './messages/helpers';
|
||||
import { maybeDeriveGroupV2Id } from './groups';
|
||||
import { assertDev, strictAssert } from './util/assert';
|
||||
import { drop } from './util/drop';
|
||||
|
@ -126,11 +127,15 @@ const {
|
|||
getAllConversations,
|
||||
getAllGroupsInvolvingServiceId,
|
||||
getMessagesBySentAt,
|
||||
} = DataReader;
|
||||
|
||||
const {
|
||||
migrateConversationMessages,
|
||||
removeConversation,
|
||||
saveConversation,
|
||||
updateConversation,
|
||||
} = dataInterface;
|
||||
updateConversations,
|
||||
} = DataWriter;
|
||||
|
||||
// We have to run this in background.js, after all backbone models and collections on
|
||||
// Whisper.* have been created. Once those are in typescript we can use more reasonable
|
||||
|
@ -261,7 +266,7 @@ export class ConversationController {
|
|||
getOrCreate(
|
||||
identifier: string | null,
|
||||
type: ConversationAttributesTypeType,
|
||||
additionalInitialProps = {}
|
||||
additionalInitialProps: Partial<ConversationAttributesType> = {}
|
||||
): ConversationModel {
|
||||
if (typeof identifier !== 'string') {
|
||||
throw new TypeError("'id' must be a string");
|
||||
|
@ -357,7 +362,7 @@ export class ConversationController {
|
|||
async getOrCreateAndWait(
|
||||
id: string | null,
|
||||
type: ConversationAttributesTypeType,
|
||||
additionalInitialProps = {}
|
||||
additionalInitialProps: Partial<ConversationAttributesType> = {}
|
||||
): Promise<ConversationModel> {
|
||||
await this.load();
|
||||
const conversation = this.getOrCreate(id, type, additionalInitialProps);
|
||||
|
@ -442,12 +447,12 @@ export class ConversationController {
|
|||
conversation.set({
|
||||
profileAvatar: { hash: SIGNAL_AVATAR_PATH, path: SIGNAL_AVATAR_PATH },
|
||||
});
|
||||
updateConversation(conversation.attributes);
|
||||
await updateConversation(conversation.attributes);
|
||||
}
|
||||
|
||||
if (!conversation.get('profileName')) {
|
||||
conversation.set({ profileName: 'Signal' });
|
||||
updateConversation(conversation.attributes);
|
||||
await updateConversation(conversation.attributes);
|
||||
}
|
||||
|
||||
this._signalConversationId = conversation.id;
|
||||
|
@ -720,6 +725,7 @@ export class ConversationController {
|
|||
(targetOldServiceIds.pni !== pni ||
|
||||
(aci && targetOldServiceIds.aci !== aci))
|
||||
) {
|
||||
targetConversation.unset('needsTitleTransition');
|
||||
mergePromises.push(
|
||||
targetConversation.addPhoneNumberDiscoveryIfNeeded(
|
||||
targetOldServiceIds.pni
|
||||
|
@ -830,7 +836,7 @@ export class ConversationController {
|
|||
// Note: `doCombineConversations` is directly used within this function since both
|
||||
// run on `_combineConversationsQueue` queue and we don't want deadlocks.
|
||||
private async doCheckForConflicts(): Promise<void> {
|
||||
log.info('checkForConflicts: starting...');
|
||||
log.info('ConversationController.checkForConflicts: starting...');
|
||||
const byServiceId = Object.create(null);
|
||||
const byE164 = Object.create(null);
|
||||
const byGroupV2Id = Object.create(null);
|
||||
|
@ -932,7 +938,7 @@ export class ConversationController {
|
|||
);
|
||||
|
||||
existing.set({ e164: undefined });
|
||||
updateConversation(existing.attributes);
|
||||
drop(updateConversation(existing.attributes));
|
||||
|
||||
byE164[e164] = conversation;
|
||||
|
||||
|
@ -1056,6 +1062,8 @@ export class ConversationController {
|
|||
}
|
||||
current.set('active_at', activeAt);
|
||||
|
||||
const currentHadMessages = (current.get('messageCount') ?? 0) > 0;
|
||||
|
||||
const dataToCopy: Partial<ConversationAttributesType> = pick(
|
||||
obsolete.attributes,
|
||||
[
|
||||
|
@ -1067,6 +1075,7 @@ export class ConversationController {
|
|||
'draftTimestamp',
|
||||
'messageCount',
|
||||
'messageRequestResponseType',
|
||||
'needsTitleTransition',
|
||||
'profileSharing',
|
||||
'quotedMessageId',
|
||||
'sentMessageCount',
|
||||
|
@ -1128,9 +1137,8 @@ export class ConversationController {
|
|||
log.warn(
|
||||
`${logId}: Ensure that all V1 groups have new conversationId instead of old`
|
||||
);
|
||||
const groups = await this.getAllGroupsInvolvingServiceId(
|
||||
obsoleteServiceId
|
||||
);
|
||||
const groups =
|
||||
await this.getAllGroupsInvolvingServiceId(obsoleteServiceId);
|
||||
groups.forEach(group => {
|
||||
const members = group.get('members');
|
||||
const withoutObsolete = without(members, obsoleteId);
|
||||
|
@ -1139,7 +1147,7 @@ export class ConversationController {
|
|||
group.set({
|
||||
members: currentAdded,
|
||||
});
|
||||
updateConversation(group.attributes);
|
||||
drop(updateConversation(group.attributes));
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -1196,7 +1204,15 @@ export class ConversationController {
|
|||
const titleIsUseful = Boolean(
|
||||
obsoleteTitleInfo && getTitleNoDefault(obsoleteTitleInfo)
|
||||
);
|
||||
if (obsoleteTitleInfo && titleIsUseful && obsoleteHadMessages) {
|
||||
// If both conversations had messages - add merge
|
||||
if (
|
||||
titleIsUseful &&
|
||||
conversationType === 'private' &&
|
||||
currentHadMessages &&
|
||||
obsoleteHadMessages
|
||||
) {
|
||||
assertDev(obsoleteTitleInfo, 'part of titleIsUseful boolean');
|
||||
|
||||
drop(current.addConversationMerge(obsoleteTitleInfo));
|
||||
}
|
||||
|
||||
|
@ -1223,7 +1239,7 @@ export class ConversationController {
|
|||
targetTimestamp: number
|
||||
): Promise<ConversationModel | null | undefined> {
|
||||
const messages = await getMessagesBySentAt(targetTimestamp);
|
||||
const targetMessage = messages.find(m => getContactId(m) === targetFromId);
|
||||
const targetMessage = messages.find(m => getAuthorId(m) === targetFromId);
|
||||
|
||||
if (targetMessage) {
|
||||
return this.get(targetMessage.conversationId);
|
||||
|
@ -1324,7 +1340,7 @@ export class ConversationController {
|
|||
);
|
||||
convo.set('isPinned', true);
|
||||
|
||||
window.Signal.Data.updateConversation(convo.attributes);
|
||||
drop(updateConversation(convo.attributes));
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1340,7 +1356,7 @@ export class ConversationController {
|
|||
`updating ${sharedWith.length} conversations`
|
||||
);
|
||||
|
||||
await window.Signal.Data.updateConversations(
|
||||
await updateConversations(
|
||||
sharedWith.map(c => {
|
||||
c.unset('shareMyPhoneNumber');
|
||||
return c.attributes;
|
||||
|
@ -1408,13 +1424,17 @@ export class ConversationController {
|
|||
);
|
||||
await queue.onIdle();
|
||||
|
||||
// Hydrate the final set of conversations
|
||||
this._conversations.add(
|
||||
collection.filter(conversation => !conversation.isTemporary)
|
||||
);
|
||||
|
||||
// It is alright to call it first because the 'add'/'update' events are
|
||||
// triggered after updating the collection.
|
||||
this._initialFetchComplete = true;
|
||||
|
||||
// Hydrate the final set of conversations
|
||||
batchDispatch(() => {
|
||||
this._conversations.add(
|
||||
collection.filter(conversation => !conversation.isTemporary)
|
||||
);
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
this._conversations.map(async conversation => {
|
||||
try {
|
||||
|
@ -1423,7 +1443,7 @@ export class ConversationController {
|
|||
|
||||
const isChanged = maybeDeriveGroupV2Id(conversation);
|
||||
if (isChanged) {
|
||||
updateConversation(conversation.attributes);
|
||||
await updateConversation(conversation.attributes);
|
||||
}
|
||||
|
||||
// In case a too-large draft was saved to the database
|
||||
|
@ -1432,7 +1452,7 @@ export class ConversationController {
|
|||
conversation.set({
|
||||
draft: draft.slice(0, MAX_MESSAGE_BODY_LENGTH),
|
||||
});
|
||||
updateConversation(conversation.attributes);
|
||||
await updateConversation(conversation.attributes);
|
||||
}
|
||||
|
||||
// Clean up the conversations that have service id as their e164.
|
||||
|
@ -1440,7 +1460,7 @@ export class ConversationController {
|
|||
const serviceId = conversation.getServiceId();
|
||||
if (e164 && isServiceIdString(e164) && serviceId) {
|
||||
conversation.set({ e164: undefined });
|
||||
updateConversation(conversation.attributes);
|
||||
await updateConversation(conversation.attributes);
|
||||
|
||||
log.info(
|
||||
`Cleaning up conversation(${serviceId}) with invalid e164`
|
||||
|
@ -1454,7 +1474,10 @@ export class ConversationController {
|
|||
}
|
||||
})
|
||||
);
|
||||
log.info('ConversationController: done with initial fetch');
|
||||
log.info(
|
||||
'ConversationController: done with initial fetch, ' +
|
||||
`got ${this._conversations.length} conversations`
|
||||
);
|
||||
} catch (error) {
|
||||
log.error(
|
||||
'ConversationController: initial fetch failed',
|
||||
|
|
212
ts/Crypto.ts
212
ts/Crypto.ts
|
@ -6,10 +6,12 @@ import Long from 'long';
|
|||
import { HKDF } from '@signalapp/libsignal-client';
|
||||
|
||||
import * as Bytes from './Bytes';
|
||||
import { Crypto } from './context/Crypto';
|
||||
import { calculateAgreement, generateKeyPair } from './Curve';
|
||||
import { HashType, CipherType } from './types/Crypto';
|
||||
import { HashType, CipherType, UUID_BYTE_SIZE } from './types/Crypto';
|
||||
import { ProfileDecryptError } from './types/errors';
|
||||
import { getBytesSubarray } from './util/uuidToBytes';
|
||||
import { logPadSize } from './util/logPadding';
|
||||
import { Environment } from './environment';
|
||||
|
||||
export { HashType, CipherType };
|
||||
|
@ -140,6 +142,201 @@ export function deriveStorageManifestKey(
|
|||
return hmacSha256(storageServiceKey, Bytes.fromString(`Manifest_${version}`));
|
||||
}
|
||||
|
||||
const BACKUP_KEY_LEN = 32;
|
||||
const BACKUP_KEY_INFO = '20231003_Signal_Backups_GenerateBackupKey';
|
||||
|
||||
export function deriveBackupKey(masterKey: Uint8Array): Uint8Array {
|
||||
const hkdf = HKDF.new(3);
|
||||
return hkdf.deriveSecrets(
|
||||
BACKUP_KEY_LEN,
|
||||
Buffer.from(masterKey),
|
||||
Buffer.from(BACKUP_KEY_INFO),
|
||||
Buffer.alloc(0)
|
||||
);
|
||||
}
|
||||
|
||||
const BACKUP_SIGNATURE_KEY_LEN = 32;
|
||||
const BACKUP_SIGNATURE_KEY_INFO =
|
||||
'20231003_Signal_Backups_GenerateBackupIdKeyPair';
|
||||
|
||||
export function deriveBackupSignatureKey(
|
||||
backupKey: Uint8Array,
|
||||
aciBytes: Uint8Array
|
||||
): Uint8Array {
|
||||
if (backupKey.byteLength !== BACKUP_KEY_LEN) {
|
||||
throw new Error('deriveBackupId: invalid backup key length');
|
||||
}
|
||||
|
||||
if (aciBytes.byteLength !== UUID_BYTE_SIZE) {
|
||||
throw new Error('deriveBackupId: invalid aci length');
|
||||
}
|
||||
|
||||
const hkdf = HKDF.new(3);
|
||||
return hkdf.deriveSecrets(
|
||||
BACKUP_SIGNATURE_KEY_LEN,
|
||||
Buffer.from(backupKey),
|
||||
Buffer.from(BACKUP_SIGNATURE_KEY_INFO),
|
||||
Buffer.from(aciBytes)
|
||||
);
|
||||
}
|
||||
|
||||
const BACKUP_ID_LEN = 16;
|
||||
const BACKUP_ID_INFO = '20231003_Signal_Backups_GenerateBackupId';
|
||||
|
||||
export function deriveBackupId(
|
||||
backupKey: Uint8Array,
|
||||
aciBytes: Uint8Array
|
||||
): Uint8Array {
|
||||
if (backupKey.byteLength !== BACKUP_KEY_LEN) {
|
||||
throw new Error('deriveBackupId: invalid backup key length');
|
||||
}
|
||||
|
||||
if (aciBytes.byteLength !== UUID_BYTE_SIZE) {
|
||||
throw new Error('deriveBackupId: invalid aci length');
|
||||
}
|
||||
|
||||
const hkdf = HKDF.new(3);
|
||||
return hkdf.deriveSecrets(
|
||||
BACKUP_ID_LEN,
|
||||
Buffer.from(backupKey),
|
||||
Buffer.from(BACKUP_ID_INFO),
|
||||
Buffer.from(aciBytes)
|
||||
);
|
||||
}
|
||||
|
||||
export type BackupKeyMaterialType = Readonly<{
|
||||
macKey: Uint8Array;
|
||||
aesKey: Uint8Array;
|
||||
}>;
|
||||
|
||||
export type BackupMediaKeyMaterialType = Readonly<{
|
||||
macKey: Uint8Array;
|
||||
aesKey: Uint8Array;
|
||||
iv: Uint8Array;
|
||||
}>;
|
||||
|
||||
const BACKUP_AES_KEY_LEN = 32;
|
||||
const BACKUP_MAC_KEY_LEN = 32;
|
||||
const BACKUP_MATERIAL_INFO = '20231003_Signal_Backups_EncryptMessageBackup';
|
||||
|
||||
const BACKUP_MEDIA_ID_INFO = '20231003_Signal_Backups_Media_ID';
|
||||
const BACKUP_MEDIA_ID_LEN = 15;
|
||||
const BACKUP_MEDIA_ENCRYPT_INFO = '20231003_Signal_Backups_EncryptMedia';
|
||||
const BACKUP_MEDIA_THUMBNAIL_ENCRYPT_INFO =
|
||||
'20240513_Signal_Backups_EncryptThumbnail';
|
||||
const BACKUP_MEDIA_AES_KEY_LEN = 32;
|
||||
const BACKUP_MEDIA_MAC_KEY_LEN = 32;
|
||||
const BACKUP_MEDIA_IV_LEN = 16;
|
||||
|
||||
export function deriveBackupKeyMaterial(
|
||||
backupKey: Uint8Array,
|
||||
backupId: Uint8Array
|
||||
): BackupKeyMaterialType {
|
||||
if (backupKey.byteLength !== BACKUP_KEY_LEN) {
|
||||
throw new Error('deriveBackupId: invalid backup key length');
|
||||
}
|
||||
|
||||
if (backupId.byteLength !== BACKUP_ID_LEN) {
|
||||
throw new Error('deriveBackupId: invalid backup id length');
|
||||
}
|
||||
|
||||
const hkdf = HKDF.new(3);
|
||||
const material = hkdf.deriveSecrets(
|
||||
BACKUP_AES_KEY_LEN + BACKUP_MAC_KEY_LEN,
|
||||
Buffer.from(backupKey),
|
||||
Buffer.from(BACKUP_MATERIAL_INFO),
|
||||
Buffer.from(backupId)
|
||||
);
|
||||
|
||||
return {
|
||||
macKey: material.slice(0, BACKUP_MAC_KEY_LEN),
|
||||
aesKey: material.slice(BACKUP_MAC_KEY_LEN),
|
||||
};
|
||||
}
|
||||
|
||||
export function deriveMediaIdFromMediaName(
|
||||
backupKey: Uint8Array,
|
||||
mediaName: string
|
||||
): Uint8Array {
|
||||
if (backupKey.byteLength !== BACKUP_KEY_LEN) {
|
||||
throw new Error('deriveMediaIdFromMediaName: invalid backup key length');
|
||||
}
|
||||
|
||||
if (!mediaName) {
|
||||
throw new Error('deriveMediaIdFromMediaName: mediaName missing');
|
||||
}
|
||||
|
||||
const hkdf = HKDF.new(3);
|
||||
return hkdf.deriveSecrets(
|
||||
BACKUP_MEDIA_ID_LEN,
|
||||
Buffer.from(backupKey),
|
||||
Buffer.from(BACKUP_MEDIA_ID_INFO),
|
||||
Buffer.from(Bytes.fromBase64(mediaName))
|
||||
);
|
||||
}
|
||||
|
||||
export function deriveBackupMediaKeyMaterial(
|
||||
backupKey: Uint8Array,
|
||||
mediaId: Uint8Array
|
||||
): BackupMediaKeyMaterialType {
|
||||
if (backupKey.byteLength !== BACKUP_KEY_LEN) {
|
||||
throw new Error('deriveBackupMediaKeyMaterial: invalid backup key length');
|
||||
}
|
||||
|
||||
if (!mediaId.length) {
|
||||
throw new Error('deriveBackupMediaKeyMaterial: mediaId missing');
|
||||
}
|
||||
|
||||
const hkdf = HKDF.new(3);
|
||||
const material = hkdf.deriveSecrets(
|
||||
BACKUP_MEDIA_MAC_KEY_LEN + BACKUP_MEDIA_AES_KEY_LEN + BACKUP_MEDIA_IV_LEN,
|
||||
Buffer.from(backupKey),
|
||||
Buffer.from(BACKUP_MEDIA_ENCRYPT_INFO),
|
||||
Buffer.from(mediaId)
|
||||
);
|
||||
|
||||
return {
|
||||
macKey: material.subarray(0, BACKUP_MEDIA_MAC_KEY_LEN),
|
||||
aesKey: material.subarray(
|
||||
BACKUP_MEDIA_MAC_KEY_LEN,
|
||||
BACKUP_MEDIA_MAC_KEY_LEN + BACKUP_MEDIA_AES_KEY_LEN
|
||||
),
|
||||
iv: material.subarray(BACKUP_MEDIA_MAC_KEY_LEN + BACKUP_MEDIA_AES_KEY_LEN),
|
||||
};
|
||||
}
|
||||
|
||||
export function deriveBackupMediaThumbnailInnerEncryptionKeyMaterial(
|
||||
backupKey: Uint8Array,
|
||||
mediaId: Uint8Array
|
||||
): BackupMediaKeyMaterialType {
|
||||
if (backupKey.byteLength !== BACKUP_KEY_LEN) {
|
||||
throw new Error(
|
||||
'deriveBackupMediaThumbnailKeyMaterial: invalid backup key length'
|
||||
);
|
||||
}
|
||||
|
||||
if (!mediaId.length) {
|
||||
throw new Error('deriveBackupMediaThumbnailKeyMaterial: mediaId missing');
|
||||
}
|
||||
|
||||
const hkdf = HKDF.new(3);
|
||||
const material = hkdf.deriveSecrets(
|
||||
BACKUP_MEDIA_MAC_KEY_LEN + BACKUP_MEDIA_AES_KEY_LEN + BACKUP_MEDIA_IV_LEN,
|
||||
Buffer.from(backupKey),
|
||||
Buffer.from(BACKUP_MEDIA_THUMBNAIL_ENCRYPT_INFO),
|
||||
Buffer.from(mediaId)
|
||||
);
|
||||
|
||||
return {
|
||||
aesKey: material.subarray(0, BACKUP_MEDIA_AES_KEY_LEN),
|
||||
macKey: material.subarray(
|
||||
BACKUP_MEDIA_AES_KEY_LEN,
|
||||
BACKUP_MEDIA_AES_KEY_LEN + BACKUP_MEDIA_MAC_KEY_LEN
|
||||
),
|
||||
iv: material.subarray(BACKUP_MEDIA_MAC_KEY_LEN + BACKUP_MEDIA_AES_KEY_LEN),
|
||||
};
|
||||
}
|
||||
|
||||
export function deriveStorageItemKey(
|
||||
storageServiceKey: Uint8Array,
|
||||
itemID: string
|
||||
|
@ -462,13 +659,6 @@ export function encryptAttachment({
|
|||
};
|
||||
}
|
||||
|
||||
export function getAttachmentSizeBucket(size: number): number {
|
||||
return Math.max(
|
||||
541,
|
||||
Math.floor(1.05 ** Math.ceil(Math.log(size) / Math.log(1.05)))
|
||||
);
|
||||
}
|
||||
|
||||
export function padAndEncryptAttachment({
|
||||
plaintext,
|
||||
keys,
|
||||
|
@ -479,7 +669,7 @@ export function padAndEncryptAttachment({
|
|||
dangerousTestOnlyIv?: Readonly<Uint8Array>;
|
||||
}): EncryptedAttachment {
|
||||
const size = plaintext.byteLength;
|
||||
const paddedSize = getAttachmentSizeBucket(size);
|
||||
const paddedSize = logPadSize(size);
|
||||
const padding = getZeroes(paddedSize - size);
|
||||
|
||||
return {
|
||||
|
@ -533,7 +723,7 @@ export function decryptProfile(data: Uint8Array, key: Uint8Array): Uint8Array {
|
|||
export function encryptProfileItemWithPadding(
|
||||
item: Uint8Array,
|
||||
profileKey: Uint8Array,
|
||||
paddedLengths: typeof PaddedLengths[keyof typeof PaddedLengths]
|
||||
paddedLengths: (typeof PaddedLengths)[keyof typeof PaddedLengths]
|
||||
): Uint8Array {
|
||||
const paddedLength = paddedLengths.find(
|
||||
(length: number) => item.byteLength <= length
|
||||
|
@ -580,7 +770,7 @@ export function decryptProfileName(
|
|||
// SignalContext APIs
|
||||
//
|
||||
|
||||
const { crypto } = globalThis.window?.SignalContext ?? {};
|
||||
const crypto = globalThis.window?.SignalContext.crypto || new Crypto();
|
||||
|
||||
export function sign(key: Uint8Array, data: Uint8Array): Uint8Array {
|
||||
return crypto.sign(key, data);
|
||||
|
|
|
@ -16,14 +16,20 @@ import { getCountryCode } from './types/PhoneNumber';
|
|||
|
||||
export type ConfigKeyType =
|
||||
| 'desktop.calling.adhoc'
|
||||
| 'desktop.calling.adhoc.create'
|
||||
| 'desktop.calling.raiseHand'
|
||||
| 'desktop.clientExpiration'
|
||||
| 'desktop.groupMultiTypingIndicators'
|
||||
| 'desktop.backup.credentialFetch'
|
||||
| 'desktop.internalUser'
|
||||
| 'desktop.mediaQuality.levels'
|
||||
| 'desktop.messageCleanup'
|
||||
| 'desktop.retryRespondMaxAge'
|
||||
| 'desktop.senderKey.retry'
|
||||
| 'desktop.senderKeyMaxAge'
|
||||
| 'desktop.experimentalTransportEnabled.alpha'
|
||||
| 'desktop.experimentalTransportEnabled.beta'
|
||||
| 'desktop.experimentalTransportEnabled.prod'
|
||||
| 'desktop.cdsiViaLibsignal'
|
||||
| 'global.attachments.maxBytes'
|
||||
| 'global.attachments.maxReceiveBytes'
|
||||
| 'global.calling.maxGroupCallRingSize'
|
||||
|
|
|
@ -18,10 +18,13 @@ import {
|
|||
SignedPreKeyRecord,
|
||||
} from '@signalapp/libsignal-client';
|
||||
|
||||
import { DataReader, DataWriter } from './sql/Client';
|
||||
import type { ItemType } from './sql/Interface';
|
||||
import * as Bytes from './Bytes';
|
||||
import { constantTimeEqual, sha256 } from './Crypto';
|
||||
import { assertDev, strictAssert } from './util/assert';
|
||||
import { isNotNil } from './util/isNotNil';
|
||||
import { drop } from './util/drop';
|
||||
import { Zone } from './util/Zone';
|
||||
import { isMoreRecentThan } from './util/timestamp';
|
||||
import {
|
||||
|
@ -136,6 +139,11 @@ export type SessionTransactionOptions = Readonly<{
|
|||
zone?: Zone;
|
||||
}>;
|
||||
|
||||
export type SaveIdentityOptions = Readonly<{
|
||||
zone?: Zone;
|
||||
noOverwrite?: boolean;
|
||||
}>;
|
||||
|
||||
export type VerifyAlternateIdentityOptionsType = Readonly<{
|
||||
aci: AciString;
|
||||
pni: PniString;
|
||||
|
@ -289,7 +297,9 @@ export class SignalProtocolStore extends EventEmitter {
|
|||
await Promise.all([
|
||||
(async () => {
|
||||
this.ourIdentityKeys.clear();
|
||||
const map = await window.Signal.Data.getItemById('identityKeyMap');
|
||||
const map = (await DataReader.getItemById(
|
||||
'identityKeyMap'
|
||||
)) as unknown as ItemType<'identityKeyMap'>;
|
||||
if (!map) {
|
||||
return;
|
||||
}
|
||||
|
@ -308,7 +318,9 @@ export class SignalProtocolStore extends EventEmitter {
|
|||
})(),
|
||||
(async () => {
|
||||
this.ourRegistrationIds.clear();
|
||||
const map = await window.Signal.Data.getItemById('registrationIdMap');
|
||||
const map = (await DataReader.getItemById(
|
||||
'registrationIdMap'
|
||||
)) as unknown as ItemType<'registrationIdMap'>;
|
||||
if (!map) {
|
||||
return;
|
||||
}
|
||||
|
@ -324,32 +336,32 @@ export class SignalProtocolStore extends EventEmitter {
|
|||
_fillCaches<string, IdentityKeyType, PublicKey>(
|
||||
this,
|
||||
'identityKeys',
|
||||
window.Signal.Data.getAllIdentityKeys()
|
||||
DataReader.getAllIdentityKeys()
|
||||
),
|
||||
_fillCaches<string, KyberPreKeyType, KyberPreKeyRecord>(
|
||||
this,
|
||||
'kyberPreKeys',
|
||||
window.Signal.Data.getAllKyberPreKeys()
|
||||
DataReader.getAllKyberPreKeys()
|
||||
),
|
||||
_fillCaches<string, SessionType, SessionRecord>(
|
||||
this,
|
||||
'sessions',
|
||||
window.Signal.Data.getAllSessions()
|
||||
DataReader.getAllSessions()
|
||||
),
|
||||
_fillCaches<string, PreKeyType, PreKeyRecord>(
|
||||
this,
|
||||
'preKeys',
|
||||
window.Signal.Data.getAllPreKeys()
|
||||
DataReader.getAllPreKeys()
|
||||
),
|
||||
_fillCaches<string, SenderKeyType, SenderKeyRecord>(
|
||||
this,
|
||||
'senderKeys',
|
||||
window.Signal.Data.getAllSenderKeys()
|
||||
DataReader.getAllSenderKeys()
|
||||
),
|
||||
_fillCaches<string, SignedPreKeyType, SignedPreKeyRecord>(
|
||||
this,
|
||||
'signedPreKeys',
|
||||
window.Signal.Data.getAllSignedPreKeys()
|
||||
DataReader.getAllSignedPreKeys()
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
@ -465,7 +477,7 @@ export class SignalProtocolStore extends EventEmitter {
|
|||
},
|
||||
};
|
||||
|
||||
await window.Signal.Data.createOrUpdateKyberPreKey(confirmedItem.fromDB);
|
||||
await DataWriter.createOrUpdateKyberPreKey(confirmedItem.fromDB);
|
||||
kyberPreKeyCache.set(id, confirmedItem);
|
||||
}
|
||||
|
||||
|
@ -500,7 +512,7 @@ export class SignalProtocolStore extends EventEmitter {
|
|||
toSave.push(kyberPreKey);
|
||||
});
|
||||
|
||||
await window.Signal.Data.bulkAddKyberPreKeys(toSave);
|
||||
await DataWriter.bulkAddKyberPreKeys(toSave);
|
||||
toSave.forEach(kyberPreKey => {
|
||||
kyberPreKeyCache.set(kyberPreKey.id, {
|
||||
hydrated: false,
|
||||
|
@ -541,7 +553,7 @@ export class SignalProtocolStore extends EventEmitter {
|
|||
const ids = keyIds.map(keyId => this._getKeyId(ourServiceId, keyId));
|
||||
|
||||
log.info('removeKyberPreKeys: Removing kyber prekeys:', formatKeys(keyIds));
|
||||
const changes = await window.Signal.Data.removeKyberPreKeyById(ids);
|
||||
const changes = await DataWriter.removeKyberPreKeyById(ids);
|
||||
log.info(`removeKyberPreKeys: Removed ${changes} kyber prekeys`);
|
||||
ids.forEach(id => {
|
||||
kyberPreKeyCache.delete(id);
|
||||
|
@ -559,7 +571,7 @@ export class SignalProtocolStore extends EventEmitter {
|
|||
if (this.kyberPreKeys) {
|
||||
this.kyberPreKeys.clear();
|
||||
}
|
||||
const changes = await window.Signal.Data.removeAllKyberPreKeys();
|
||||
const changes = await DataWriter.removeAllKyberPreKeys();
|
||||
log.info(`clearKyberPreKeyStore: Removed ${changes} kyber prekeys`);
|
||||
}
|
||||
|
||||
|
@ -641,7 +653,7 @@ export class SignalProtocolStore extends EventEmitter {
|
|||
});
|
||||
|
||||
log.info(`storePreKeys: Saving ${toSave.length} prekeys`);
|
||||
await window.Signal.Data.bulkAddPreKeys(toSave);
|
||||
await DataWriter.bulkAddPreKeys(toSave);
|
||||
toSave.forEach(preKey => {
|
||||
preKeyCache.set(preKey.id, {
|
||||
hydrated: false,
|
||||
|
@ -663,7 +675,7 @@ export class SignalProtocolStore extends EventEmitter {
|
|||
|
||||
log.info('removePreKeys: Removing prekeys:', formatKeys(keyIds));
|
||||
|
||||
const changes = await window.Signal.Data.removePreKeyById(ids);
|
||||
const changes = await DataWriter.removePreKeyById(ids);
|
||||
log.info(`removePreKeys: Removed ${changes} prekeys`);
|
||||
ids.forEach(id => {
|
||||
preKeyCache.delete(id);
|
||||
|
@ -678,7 +690,7 @@ export class SignalProtocolStore extends EventEmitter {
|
|||
if (this.preKeys) {
|
||||
this.preKeys.clear();
|
||||
}
|
||||
const changes = await window.Signal.Data.removeAllPreKeys();
|
||||
const changes = await DataWriter.removeAllPreKeys();
|
||||
log.info(`clearPreKeyStore: Removed ${changes} prekeys`);
|
||||
}
|
||||
|
||||
|
@ -764,7 +776,7 @@ export class SignalProtocolStore extends EventEmitter {
|
|||
},
|
||||
};
|
||||
|
||||
await window.Signal.Data.createOrUpdateSignedPreKey(confirmedItem.fromDB);
|
||||
await DataWriter.createOrUpdateSignedPreKey(confirmedItem.fromDB);
|
||||
signedPreKeyCache.set(id, confirmedItem);
|
||||
}
|
||||
|
||||
|
@ -791,7 +803,7 @@ export class SignalProtocolStore extends EventEmitter {
|
|||
confirmed: Boolean(confirmed),
|
||||
};
|
||||
|
||||
await window.Signal.Data.createOrUpdateSignedPreKey(fromDB);
|
||||
await DataWriter.createOrUpdateSignedPreKey(fromDB);
|
||||
this.signedPreKeys.set(id, {
|
||||
hydrated: false,
|
||||
fromDB,
|
||||
|
@ -813,7 +825,7 @@ export class SignalProtocolStore extends EventEmitter {
|
|||
'removeSignedPreKeys: Removing signed prekeys:',
|
||||
formatKeys(keyIds)
|
||||
);
|
||||
await window.Signal.Data.removeSignedPreKeyById(ids);
|
||||
await DataWriter.removeSignedPreKeyById(ids);
|
||||
ids.forEach(id => {
|
||||
signedPreKeyCache.delete(id);
|
||||
});
|
||||
|
@ -823,7 +835,7 @@ export class SignalProtocolStore extends EventEmitter {
|
|||
if (this.signedPreKeys) {
|
||||
this.signedPreKeys.clear();
|
||||
}
|
||||
const changes = await window.Signal.Data.removeAllSignedPreKeys();
|
||||
const changes = await DataWriter.removeAllSignedPreKeys();
|
||||
log.info(`clearSignedPreKeysStore: Removed ${changes} signed prekeys`);
|
||||
}
|
||||
|
||||
|
@ -987,7 +999,7 @@ export class SignalProtocolStore extends EventEmitter {
|
|||
try {
|
||||
const id = this.getSenderKeyId(qualifiedAddress, distributionId);
|
||||
|
||||
await window.Signal.Data.removeSenderKeyById(id);
|
||||
await DataWriter.removeSenderKeyById(id);
|
||||
|
||||
this.senderKeys.delete(id);
|
||||
} catch (error) {
|
||||
|
@ -1006,7 +1018,7 @@ export class SignalProtocolStore extends EventEmitter {
|
|||
if (this.pendingSenderKeys) {
|
||||
this.pendingSenderKeys.clear();
|
||||
}
|
||||
await window.Signal.Data.removeAllSenderKeys();
|
||||
await DataWriter.removeAllSenderKeys();
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -1186,7 +1198,7 @@ export class SignalProtocolStore extends EventEmitter {
|
|||
|
||||
// Commit both sender keys, sessions and unprocessed in the same database transaction
|
||||
// to unroll both on error.
|
||||
await window.Signal.Data.commitDecryptResult({
|
||||
await DataWriter.commitDecryptResult({
|
||||
senderKeys: Array.from(pendingSenderKeys.values()).map(
|
||||
({ fromDB }) => fromDB
|
||||
),
|
||||
|
@ -1588,7 +1600,7 @@ export class SignalProtocolStore extends EventEmitter {
|
|||
const id = qualifiedAddress.toString();
|
||||
log.info('removeSession: deleting session for', id);
|
||||
try {
|
||||
await window.Signal.Data.removeSessionById(id);
|
||||
await DataWriter.removeSessionById(id);
|
||||
this.sessions.delete(id);
|
||||
this.pendingSessions.delete(id);
|
||||
} catch (e) {
|
||||
|
@ -1635,7 +1647,7 @@ export class SignalProtocolStore extends EventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
await window.Signal.Data.removeSessionsByConversation(id);
|
||||
await DataWriter.removeSessionsByConversation(id);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
@ -1660,7 +1672,7 @@ export class SignalProtocolStore extends EventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
await window.Signal.Data.removeSessionsByServiceId(serviceId);
|
||||
await DataWriter.removeSessionsByServiceId(serviceId);
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -1767,7 +1779,7 @@ export class SignalProtocolStore extends EventEmitter {
|
|||
this.sessions.clear();
|
||||
}
|
||||
this.pendingSessions.clear();
|
||||
const changes = await window.Signal.Data.removeAllSessions();
|
||||
const changes = await DataWriter.removeAllSessions();
|
||||
log.info(`clearSessionStore: Removed ${changes} sessions`);
|
||||
});
|
||||
}
|
||||
|
@ -1888,9 +1900,7 @@ export class SignalProtocolStore extends EventEmitter {
|
|||
await this._saveIdentityKey(newRecord);
|
||||
|
||||
this.identityKeys.delete(record.fromDB.id);
|
||||
const changes = await window.Signal.Data.removeIdentityKeyById(
|
||||
record.fromDB.id
|
||||
);
|
||||
const changes = await DataWriter.removeIdentityKeyById(record.fromDB.id);
|
||||
|
||||
log.info(
|
||||
`getOrMigrateIdentityRecord: Removed ${changes} old identity keys for ${record.fromDB.id}`
|
||||
|
@ -2037,7 +2047,7 @@ export class SignalProtocolStore extends EventEmitter {
|
|||
|
||||
const { id } = data;
|
||||
|
||||
await window.Signal.Data.createOrUpdateIdentityKey(data);
|
||||
await DataWriter.createOrUpdateIdentityKey(data);
|
||||
this.identityKeys.set(id, {
|
||||
hydrated: false,
|
||||
fromDB: data,
|
||||
|
@ -2049,7 +2059,7 @@ export class SignalProtocolStore extends EventEmitter {
|
|||
encodedAddress: Address,
|
||||
publicKey: Uint8Array,
|
||||
nonblockingApproval = false,
|
||||
{ zone = GLOBAL_ZONE }: SessionTransactionOptions = {}
|
||||
{ zone = GLOBAL_ZONE, noOverwrite = false }: SaveIdentityOptions = {}
|
||||
): Promise<boolean> {
|
||||
if (!this.identityKeys) {
|
||||
throw new Error('saveIdentity: this.identityKeys not yet cached!');
|
||||
|
@ -2100,6 +2110,10 @@ export class SignalProtocolStore extends EventEmitter {
|
|||
return false;
|
||||
}
|
||||
|
||||
if (noOverwrite) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const identityKeyChanged = !constantTimeEqual(
|
||||
identityRecord.publicKey,
|
||||
publicKey
|
||||
|
@ -2336,7 +2350,7 @@ export class SignalProtocolStore extends EventEmitter {
|
|||
|
||||
// We only want to clear previousIdentityKey on a match, or on successfully emit.
|
||||
conversation.set({ previousIdentityKey: undefined });
|
||||
window.Signal.Data.updateConversation(conversation.attributes);
|
||||
drop(DataWriter.updateConversation(conversation.attributes));
|
||||
} catch (error) {
|
||||
log.error(
|
||||
'saveIdentity: error triggering keychange:',
|
||||
|
@ -2453,20 +2467,20 @@ export class SignalProtocolStore extends EventEmitter {
|
|||
|
||||
const id = serviceId;
|
||||
this.identityKeys.delete(id);
|
||||
await window.Signal.Data.removeIdentityKeyById(serviceId);
|
||||
await DataWriter.removeIdentityKeyById(serviceId);
|
||||
await this.removeSessionsByServiceId(serviceId);
|
||||
}
|
||||
|
||||
// Not yet processed messages - for resiliency
|
||||
getUnprocessedCount(): Promise<number> {
|
||||
return this.withZone(GLOBAL_ZONE, 'getUnprocessedCount', async () => {
|
||||
return window.Signal.Data.getUnprocessedCount();
|
||||
return DataReader.getUnprocessedCount();
|
||||
});
|
||||
}
|
||||
|
||||
getAllUnprocessedIds(): Promise<Array<string>> {
|
||||
return this.withZone(GLOBAL_ZONE, 'getAllUnprocessedIds', () => {
|
||||
return window.Signal.Data.getAllUnprocessedIds();
|
||||
return DataWriter.getAllUnprocessedIds();
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -2477,14 +2491,14 @@ export class SignalProtocolStore extends EventEmitter {
|
|||
GLOBAL_ZONE,
|
||||
'getAllUnprocessedByIdsAndIncrementAttempts',
|
||||
async () => {
|
||||
return window.Signal.Data.getUnprocessedByIdsAndIncrementAttempts(ids);
|
||||
return DataWriter.getUnprocessedByIdsAndIncrementAttempts(ids);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
getUnprocessedById(id: string): Promise<UnprocessedType | undefined> {
|
||||
return this.withZone(GLOBAL_ZONE, 'getUnprocessedById', async () => {
|
||||
return window.Signal.Data.getUnprocessedById(id);
|
||||
return DataReader.getUnprocessedById(id);
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -2522,7 +2536,7 @@ export class SignalProtocolStore extends EventEmitter {
|
|||
data: UnprocessedUpdateType
|
||||
): Promise<void> {
|
||||
return this.withZone(GLOBAL_ZONE, 'updateUnprocessedWithData', async () => {
|
||||
await window.Signal.Data.updateUnprocessedWithData(id, data);
|
||||
await DataWriter.updateUnprocessedWithData(id, data);
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -2533,14 +2547,14 @@ export class SignalProtocolStore extends EventEmitter {
|
|||
GLOBAL_ZONE,
|
||||
'updateUnprocessedsWithData',
|
||||
async () => {
|
||||
await window.Signal.Data.updateUnprocessedsWithData(items);
|
||||
await DataWriter.updateUnprocessedsWithData(items);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
removeUnprocessed(idOrArray: string | Array<string>): Promise<void> {
|
||||
return this.withZone(GLOBAL_ZONE, 'removeUnprocessed', async () => {
|
||||
await window.Signal.Data.removeUnprocessed(idOrArray);
|
||||
await DataWriter.removeUnprocessed(idOrArray);
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -2548,7 +2562,7 @@ export class SignalProtocolStore extends EventEmitter {
|
|||
removeAllUnprocessed(): Promise<void> {
|
||||
log.info('removeAllUnprocessed');
|
||||
return this.withZone(GLOBAL_ZONE, 'removeAllUnprocessed', async () => {
|
||||
await window.Signal.Data.removeAllUnprocessed();
|
||||
await DataWriter.removeAllUnprocessed();
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -2594,9 +2608,9 @@ export class SignalProtocolStore extends EventEmitter {
|
|||
'registrationIdMap',
|
||||
omit(storage.get('registrationIdMap') || {}, oldPni)
|
||||
),
|
||||
window.Signal.Data.removePreKeysByServiceId(oldPni),
|
||||
window.Signal.Data.removeSignedPreKeysByServiceId(oldPni),
|
||||
window.Signal.Data.removeKyberPreKeysByServiceId(oldPni),
|
||||
DataWriter.removePreKeysByServiceId(oldPni),
|
||||
DataWriter.removeSignedPreKeysByServiceId(oldPni),
|
||||
DataWriter.removeKyberPreKeysByServiceId(oldPni),
|
||||
]);
|
||||
}
|
||||
|
||||
|
@ -2686,7 +2700,7 @@ export class SignalProtocolStore extends EventEmitter {
|
|||
}
|
||||
|
||||
async removeAllData(): Promise<void> {
|
||||
await window.Signal.Data.removeAll();
|
||||
await DataWriter.removeAll();
|
||||
await this.hydrateCaches();
|
||||
|
||||
window.storage.reset();
|
||||
|
@ -2699,7 +2713,16 @@ export class SignalProtocolStore extends EventEmitter {
|
|||
}
|
||||
|
||||
async removeAllConfiguration(): Promise<void> {
|
||||
await window.Signal.Data.removeAllConfiguration();
|
||||
// Conversations. These properties are not present in redux.
|
||||
window.getConversations().forEach(conversation => {
|
||||
conversation.unset('storageID');
|
||||
conversation.unset('needsStorageServiceSync');
|
||||
conversation.unset('storageUnknownFields');
|
||||
conversation.unset('senderKeyInfo');
|
||||
});
|
||||
|
||||
await DataWriter.removeAllConfiguration();
|
||||
|
||||
await this.hydrateCaches();
|
||||
|
||||
window.storage.reset();
|
||||
|
|
|
@ -136,7 +136,7 @@ const triggerEvents = function (
|
|||
function trigger<
|
||||
T extends Backbone.Events & {
|
||||
_events: undefined | Record<string, ReadonlyArray<InternalBackboneEvent>>;
|
||||
}
|
||||
},
|
||||
>(this: T, name: string, ...args: Array<unknown>): T {
|
||||
if (!this._events) return this;
|
||||
if (!eventsApi(this, name, args)) return this;
|
||||
|
|
1580
ts/background.ts
1580
ts/background.ts
File diff suppressed because it is too large
Load diff
|
@ -2,6 +2,7 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import PQueue from 'p-queue';
|
||||
import { DataWriter } from '../sql/Client';
|
||||
import * as log from '../logging/log';
|
||||
import { MINUTE } from '../util/durations';
|
||||
import { missingCaseError } from '../util/missingCaseError';
|
||||
|
@ -86,7 +87,7 @@ function getUrlsToDownload(): Array<string> {
|
|||
}
|
||||
|
||||
async function downloadBadgeImageFile(url: string): Promise<string> {
|
||||
await waitForOnline(navigator, window, { timeout: 1 * MINUTE });
|
||||
await waitForOnline({ timeout: 1 * MINUTE });
|
||||
|
||||
const { server } = window.textsecure;
|
||||
if (!server) {
|
||||
|
@ -96,11 +97,10 @@ async function downloadBadgeImageFile(url: string): Promise<string> {
|
|||
}
|
||||
|
||||
const imageFileData = await server.getBadgeImageFile(url);
|
||||
const localPath = await window.Signal.Migrations.writeNewBadgeImageFileData(
|
||||
imageFileData
|
||||
);
|
||||
const localPath =
|
||||
await window.Signal.Migrations.writeNewBadgeImageFileData(imageFileData);
|
||||
|
||||
await window.Signal.Data.badgeImageFileDownloaded(url, localPath);
|
||||
await DataWriter.badgeImageFileDownloaded(url, localPath);
|
||||
|
||||
window.reduxActions.badges.badgeImageFileDownloaded(url, localPath);
|
||||
|
||||
|
|
|
@ -1,18 +0,0 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { makeEnumParser } from '../util/enum';
|
||||
import OS from '../util/os/osMain';
|
||||
|
||||
export enum AudioDeviceModule {
|
||||
Default = 'Default',
|
||||
WindowsAdm2 = 'WindowsAdm2',
|
||||
}
|
||||
|
||||
export const parseAudioDeviceModule = makeEnumParser(
|
||||
AudioDeviceModule,
|
||||
AudioDeviceModule.Default
|
||||
);
|
||||
|
||||
export const getAudioDeviceModule = (): AudioDeviceModule =>
|
||||
OS.isWindows() ? AudioDeviceModule.WindowsAdm2 : AudioDeviceModule.Default;
|
|
@ -46,7 +46,7 @@ type Handler = Readonly<{
|
|||
}>;
|
||||
|
||||
export type ChallengeData = Readonly<{
|
||||
type: 'recaptcha';
|
||||
type: 'captcha';
|
||||
token: string;
|
||||
captcha: string;
|
||||
}>;
|
||||
|
@ -259,8 +259,12 @@ export class ChallengeHandler {
|
|||
log.info(`${logId}: tracking ${conversationId} with no waitTime`);
|
||||
}
|
||||
|
||||
if (data && !data.options?.includes('recaptcha')) {
|
||||
log.error(`${logId}: unexpected options ${JSON.stringify(data.options)}`);
|
||||
if (data && !data.options?.includes('captcha')) {
|
||||
const dataString = JSON.stringify(data.options);
|
||||
log.error(
|
||||
`${logId}: unexpected options ${dataString}. ${conversationId} is waiting.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!challenge.token) {
|
||||
|
@ -330,6 +334,10 @@ export class ChallengeHandler {
|
|||
);
|
||||
}
|
||||
|
||||
public areAnyRegistered(): boolean {
|
||||
return this.registeredConversations.size > 0;
|
||||
}
|
||||
|
||||
public isRegistered(conversationId: string): boolean {
|
||||
return this.registeredConversations.has(conversationId);
|
||||
}
|
||||
|
@ -384,7 +392,7 @@ export class ChallengeHandler {
|
|||
|
||||
try {
|
||||
await this.sendChallengeResponse({
|
||||
type: 'recaptcha',
|
||||
type: 'captcha',
|
||||
token: lastToken,
|
||||
captcha,
|
||||
});
|
||||
|
@ -393,7 +401,11 @@ export class ChallengeHandler {
|
|||
`challenge(${reason}): challenge failure, error:`,
|
||||
Errors.toLogFormat(error)
|
||||
);
|
||||
this.options.setChallengeStatus('required');
|
||||
if (error.code === 413 || error.code === 429) {
|
||||
this.options.setChallengeStatus('idle');
|
||||
} else {
|
||||
this.options.setChallengeStatus('required');
|
||||
}
|
||||
this.solving -= 1;
|
||||
return;
|
||||
}
|
||||
|
@ -425,7 +437,8 @@ export class ChallengeHandler {
|
|||
|
||||
log.info(`challenge: retry after ${retryAfter}ms`);
|
||||
this.options.onChallengeFailed(retryAfter);
|
||||
return;
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.options.onChallengeSolved();
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
// Copyright 2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, { useContext } from 'react';
|
||||
import React from 'react';
|
||||
import type { Meta, StoryFn } from '@storybook/react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
|
||||
|
@ -13,7 +13,6 @@ import {
|
|||
} from '../test-both/helpers/getDefaultConversation';
|
||||
import { setupI18n } from '../util/setupI18n';
|
||||
import { AddUserToAnotherGroupModal } from './AddUserToAnotherGroupModal';
|
||||
import { StorybookThemeContext } from '../../.storybook/StorybookThemeContext';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
|
@ -36,7 +35,6 @@ const Template: StoryFn<Props> = args => {
|
|||
toggleAddUserToAnotherGroupModal={action(
|
||||
'toggleAddUserToAnotherGroupModal'
|
||||
)}
|
||||
theme={useContext(StorybookThemeContext)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -6,9 +6,9 @@ import React, { useCallback } from 'react';
|
|||
import type { ListRowProps } from 'react-virtualized';
|
||||
|
||||
import type { ConversationType } from '../state/ducks/conversations';
|
||||
import type { LocalizerType, ThemeType } from '../types/Util';
|
||||
import type { LocalizerType } from '../types/Util';
|
||||
import { ToastType } from '../types/Toast';
|
||||
import { filterAndSortConversationsByRecent } from '../util/filterAndSortConversations';
|
||||
import { filterAndSortConversations } from '../util/filterAndSortConversations';
|
||||
import { ConfirmationDialog } from './ConfirmationDialog';
|
||||
import type { GroupListItemConversationType } from './conversationList/GroupListItem';
|
||||
import {
|
||||
|
@ -25,7 +25,6 @@ import { SizeObserver } from '../hooks/useSizeObserver';
|
|||
|
||||
type OwnProps = {
|
||||
i18n: LocalizerType;
|
||||
theme: ThemeType;
|
||||
contact: Pick<ConversationType, 'id' | 'title' | 'serviceId' | 'pni'>;
|
||||
candidateConversations: ReadonlyArray<ConversationType>;
|
||||
regionCode: string | undefined;
|
||||
|
@ -57,7 +56,7 @@ export function AddUserToAnotherGroupModal({
|
|||
}: Props): JSX.Element | null {
|
||||
const [searchTerm, setSearchTerm] = React.useState('');
|
||||
const [filteredConversations, setFilteredConversations] = React.useState(
|
||||
filterAndSortConversationsByRecent(candidateConversations, '', undefined)
|
||||
filterAndSortConversations(candidateConversations, '', undefined)
|
||||
);
|
||||
|
||||
const [selectedGroupId, setSelectedGroupId] = React.useState<
|
||||
|
@ -79,7 +78,7 @@ export function AddUserToAnotherGroupModal({
|
|||
React.useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
setFilteredConversations(
|
||||
filterAndSortConversationsByRecent(
|
||||
filterAndSortConversations(
|
||||
candidateConversations,
|
||||
normalizedSearchTerm,
|
||||
regionCode
|
||||
|
@ -130,7 +129,7 @@ export function AddUserToAnotherGroupModal({
|
|||
}
|
||||
|
||||
return {
|
||||
...pick(convo, 'id', 'avatarPath', 'title', 'unblurredAvatarPath'),
|
||||
...pick(convo, 'id', 'avatarUrl', 'title', 'unblurredAvatarUrl'),
|
||||
memberships,
|
||||
membersCount,
|
||||
disabledReason,
|
||||
|
|
|
@ -6,7 +6,7 @@ import type {
|
|||
ConversationType,
|
||||
ShowConversationType,
|
||||
} from '../state/ducks/conversations';
|
||||
import { Intl } from './Intl';
|
||||
import { I18n } from './I18n';
|
||||
import type { LocalizerType, ThemeType } from '../types/Util';
|
||||
import { Modal } from './Modal';
|
||||
import { ConversationListItem } from './conversationList/ConversationListItem';
|
||||
|
@ -51,7 +51,7 @@ export function AnnouncementsOnlyGroupBanner({
|
|||
</Modal>
|
||||
)}
|
||||
<div className="AnnouncementsOnlyGroupBanner__banner">
|
||||
<Intl
|
||||
<I18n
|
||||
i18n={i18n}
|
||||
id="icu:AnnouncementsOnlyGroupBanner--announcements-only"
|
||||
components={{
|
||||
|
|
|
@ -17,14 +17,20 @@ import { useReducedMotion } from '../hooks/useReducedMotion';
|
|||
type PropsType = {
|
||||
appView: AppViewType;
|
||||
openInbox: () => void;
|
||||
getCaptchaToken: () => Promise<string>;
|
||||
registerSingleDevice: (
|
||||
number: string,
|
||||
code: string,
|
||||
sessionId: string
|
||||
) => Promise<void>;
|
||||
uploadProfile: (opts: {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
}) => Promise<void>;
|
||||
renderCallManager: () => JSX.Element;
|
||||
renderGlobalModalContainer: () => JSX.Element;
|
||||
hasSelectedStoryData: boolean;
|
||||
readyForUpdates: () => void;
|
||||
renderStoryViewer: (closeView: () => unknown) => JSX.Element;
|
||||
renderLightbox: () => JSX.Element | null;
|
||||
requestVerification: (
|
||||
|
@ -44,11 +50,13 @@ type PropsType = {
|
|||
|
||||
export function App({
|
||||
appView,
|
||||
getCaptchaToken,
|
||||
hasSelectedStoryData,
|
||||
isFullScreen,
|
||||
isMaximized,
|
||||
openInbox,
|
||||
osClassName,
|
||||
readyForUpdates,
|
||||
registerSingleDevice,
|
||||
renderCallManager,
|
||||
renderGlobalModalContainer,
|
||||
|
@ -57,6 +65,7 @@ export function App({
|
|||
renderStoryViewer,
|
||||
requestVerification,
|
||||
theme,
|
||||
uploadProfile,
|
||||
viewStory,
|
||||
}: PropsType): JSX.Element {
|
||||
let contents;
|
||||
|
@ -71,8 +80,11 @@ export function App({
|
|||
contents = (
|
||||
<StandaloneRegistration
|
||||
onComplete={onComplete}
|
||||
getCaptchaToken={getCaptchaToken}
|
||||
readyForUpdates={readyForUpdates}
|
||||
requestVerification={requestVerification}
|
||||
registerSingleDevice={registerSingleDevice}
|
||||
uploadProfile={uploadProfile}
|
||||
/>
|
||||
);
|
||||
} else if (appView === AppViewType.Inbox) {
|
||||
|
|
56
ts/components/AutoSizeTextArea.tsx
Normal file
56
ts/components/AutoSizeTextArea.tsx
Normal file
|
@ -0,0 +1,56 @@
|
|||
// Copyright 2024 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
import type { ForwardedRef } from 'react';
|
||||
import React, { forwardRef, useEffect, useLayoutEffect, useRef } from 'react';
|
||||
import { mergeRefs } from '@react-aria/utils';
|
||||
import { strictAssert } from '../util/assert';
|
||||
import type { PropsType } from './Input';
|
||||
import { Input } from './Input';
|
||||
|
||||
export const AutoSizeTextArea = forwardRef(function AutoSizeTextArea(
|
||||
props: PropsType,
|
||||
ref: ForwardedRef<HTMLTextAreaElement>
|
||||
): JSX.Element {
|
||||
const ownRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const textareaRef = mergeRefs(ownRef, ref);
|
||||
|
||||
function update(textarea: HTMLTextAreaElement) {
|
||||
const styles = window.getComputedStyle(textarea);
|
||||
const { scrollHeight } = textarea;
|
||||
let height = 'calc(';
|
||||
height += `${scrollHeight}px`;
|
||||
if (styles.boxSizing === 'border-box') {
|
||||
height += ` + ${styles.borderTopWidth} + ${styles.borderBottomWidth}`;
|
||||
} else {
|
||||
height += ` - ${styles.paddingTop} - ${styles.paddingBottom}`;
|
||||
}
|
||||
height += ')';
|
||||
Object.assign(textarea.style, {
|
||||
height,
|
||||
overflow: 'hidden',
|
||||
resize: 'none',
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
strictAssert(ownRef.current, 'inputRef.current should be defined');
|
||||
const textarea = ownRef.current;
|
||||
function onInput() {
|
||||
textarea.style.height = 'auto';
|
||||
requestAnimationFrame(() => update(textarea));
|
||||
}
|
||||
textarea.addEventListener('input', onInput);
|
||||
return () => {
|
||||
textarea.removeEventListener('input', onInput);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
strictAssert(ownRef.current, 'inputRef.current should be defined');
|
||||
const textarea = ownRef.current;
|
||||
textarea.style.height = 'auto';
|
||||
update(textarea);
|
||||
}, [props.value]);
|
||||
|
||||
return <Input ref={textareaRef} {...props} forceTextarea />;
|
||||
});
|
|
@ -4,9 +4,8 @@
|
|||
import type { Meta, StoryFn } from '@storybook/react';
|
||||
import * as React from 'react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { expect, jest } from '@storybook/jest';
|
||||
import { isBoolean } from 'lodash';
|
||||
import { within, userEvent } from '@storybook/testing-library';
|
||||
import { expect, fn, within, userEvent } from '@storybook/test';
|
||||
import type { AvatarColorType } from '../types/Colors';
|
||||
import type { Props } from './Avatar';
|
||||
import enMessages from '../../_locales/en/messages.json';
|
||||
|
@ -19,19 +18,6 @@ import { setupI18n } from '../util/setupI18n';
|
|||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
const colorMap: Record<string, AvatarColorType> = AvatarColors.reduce(
|
||||
(m, color) => ({
|
||||
...m,
|
||||
[color]: color,
|
||||
}),
|
||||
{}
|
||||
);
|
||||
|
||||
const conversationTypeMap: Record<string, Props['conversationType']> = {
|
||||
direct: 'direct',
|
||||
group: 'group',
|
||||
};
|
||||
|
||||
export default {
|
||||
title: 'Components/Avatar',
|
||||
component: Avatar,
|
||||
|
@ -41,19 +27,19 @@ export default {
|
|||
},
|
||||
blur: {
|
||||
control: { type: 'radio' },
|
||||
options: {
|
||||
Undefined: undefined,
|
||||
NoBlur: AvatarBlur.NoBlur,
|
||||
BlurPicture: AvatarBlur.BlurPicture,
|
||||
BlurPictureWithClickToView: AvatarBlur.BlurPictureWithClickToView,
|
||||
},
|
||||
options: [
|
||||
undefined,
|
||||
AvatarBlur.NoBlur,
|
||||
AvatarBlur.BlurPicture,
|
||||
AvatarBlur.BlurPictureWithClickToView,
|
||||
],
|
||||
},
|
||||
color: {
|
||||
options: colorMap,
|
||||
options: AvatarColors,
|
||||
},
|
||||
conversationType: {
|
||||
control: { type: 'radio' },
|
||||
options: conversationTypeMap,
|
||||
options: ['direct', 'group'],
|
||||
},
|
||||
size: {
|
||||
control: false,
|
||||
|
@ -64,7 +50,7 @@ export default {
|
|||
},
|
||||
theme: {
|
||||
control: { type: 'radio' },
|
||||
options: ThemeType,
|
||||
options: [ThemeType.light, ThemeType.dark],
|
||||
},
|
||||
},
|
||||
args: {
|
||||
|
@ -79,7 +65,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
|||
acceptedMessageRequest: isBoolean(overrideProps.acceptedMessageRequest)
|
||||
? overrideProps.acceptedMessageRequest
|
||||
: true,
|
||||
avatarPath: overrideProps.avatarPath || '',
|
||||
avatarUrl: overrideProps.avatarUrl || '',
|
||||
badge: overrideProps.badge,
|
||||
blur: overrideProps.blur,
|
||||
color: overrideProps.color || AvatarColors[0],
|
||||
|
@ -88,7 +74,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
|||
isMe: false,
|
||||
loading: Boolean(overrideProps.loading),
|
||||
noteToSelf: Boolean(overrideProps.noteToSelf),
|
||||
onClick: jest.fn(action('onClick')),
|
||||
onClick: fn(action('onClick')),
|
||||
onClickBadge: action('onClickBadge'),
|
||||
phoneNumber: overrideProps.phoneNumber || '',
|
||||
searchResult: Boolean(overrideProps.searchResult),
|
||||
|
@ -121,7 +107,7 @@ const TemplateSingle: StoryFn<Props> = (args: Props) => (
|
|||
|
||||
export const Default = Template.bind({});
|
||||
Default.args = createProps({
|
||||
avatarPath: '/fixtures/giphy-GVNvOUpeYmI7e.gif',
|
||||
avatarUrl: '/fixtures/giphy-GVNvOUpeYmI7e.gif',
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
Default.play = async (context: any) => {
|
||||
|
@ -134,13 +120,13 @@ Default.play = async (context: any) => {
|
|||
|
||||
export const WithBadge = Template.bind({});
|
||||
WithBadge.args = createProps({
|
||||
avatarPath: '/fixtures/kitten-3-64-64.jpg',
|
||||
avatarUrl: '/fixtures/kitten-3-64-64.jpg',
|
||||
badge: getFakeBadge(),
|
||||
});
|
||||
|
||||
export const WideImage = Template.bind({});
|
||||
WideImage.args = createProps({
|
||||
avatarPath: '/fixtures/wide.jpg',
|
||||
avatarUrl: '/fixtures/wide.jpg',
|
||||
});
|
||||
|
||||
export const OneWordName = Template.bind({});
|
||||
|
@ -200,12 +186,12 @@ BrokenColor.args = createProps({
|
|||
|
||||
export const BrokenAvatar = Template.bind({});
|
||||
BrokenAvatar.args = createProps({
|
||||
avatarPath: 'badimage.png',
|
||||
avatarUrl: 'badimage.png',
|
||||
});
|
||||
|
||||
export const BrokenAvatarForGroup = Template.bind({});
|
||||
BrokenAvatarForGroup.args = createProps({
|
||||
avatarPath: 'badimage.png',
|
||||
avatarUrl: 'badimage.png',
|
||||
conversationType: 'group',
|
||||
});
|
||||
|
||||
|
@ -217,29 +203,29 @@ Loading.args = createProps({
|
|||
export const BlurredBasedOnProps = TemplateSingle.bind({});
|
||||
BlurredBasedOnProps.args = createProps({
|
||||
acceptedMessageRequest: false,
|
||||
avatarPath: '/fixtures/kitten-3-64-64.jpg',
|
||||
avatarUrl: '/fixtures/kitten-3-64-64.jpg',
|
||||
});
|
||||
|
||||
export const ForceBlurred = TemplateSingle.bind({});
|
||||
ForceBlurred.args = createProps({
|
||||
avatarPath: '/fixtures/kitten-3-64-64.jpg',
|
||||
avatarUrl: '/fixtures/kitten-3-64-64.jpg',
|
||||
blur: AvatarBlur.BlurPicture,
|
||||
});
|
||||
|
||||
export const BlurredWithClickToView = TemplateSingle.bind({});
|
||||
BlurredWithClickToView.args = createProps({
|
||||
avatarPath: '/fixtures/kitten-3-64-64.jpg',
|
||||
avatarUrl: '/fixtures/kitten-3-64-64.jpg',
|
||||
blur: AvatarBlur.BlurPictureWithClickToView,
|
||||
});
|
||||
|
||||
export const StoryUnread = TemplateSingle.bind({});
|
||||
StoryUnread.args = createProps({
|
||||
avatarPath: '/fixtures/kitten-3-64-64.jpg',
|
||||
avatarUrl: '/fixtures/kitten-3-64-64.jpg',
|
||||
storyRing: HasStories.Unread,
|
||||
});
|
||||
|
||||
export const StoryRead = TemplateSingle.bind({});
|
||||
StoryRead.args = createProps({
|
||||
avatarPath: '/fixtures/kitten-3-64-64.jpg',
|
||||
avatarUrl: '/fixtures/kitten-3-64-64.jpg',
|
||||
storyRing: HasStories.Read,
|
||||
});
|
||||
|
|
|
@ -38,11 +38,13 @@ export enum AvatarSize {
|
|||
TWENTY = 20,
|
||||
TWENTY_FOUR = 24,
|
||||
TWENTY_EIGHT = 28,
|
||||
THIRTY = 30,
|
||||
THIRTY_TWO = 32,
|
||||
THIRTY_SIX = 36,
|
||||
FORTY = 40,
|
||||
FORTY_EIGHT = 48,
|
||||
FIFTY_TWO = 52,
|
||||
SIXTY_FOUR = 64,
|
||||
EIGHTY = 80,
|
||||
NINETY_SIX = 96,
|
||||
TWO_HUNDRED_SIXTEEN = 216,
|
||||
|
@ -51,7 +53,7 @@ export enum AvatarSize {
|
|||
type BadgePlacementType = { bottom: number; right: number };
|
||||
|
||||
export type Props = {
|
||||
avatarPath?: string;
|
||||
avatarUrl?: string;
|
||||
blur?: AvatarBlur;
|
||||
color?: AvatarColorType;
|
||||
loading?: boolean;
|
||||
|
@ -65,7 +67,7 @@ export type Props = {
|
|||
sharedGroupNames: ReadonlyArray<string>;
|
||||
size: AvatarSize;
|
||||
title: string;
|
||||
unblurredAvatarPath?: string;
|
||||
unblurredAvatarUrl?: string;
|
||||
searchResult?: boolean;
|
||||
storyRing?: HasStories;
|
||||
|
||||
|
@ -85,6 +87,7 @@ export type Props = {
|
|||
|
||||
const BADGE_PLACEMENT_BY_SIZE = new Map<number, BadgePlacementType>([
|
||||
[28, { bottom: -4, right: -2 }],
|
||||
[30, { bottom: -4, right: -2 }],
|
||||
[32, { bottom: -4, right: -2 }],
|
||||
[36, { bottom: -3, right: 0 }],
|
||||
[40, { bottom: -6, right: -4 }],
|
||||
|
@ -104,7 +107,7 @@ const getDefaultBlur = (
|
|||
|
||||
export function Avatar({
|
||||
acceptedMessageRequest,
|
||||
avatarPath,
|
||||
avatarUrl,
|
||||
badge,
|
||||
className,
|
||||
color = 'A200',
|
||||
|
@ -120,15 +123,15 @@ export function Avatar({
|
|||
size,
|
||||
theme,
|
||||
title,
|
||||
unblurredAvatarPath,
|
||||
unblurredAvatarUrl,
|
||||
searchResult,
|
||||
storyRing,
|
||||
blur = getDefaultBlur({
|
||||
acceptedMessageRequest,
|
||||
avatarPath,
|
||||
avatarUrl,
|
||||
isMe,
|
||||
sharedGroupNames,
|
||||
unblurredAvatarPath,
|
||||
unblurredAvatarUrl,
|
||||
}),
|
||||
...ariaProps
|
||||
}: Props): JSX.Element {
|
||||
|
@ -136,15 +139,15 @@ export function Avatar({
|
|||
|
||||
useEffect(() => {
|
||||
setImageBroken(false);
|
||||
}, [avatarPath]);
|
||||
}, [avatarUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!avatarPath) {
|
||||
if (!avatarUrl) {
|
||||
return noop;
|
||||
}
|
||||
|
||||
const image = new Image();
|
||||
image.src = avatarPath;
|
||||
image.src = avatarUrl;
|
||||
image.onerror = () => {
|
||||
log.warn('Avatar: Image failed to load; failing over to placeholder');
|
||||
setImageBroken(true);
|
||||
|
@ -153,12 +156,15 @@ export function Avatar({
|
|||
return () => {
|
||||
image.onerror = noop;
|
||||
};
|
||||
}, [avatarPath]);
|
||||
}, [avatarUrl]);
|
||||
|
||||
const initials = getInitials(title);
|
||||
const hasImage = !noteToSelf && avatarPath && !imageBroken;
|
||||
const hasImage = !noteToSelf && avatarUrl && !imageBroken;
|
||||
const shouldUseInitials =
|
||||
!hasImage && conversationType === 'direct' && Boolean(initials);
|
||||
!hasImage &&
|
||||
conversationType === 'direct' &&
|
||||
Boolean(initials) &&
|
||||
title !== i18n('icu:unknownContact');
|
||||
|
||||
let contentsChildren: ReactNode;
|
||||
if (loading) {
|
||||
|
@ -173,7 +179,7 @@ export function Avatar({
|
|||
</div>
|
||||
);
|
||||
} else if (hasImage) {
|
||||
assertDev(avatarPath, 'avatarPath should be defined here');
|
||||
assertDev(avatarUrl, 'avatarUrl should be defined here');
|
||||
|
||||
assertDev(
|
||||
blur !== AvatarBlur.BlurPictureWithClickToView ||
|
||||
|
@ -189,7 +195,7 @@ export function Avatar({
|
|||
<div
|
||||
className="module-Avatar__image"
|
||||
style={{
|
||||
backgroundImage: `url('${encodeURI(avatarPath)}')`,
|
||||
backgroundImage: `url('${avatarUrl}')`,
|
||||
...(isBlurred ? { filter: `blur(${Math.ceil(size / 2)}px)` } : {}),
|
||||
}}
|
||||
/>
|
||||
|
@ -310,7 +316,7 @@ export function Avatar({
|
|||
Boolean(storyRing) && 'module-Avatar--with-story',
|
||||
storyRing === HasStories.Unread && 'module-Avatar--with-story--unread',
|
||||
className,
|
||||
avatarPath === SIGNAL_AVATAR_PATH
|
||||
avatarUrl === SIGNAL_AVATAR_PATH
|
||||
? 'module-Avatar--signal-official'
|
||||
: undefined
|
||||
)}
|
||||
|
|
|
@ -18,7 +18,7 @@ const i18n = setupI18n('en', enMessages);
|
|||
|
||||
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
||||
avatarColor: overrideProps.avatarColor || AvatarColors[9],
|
||||
avatarPath: overrideProps.avatarPath,
|
||||
avatarUrl: overrideProps.avatarUrl,
|
||||
conversationId: '123',
|
||||
conversationTitle: overrideProps.conversationTitle || 'Default Title',
|
||||
deleteAvatarFromDisk: action('deleteAvatarFromDisk'),
|
||||
|
@ -104,7 +104,7 @@ export function HasAvatar(): JSX.Element {
|
|||
return (
|
||||
<AvatarEditor
|
||||
{...createProps({
|
||||
avatarPath: '/fixtures/kitten-3-64-64.jpg',
|
||||
avatarUrl: '/fixtures/kitten-3-64-64.jpg',
|
||||
})}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -24,7 +24,7 @@ import { missingCaseError } from '../util/missingCaseError';
|
|||
|
||||
export type PropsType = {
|
||||
avatarColor?: AvatarColorType;
|
||||
avatarPath?: string;
|
||||
avatarUrl?: string;
|
||||
avatarValue?: Uint8Array;
|
||||
conversationId?: string;
|
||||
conversationTitle?: string;
|
||||
|
@ -46,7 +46,7 @@ enum EditMode {
|
|||
|
||||
export function AvatarEditor({
|
||||
avatarColor,
|
||||
avatarPath,
|
||||
avatarUrl,
|
||||
avatarValue,
|
||||
conversationId,
|
||||
conversationTitle,
|
||||
|
@ -152,7 +152,7 @@ export function AvatarEditor({
|
|||
}, []);
|
||||
|
||||
const hasChanges =
|
||||
initialAvatar !== avatarPreview || Boolean(pendingClear && avatarPath);
|
||||
initialAvatar !== avatarPreview || Boolean(pendingClear && avatarUrl);
|
||||
|
||||
let content: JSX.Element | undefined;
|
||||
|
||||
|
@ -162,7 +162,7 @@ export function AvatarEditor({
|
|||
<div className="AvatarEditor__preview">
|
||||
<AvatarPreview
|
||||
avatarColor={avatarColor}
|
||||
avatarPath={pendingClear ? undefined : avatarPath}
|
||||
avatarUrl={pendingClear ? undefined : avatarUrl}
|
||||
avatarValue={avatarPreview}
|
||||
conversationTitle={conversationTitle}
|
||||
i18n={i18n}
|
||||
|
|
|
@ -45,5 +45,5 @@ export function Person(args: PropsType): JSX.Element {
|
|||
}
|
||||
|
||||
export function Photo(args: PropsType): JSX.Element {
|
||||
return <AvatarLightbox {...args} avatarPath="/fixtures/kitten-1-64-64.jpg" />;
|
||||
return <AvatarLightbox {...args} avatarUrl="/fixtures/kitten-1-64-64.jpg" />;
|
||||
}
|
||||
|
|
|
@ -11,7 +11,7 @@ import type { LocalizerType } from '../types/Util';
|
|||
|
||||
export type PropsType = {
|
||||
avatarColor?: AvatarColorType;
|
||||
avatarPath?: string;
|
||||
avatarUrl?: string;
|
||||
conversationTitle?: string;
|
||||
i18n: LocalizerType;
|
||||
isGroup?: boolean;
|
||||
|
@ -20,7 +20,7 @@ export type PropsType = {
|
|||
|
||||
export function AvatarLightbox({
|
||||
avatarColor,
|
||||
avatarPath,
|
||||
avatarUrl,
|
||||
conversationTitle,
|
||||
i18n,
|
||||
isGroup,
|
||||
|
@ -43,16 +43,17 @@ export function AvatarLightbox({
|
|||
>
|
||||
<AvatarPreview
|
||||
avatarColor={avatarColor}
|
||||
avatarPath={avatarPath}
|
||||
avatarUrl={avatarUrl}
|
||||
conversationTitle={conversationTitle}
|
||||
i18n={i18n}
|
||||
isGroup={isGroup}
|
||||
style={{
|
||||
fontSize: '16em',
|
||||
height: '2em',
|
||||
maxHeight: 512,
|
||||
maxWidth: 512,
|
||||
width: '2em',
|
||||
width: 'auto',
|
||||
minHeight: '64px',
|
||||
height: '100%',
|
||||
maxHeight: `min(${512}px, 100%)`,
|
||||
aspectRatio: '1 / 1',
|
||||
}}
|
||||
/>
|
||||
</Lightbox>
|
||||
|
|
|
@ -24,7 +24,7 @@ const TEST_IMAGE = new Uint8Array(
|
|||
|
||||
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
||||
avatarColor: overrideProps.avatarColor,
|
||||
avatarPath: overrideProps.avatarPath,
|
||||
avatarUrl: overrideProps.avatarUrl,
|
||||
avatarValue: overrideProps.avatarValue,
|
||||
conversationTitle: overrideProps.conversationTitle,
|
||||
i18n,
|
||||
|
@ -81,7 +81,7 @@ export function Value(): JSX.Element {
|
|||
export function Path(): JSX.Element {
|
||||
return (
|
||||
<AvatarPreview
|
||||
{...createProps({ avatarPath: '/fixtures/kitten-3-64-64.jpg' })}
|
||||
{...createProps({ avatarUrl: '/fixtures/kitten-3-64-64.jpg' })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -90,7 +90,7 @@ export function ValueAndPath(): JSX.Element {
|
|||
return (
|
||||
<AvatarPreview
|
||||
{...createProps({
|
||||
avatarPath: '/fixtures/kitten-3-64-64.jpg',
|
||||
avatarUrl: '/fixtures/kitten-3-64-64.jpg',
|
||||
avatarValue: TEST_IMAGE,
|
||||
})}
|
||||
/>
|
||||
|
|
|
@ -15,7 +15,7 @@ import { imagePathToBytes } from '../util/imagePathToBytes';
|
|||
|
||||
export type PropsType = {
|
||||
avatarColor?: AvatarColorType;
|
||||
avatarPath?: string;
|
||||
avatarUrl?: string;
|
||||
avatarValue?: Uint8Array;
|
||||
conversationTitle?: string;
|
||||
i18n: LocalizerType;
|
||||
|
@ -35,7 +35,7 @@ enum ImageStatus {
|
|||
|
||||
export function AvatarPreview({
|
||||
avatarColor = AvatarColors[0],
|
||||
avatarPath,
|
||||
avatarUrl,
|
||||
avatarValue,
|
||||
conversationTitle,
|
||||
i18n,
|
||||
|
@ -48,15 +48,15 @@ export function AvatarPreview({
|
|||
}: PropsType): JSX.Element {
|
||||
const [avatarPreview, setAvatarPreview] = useState<Uint8Array | undefined>();
|
||||
|
||||
// Loads the initial avatarPath if one is provided, but only if we're in editable mode.
|
||||
// If we're not editable, we assume that we either have an avatarPath or we show a
|
||||
// Loads the initial avatarUrl if one is provided, but only if we're in editable mode.
|
||||
// If we're not editable, we assume that we either have an avatarUrl or we show a
|
||||
// default avatar.
|
||||
useEffect(() => {
|
||||
if (!isEditable) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!avatarPath) {
|
||||
if (!avatarUrl) {
|
||||
return noop;
|
||||
}
|
||||
|
||||
|
@ -64,7 +64,7 @@ export function AvatarPreview({
|
|||
|
||||
void (async () => {
|
||||
try {
|
||||
const buffer = await imagePathToBytes(avatarPath);
|
||||
const buffer = await imagePathToBytes(avatarUrl);
|
||||
if (shouldCancel) {
|
||||
return;
|
||||
}
|
||||
|
@ -85,7 +85,7 @@ export function AvatarPreview({
|
|||
return () => {
|
||||
shouldCancel = true;
|
||||
};
|
||||
}, [avatarPath, onAvatarLoaded, isEditable]);
|
||||
}, [avatarUrl, onAvatarLoaded, isEditable]);
|
||||
|
||||
// Ensures that when avatarValue changes we generate new URLs
|
||||
useEffect(() => {
|
||||
|
@ -120,8 +120,8 @@ export function AvatarPreview({
|
|||
} else if (objectUrl) {
|
||||
encodedPath = objectUrl;
|
||||
imageStatus = ImageStatus.HasImage;
|
||||
} else if (avatarPath) {
|
||||
encodedPath = encodeURI(avatarPath);
|
||||
} else if (avatarUrl) {
|
||||
encodedPath = avatarUrl;
|
||||
imageStatus = ImageStatus.HasImage;
|
||||
} else {
|
||||
imageStatus = ImageStatus.Nothing;
|
||||
|
|
|
@ -5,13 +5,13 @@ import React from 'react';
|
|||
import classNames from 'classnames';
|
||||
|
||||
export type PropsType = {
|
||||
avatarPath?: string;
|
||||
avatarUrl?: string;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function CallBackgroundBlur({
|
||||
avatarPath,
|
||||
avatarUrl,
|
||||
children,
|
||||
className,
|
||||
}: PropsType): JSX.Element {
|
||||
|
@ -19,15 +19,15 @@ export function CallBackgroundBlur({
|
|||
<div
|
||||
className={classNames(
|
||||
'module-calling__background',
|
||||
!avatarPath && 'module-calling__background--no-avatar',
|
||||
!avatarUrl && 'module-calling__background--no-avatar',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{avatarPath && (
|
||||
{avatarUrl && (
|
||||
<div
|
||||
className="module-calling__background--blur"
|
||||
style={{
|
||||
backgroundImage: `url('${encodeURI(avatarPath)}')`,
|
||||
backgroundImage: `url('${avatarUrl}')`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
|
28
ts/components/CallLinkAddNameModal.stories.tsx
Normal file
28
ts/components/CallLinkAddNameModal.stories.tsx
Normal file
|
@ -0,0 +1,28 @@
|
|||
// Copyright 2024 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { setupI18n } from '../util/setupI18n';
|
||||
import enMessages from '../../_locales/en/messages.json';
|
||||
import type { CallLinkAddNameModalProps } from './CallLinkAddNameModal';
|
||||
import { CallLinkAddNameModal } from './CallLinkAddNameModal';
|
||||
import type { ComponentMeta } from '../storybook/types';
|
||||
import { FAKE_CALL_LINK_WITH_ADMIN_KEY } from '../test-both/helpers/fakeCallLink';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
export default {
|
||||
title: 'Components/CallLinkAddNameModal',
|
||||
component: CallLinkAddNameModal,
|
||||
args: {
|
||||
i18n,
|
||||
callLink: FAKE_CALL_LINK_WITH_ADMIN_KEY,
|
||||
onClose: action('onClose'),
|
||||
onUpdateCallLinkName: action('onUpdateCallLinkName'),
|
||||
},
|
||||
} satisfies ComponentMeta<CallLinkAddNameModalProps>;
|
||||
|
||||
export function Basic(args: CallLinkAddNameModalProps): JSX.Element {
|
||||
return <CallLinkAddNameModal {...args} />;
|
||||
}
|
120
ts/components/CallLinkAddNameModal.tsx
Normal file
120
ts/components/CallLinkAddNameModal.tsx
Normal file
|
@ -0,0 +1,120 @@
|
|||
// Copyright 2024 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { v4 as generateUuid } from 'uuid';
|
||||
import { Modal } from './Modal';
|
||||
import type { LocalizerType } from '../types/I18N';
|
||||
import { Button, ButtonVariant } from './Button';
|
||||
import { Avatar, AvatarSize } from './Avatar';
|
||||
import { Input } from './Input';
|
||||
import {
|
||||
CallLinkNameMaxByteLength,
|
||||
type CallLinkType,
|
||||
} from '../types/CallLink';
|
||||
import { getColorForCallLink } from '../util/getColorForCallLink';
|
||||
|
||||
export type CallLinkAddNameModalProps = Readonly<{
|
||||
i18n: LocalizerType;
|
||||
callLink: CallLinkType;
|
||||
onClose: () => void;
|
||||
onUpdateCallLinkName: (name: string) => void;
|
||||
}>;
|
||||
|
||||
export function CallLinkAddNameModal({
|
||||
i18n,
|
||||
callLink,
|
||||
onClose,
|
||||
onUpdateCallLinkName,
|
||||
}: CallLinkAddNameModalProps): JSX.Element {
|
||||
const [formId] = useState(() => generateUuid());
|
||||
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(() => {
|
||||
if (parsedForm == null) {
|
||||
return;
|
||||
}
|
||||
onUpdateCallLinkName(parsedForm.name);
|
||||
onClose();
|
||||
}, [parsedForm, onUpdateCallLinkName, onClose]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
modalName="CallLinkAddNameModal"
|
||||
i18n={i18n}
|
||||
hasXButton
|
||||
noEscapeClose
|
||||
noMouseClose
|
||||
title={
|
||||
callLink.name === ''
|
||||
? i18n('icu:CallLinkAddNameModal__Title')
|
||||
: i18n('icu:CallLinkAddNameModal__Title--Edit')
|
||||
}
|
||||
onClose={onClose}
|
||||
moduleClassName="CallLinkAddNameModal"
|
||||
modalFooter={
|
||||
<>
|
||||
<Button onClick={onClose} variant={ButtonVariant.Secondary}>
|
||||
{i18n('icu:cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form={formId}
|
||||
variant={ButtonVariant.Primary}
|
||||
aria-disabled={parsedForm == null}
|
||||
>
|
||||
{i18n('icu:save')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form
|
||||
id={formId}
|
||||
onSubmit={handleSubmit}
|
||||
className="CallLinkAddNameModal__Row"
|
||||
>
|
||||
<Avatar
|
||||
i18n={i18n}
|
||||
badge={undefined}
|
||||
color={getColorForCallLink(callLink.rootKey)}
|
||||
conversationType="callLink"
|
||||
size={AvatarSize.SIXTY_FOUR}
|
||||
acceptedMessageRequest
|
||||
isMe={false}
|
||||
sharedGroupNames={[]}
|
||||
title={
|
||||
callLink.name === ''
|
||||
? i18n('icu:calling__call-link-default-title')
|
||||
: callLink.name
|
||||
}
|
||||
/>
|
||||
|
||||
<label htmlFor={nameId} className="CallLinkAddNameModal__SrOnly">
|
||||
{i18n('icu:CallLinkAddNameModal__NameLabel')}
|
||||
</label>
|
||||
<Input
|
||||
i18n={i18n}
|
||||
id={nameId}
|
||||
value={nameInput}
|
||||
placeholder={i18n('icu:CallLinkAddNameModal__NameLabel')}
|
||||
autoFocus
|
||||
onChange={handleNameInputChange}
|
||||
moduleClassName="CallLinkAddNameModal__Input"
|
||||
maxByteCount={CallLinkNameMaxByteLength}
|
||||
/>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
149
ts/components/CallLinkDetails.tsx
Normal file
149
ts/components/CallLinkDetails.tsx
Normal file
|
@ -0,0 +1,149 @@
|
|||
// Copyright 2024 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
import React from 'react';
|
||||
import type { CallHistoryGroup } from '../types/CallDisposition';
|
||||
import type { LocalizerType } from '../types/I18N';
|
||||
import { CallHistoryGroupPanelSection } from './conversation/conversation-details/CallHistoryGroupPanelSection';
|
||||
import { PanelSection } from './conversation/conversation-details/PanelSection';
|
||||
import {
|
||||
ConversationDetailsIcon,
|
||||
IconType,
|
||||
} from './conversation/conversation-details/ConversationDetailsIcon';
|
||||
import { PanelRow } from './conversation/conversation-details/PanelRow';
|
||||
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}`;
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
return (
|
||||
<div className="CallLinkDetails__Container">
|
||||
<header className="CallLinkDetails__Header">
|
||||
<Avatar
|
||||
className="CallLinkDetails__HeaderAvatar"
|
||||
i18n={i18n}
|
||||
badge={undefined}
|
||||
color={getColorForCallLink(callLink.rootKey)}
|
||||
conversationType="callLink"
|
||||
size={AvatarSize.SIXTY_FOUR}
|
||||
acceptedMessageRequest
|
||||
isMe={false}
|
||||
sharedGroupNames={[]}
|
||||
title={callLink.name ?? i18n('icu:calling__call-link-default-title')}
|
||||
/>
|
||||
<div className="CallLinkDetails__HeaderDetails">
|
||||
<h1 className="CallLinkDetails__HeaderTitle">
|
||||
{callLink.name === ''
|
||||
? i18n('icu:calling__call-link-default-title')
|
||||
: callLink.name}
|
||||
</h1>
|
||||
<p className="CallLinkDetails__HeaderDescription">
|
||||
{toUrlWithoutProtocol(webUrl)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="CallLinkDetails__HeaderActions">
|
||||
<Button
|
||||
className="CallLinkDetails__HeaderButton"
|
||||
variant={ButtonVariant.SecondaryAffirmative}
|
||||
size={ButtonSize.Small}
|
||||
onClick={onStartCallLinkLobby}
|
||||
>
|
||||
{i18n('icu:CallLinkDetails__Join')}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<CallHistoryGroupPanelSection
|
||||
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={
|
||||
<ConversationDetailsIcon
|
||||
ariaLabel={i18n('icu:CallLinkDetails__CopyLink')}
|
||||
icon={IconType.share}
|
||||
/>
|
||||
}
|
||||
label={i18n('icu:CallLinkDetails__CopyLink')}
|
||||
onClick={() => {
|
||||
drop(copyCallLink(webUrl.toString()));
|
||||
}}
|
||||
/>
|
||||
<PanelRow
|
||||
icon={
|
||||
<ConversationDetailsIcon
|
||||
ariaLabel={i18n('icu:CallLinkDetails__ShareLinkViaSignal')}
|
||||
icon={IconType.forward}
|
||||
/>
|
||||
}
|
||||
label={i18n('icu:CallLinkDetails__ShareLinkViaSignal')}
|
||||
onClick={onShareCallLinkViaSignal}
|
||||
/>
|
||||
</PanelSection>
|
||||
</div>
|
||||
);
|
||||
}
|
32
ts/components/CallLinkEditModal.stories.tsx
Normal file
32
ts/components/CallLinkEditModal.stories.tsx
Normal file
|
@ -0,0 +1,32 @@
|
|||
// Copyright 2024 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { setupI18n } from '../util/setupI18n';
|
||||
import enMessages from '../../_locales/en/messages.json';
|
||||
import type { CallLinkEditModalProps } from './CallLinkEditModal';
|
||||
import { CallLinkEditModal } from './CallLinkEditModal';
|
||||
import type { ComponentMeta } from '../storybook/types';
|
||||
import { FAKE_CALL_LINK_WITH_ADMIN_KEY } from '../test-both/helpers/fakeCallLink';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
export default {
|
||||
title: 'Components/CallLinkEditModal',
|
||||
component: CallLinkEditModal,
|
||||
args: {
|
||||
i18n,
|
||||
callLink: FAKE_CALL_LINK_WITH_ADMIN_KEY,
|
||||
onClose: action('onClose'),
|
||||
onCopyCallLink: action('onCopyCallLink'),
|
||||
onOpenCallLinkAddNameModal: action('onOpenCallLinkAddNameModal'),
|
||||
onUpdateCallLinkRestrictions: action('onUpdateCallLinkRestrictions'),
|
||||
onShareCallLinkViaSignal: action('onShareCallLinkViaSignal'),
|
||||
onStartCallLinkLobby: action('onStartCallLinkLobby'),
|
||||
},
|
||||
} satisfies ComponentMeta<CallLinkEditModalProps>;
|
||||
|
||||
export function Basic(args: CallLinkEditModalProps): JSX.Element {
|
||||
return <CallLinkEditModal {...args} />;
|
||||
}
|
200
ts/components/CallLinkEditModal.tsx
Normal file
200
ts/components/CallLinkEditModal.tsx
Normal file
|
@ -0,0 +1,200 @@
|
|||
// Copyright 2024 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { v4 as generateUuid } from 'uuid';
|
||||
import { Modal } from './Modal';
|
||||
import type { LocalizerType } from '../types/I18N';
|
||||
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',
|
||||
Approve: 'CallLinkEditModal__RowIcon--Approve',
|
||||
Copy: 'CallLinkEditModal__RowIcon--Copy',
|
||||
Share: 'CallLinkEditModal__RowIcon--Share',
|
||||
} as const;
|
||||
|
||||
function RowIcon({
|
||||
icon,
|
||||
}: {
|
||||
icon: keyof typeof CallLinkEditModalRowIconClasses;
|
||||
}) {
|
||||
return (
|
||||
<i
|
||||
role="presentation"
|
||||
className={`CallLinkEditModal__RowIcon ${CallLinkEditModalRowIconClasses[icon]}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function RowText({ children }: { children: ReactNode }) {
|
||||
return <div className="CallLinkEditModal__RowLabel">{children}</div>;
|
||||
}
|
||||
|
||||
function Row({ children }: { children: ReactNode }) {
|
||||
return <div className="CallLinkEditModal__Row">{children}</div>;
|
||||
}
|
||||
|
||||
function RowButton({
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className="CallLinkEditModal__RowButton"
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Hr() {
|
||||
return <hr className="CallLinkEditModal__Hr" />;
|
||||
}
|
||||
|
||||
export type CallLinkEditModalProps = {
|
||||
i18n: LocalizerType;
|
||||
callLink: CallLinkType;
|
||||
onClose: () => void;
|
||||
onCopyCallLink: () => void;
|
||||
onOpenCallLinkAddNameModal: () => void;
|
||||
onUpdateCallLinkRestrictions: (restrictions: CallLinkRestrictions) => void;
|
||||
onShareCallLinkViaSignal: () => void;
|
||||
onStartCallLinkLobby: () => void;
|
||||
};
|
||||
|
||||
export function CallLinkEditModal({
|
||||
i18n,
|
||||
callLink,
|
||||
onClose,
|
||||
onCopyCallLink,
|
||||
onOpenCallLinkAddNameModal,
|
||||
onUpdateCallLinkRestrictions,
|
||||
onShareCallLinkViaSignal,
|
||||
onStartCallLinkLobby,
|
||||
}: CallLinkEditModalProps): JSX.Element {
|
||||
const [restrictionsId] = useState(() => generateUuid());
|
||||
|
||||
const callLinkWebUrl = useMemo(() => {
|
||||
return linkCallRoute.toWebUrl({ key: callLink.rootKey }).toString();
|
||||
}, [callLink.rootKey]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
i18n={i18n}
|
||||
modalName="CallLinkEditModal"
|
||||
moduleClassName="CallLinkEditModal"
|
||||
title={i18n('icu:CallLinkEditModal__Title')}
|
||||
noEscapeClose
|
||||
noMouseClose
|
||||
padded={false}
|
||||
modalFooter={
|
||||
<Button type="submit" variant={ButtonVariant.Primary} onClick={onClose}>
|
||||
{i18n('icu:done')}
|
||||
</Button>
|
||||
}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="CallLinkEditModal__Header">
|
||||
<Avatar
|
||||
i18n={i18n}
|
||||
badge={undefined}
|
||||
color={getColorForCallLink(callLink.rootKey)}
|
||||
conversationType="callLink"
|
||||
size={AvatarSize.SIXTY_FOUR}
|
||||
acceptedMessageRequest
|
||||
isMe={false}
|
||||
sharedGroupNames={[]}
|
||||
title={
|
||||
callLink.name === ''
|
||||
? i18n('icu:calling__call-link-default-title')
|
||||
: callLink.name
|
||||
}
|
||||
/>
|
||||
<div className="CallLinkEditModal__Header__Details">
|
||||
<div className="CallLinkEditModal__Header__Title">
|
||||
{callLink.name === ''
|
||||
? i18n('icu:calling__call-link-default-title')
|
||||
: callLink.name}
|
||||
</div>
|
||||
<button
|
||||
className="CallLinkEditModal__Header__CallLinkButton"
|
||||
type="button"
|
||||
onClick={onCopyCallLink}
|
||||
aria-label={i18n('icu:CallLinkDetails__CopyLink')}
|
||||
>
|
||||
<div className="CallLinkEditModal__Header__CallLinkButton__Text">
|
||||
{callLinkWebUrl}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div className="CallLinkEditModal__Header__Actions">
|
||||
<Button
|
||||
onClick={onStartCallLinkLobby}
|
||||
size={ButtonSize.Small}
|
||||
variant={ButtonVariant.SecondaryAffirmative}
|
||||
className="CallLinkEditModal__JoinButton"
|
||||
>
|
||||
{i18n('icu:CallLinkEditModal__JoinButtonLabel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Hr />
|
||||
|
||||
<RowButton onClick={onOpenCallLinkAddNameModal}>
|
||||
<Row>
|
||||
<RowIcon icon="Edit" />
|
||||
<RowText>
|
||||
{callLink.name === ''
|
||||
? i18n('icu:CallLinkEditModal__AddCallNameLabel')
|
||||
: i18n('icu:CallLinkEditModal__EditCallNameLabel')}
|
||||
</RowText>
|
||||
</Row>
|
||||
</RowButton>
|
||||
|
||||
<Row>
|
||||
<RowIcon icon="Approve" />
|
||||
<RowText>
|
||||
<label htmlFor={restrictionsId}>
|
||||
{i18n('icu:CallLinkEditModal__InputLabel--ApproveAllMembers')}
|
||||
</label>
|
||||
</RowText>
|
||||
<CallLinkRestrictionsSelect
|
||||
i18n={i18n}
|
||||
id={restrictionsId}
|
||||
value={callLink.restrictions}
|
||||
onChange={onUpdateCallLinkRestrictions}
|
||||
/>
|
||||
</Row>
|
||||
|
||||
<Hr />
|
||||
|
||||
<RowButton onClick={onCopyCallLink}>
|
||||
<Row>
|
||||
<RowIcon icon="Copy" />
|
||||
<RowText>{i18n('icu:CallLinkDetails__CopyLink')}</RowText>
|
||||
</Row>
|
||||
</RowButton>
|
||||
|
||||
<RowButton onClick={onShareCallLinkViaSignal}>
|
||||
<Row>
|
||||
<RowIcon icon="Share" />
|
||||
<RowText>{i18n('icu:CallLinkDetails__ShareLinkViaSignal')}</RowText>
|
||||
</Row>
|
||||
</RowButton>
|
||||
</Modal>
|
||||
);
|
||||
}
|
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));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -14,6 +14,10 @@ import {
|
|||
GroupCallConnectionState,
|
||||
GroupCallJoinState,
|
||||
} from '../types/Calling';
|
||||
import type {
|
||||
ActiveGroupCallType,
|
||||
GroupCallRemoteParticipantType,
|
||||
} from '../types/Calling';
|
||||
import type {
|
||||
ConversationType,
|
||||
ConversationTypeType,
|
||||
|
@ -23,17 +27,22 @@ import { generateAci } from '../types/ServiceId';
|
|||
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
|
||||
import { fakeGetGroupCallVideoFrameSource } from '../test-both/helpers/fakeGetGroupCallVideoFrameSource';
|
||||
import { setupI18n } from '../util/setupI18n';
|
||||
import type { SafetyNumberProps } from './SafetyNumberChangeDialog';
|
||||
import enMessages from '../../_locales/en/messages.json';
|
||||
import { ThemeType } from '../types/Util';
|
||||
import { StorySendMode } from '../types/Stories';
|
||||
import {
|
||||
FAKE_CALL_LINK,
|
||||
FAKE_CALL_LINK_WITH_ADMIN_KEY,
|
||||
getDefaultCallLinkConversation,
|
||||
} from '../test-both/helpers/fakeCallLink';
|
||||
import { allRemoteParticipants } from './CallScreen.stories';
|
||||
import { getPlaceholderContact } from '../state/selectors/conversations';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
const getConversation = () =>
|
||||
getDefaultConversation({
|
||||
id: '3051234567',
|
||||
avatarPath: undefined,
|
||||
avatarUrl: undefined,
|
||||
color: AvatarColors[0],
|
||||
title: 'Rick Sanchez',
|
||||
name: 'Rick Sanchez',
|
||||
|
@ -44,6 +53,25 @@ const getConversation = () =>
|
|||
lastUpdated: Date.now(),
|
||||
});
|
||||
|
||||
const getUnknownContact = (): ConversationType => ({
|
||||
...getPlaceholderContact(),
|
||||
serviceId: generateAci(),
|
||||
});
|
||||
|
||||
const getUnknownParticipant = (): GroupCallRemoteParticipantType => ({
|
||||
...getPlaceholderContact(),
|
||||
serviceId: generateAci(),
|
||||
aci: generateAci(),
|
||||
demuxId: Math.round(10000 * Math.random()),
|
||||
hasRemoteAudio: true,
|
||||
hasRemoteVideo: true,
|
||||
isHandRaised: false,
|
||||
mediaKeysReceived: false,
|
||||
presenting: false,
|
||||
sharingScreen: false,
|
||||
videoAspectRatio: 1,
|
||||
});
|
||||
|
||||
const getCommonActiveCallData = () => ({
|
||||
conversation: getConversation(),
|
||||
joinedAt: Date.now(),
|
||||
|
@ -61,23 +89,25 @@ const createProps = (storyProps: Partial<PropsType> = {}): PropsType => ({
|
|||
...storyProps,
|
||||
availableCameras: [],
|
||||
acceptCall: action('accept-call'),
|
||||
approveUser: action('approve-user'),
|
||||
batchUserAction: action('batch-user-action'),
|
||||
bounceAppIconStart: action('bounce-app-icon-start'),
|
||||
bounceAppIconStop: action('bounce-app-icon-stop'),
|
||||
cancelCall: action('cancel-call'),
|
||||
changeCallView: action('change-call-view'),
|
||||
closeNeedPermissionScreen: action('close-need-permission-screen'),
|
||||
declineCall: action('decline-call'),
|
||||
denyUser: action('deny-user'),
|
||||
getGroupCallVideoFrameSource: (_: string, demuxId: number) =>
|
||||
fakeGetGroupCallVideoFrameSource(demuxId),
|
||||
getPreferredBadge: () => undefined,
|
||||
getIsSharingPhoneNumberWithEverybody: () => false,
|
||||
getPresentingSources: action('get-presenting-sources'),
|
||||
hangUpActiveCall: action('hang-up-active-call'),
|
||||
hasInitialLoadCompleted: true,
|
||||
i18n,
|
||||
incomingCall: null,
|
||||
callLink: undefined,
|
||||
callLink: storyProps.callLink ?? undefined,
|
||||
isGroupCallRaiseHandEnabled: true,
|
||||
isGroupCallReactionsEnabled: true,
|
||||
keyChangeOk: action('key-change-ok'),
|
||||
me: {
|
||||
...getDefaultConversation({
|
||||
color: AvatarColors[0],
|
||||
|
@ -88,10 +118,11 @@ const createProps = (storyProps: Partial<PropsType> = {}): PropsType => ({
|
|||
notifyForCall: action('notify-for-call'),
|
||||
openSystemPreferencesAction: action('open-system-preferences-action'),
|
||||
playRingtone: action('play-ringtone'),
|
||||
removeClient: action('remove-client'),
|
||||
blockClient: action('block-client'),
|
||||
renderDeviceSelection: () => <div />,
|
||||
renderEmojiPicker: () => <>EmojiPicker</>,
|
||||
renderReactionPicker: () => <div />,
|
||||
renderSafetyNumberViewer: (_: SafetyNumberProps) => <div />,
|
||||
sendGroupCallRaiseHand: action('send-group-call-raise-hand'),
|
||||
sendGroupCallReaction: action('send-group-call-reaction'),
|
||||
setGroupCallVideoRequest: action('set-group-call-video-request'),
|
||||
|
@ -102,12 +133,12 @@ const createProps = (storyProps: Partial<PropsType> = {}): PropsType => ({
|
|||
setPresenting: action('toggle-presenting'),
|
||||
setRendererCanvas: action('set-renderer-canvas'),
|
||||
setOutgoingRing: action('set-outgoing-ring'),
|
||||
showToast: action('show-toast'),
|
||||
showContactModal: action('show-contact-modal'),
|
||||
showShareCallLinkViaSignal: action('show-share-call-link-via-signal'),
|
||||
startCall: action('start-call'),
|
||||
stopRingtone: action('stop-ringtone'),
|
||||
switchToPresentationView: action('switch-to-presentation-view'),
|
||||
switchFromPresentationView: action('switch-from-presentation-view'),
|
||||
theme: ThemeType.light,
|
||||
toggleParticipants: action('toggle-participants'),
|
||||
togglePip: action('toggle-pip'),
|
||||
toggleScreenRecordingPermissionsDialog: action(
|
||||
|
@ -118,6 +149,39 @@ const createProps = (storyProps: Partial<PropsType> = {}): PropsType => ({
|
|||
pauseVoiceNotePlayer: action('pause-audio-player'),
|
||||
});
|
||||
|
||||
const getActiveCallForCallLink = (
|
||||
overrideProps: Partial<ActiveGroupCallType> = {}
|
||||
): ActiveGroupCallType => {
|
||||
return {
|
||||
conversation: getDefaultCallLinkConversation(),
|
||||
joinedAt: Date.now(),
|
||||
hasLocalAudio: true,
|
||||
hasLocalVideo: true,
|
||||
localAudioLevel: 0,
|
||||
viewMode: CallViewMode.Paginated,
|
||||
outgoingRing: false,
|
||||
pip: false,
|
||||
settingsDialogOpen: false,
|
||||
showParticipantsList: overrideProps.showParticipantsList ?? true,
|
||||
callMode: CallMode.Adhoc,
|
||||
connectionState:
|
||||
overrideProps.connectionState ?? GroupCallConnectionState.NotConnected,
|
||||
conversationsByDemuxId: new Map<number, ConversationType>(),
|
||||
deviceCount: 0,
|
||||
joinState: overrideProps.joinState ?? GroupCallJoinState.NotJoined,
|
||||
localDemuxId: 1,
|
||||
maxDevices: 5,
|
||||
groupMembers: [],
|
||||
isConversationTooBigToRing: false,
|
||||
peekedParticipants:
|
||||
overrideProps.peekedParticipants ?? allRemoteParticipants.slice(0, 3),
|
||||
remoteParticipants: overrideProps.remoteParticipants ?? [],
|
||||
pendingParticipants: overrideProps.pendingParticipants ?? [],
|
||||
raisedHands: new Set<number>(),
|
||||
remoteAudioLevels: new Map<number, number>(),
|
||||
};
|
||||
};
|
||||
|
||||
export default {
|
||||
title: 'Components/CallManager',
|
||||
argTypes: {},
|
||||
|
@ -154,7 +218,6 @@ export function OngoingGroupCall(): JSX.Element {
|
|||
...getCommonActiveCallData(),
|
||||
callMode: CallMode.Group,
|
||||
connectionState: GroupCallConnectionState.Connected,
|
||||
conversationsWithSafetyNumberChanges: [],
|
||||
conversationsByDemuxId: new Map<number, ConversationType>(),
|
||||
deviceCount: 0,
|
||||
joinState: GroupCallJoinState.Joined,
|
||||
|
@ -163,6 +226,7 @@ export function OngoingGroupCall(): JSX.Element {
|
|||
groupMembers: [],
|
||||
isConversationTooBigToRing: false,
|
||||
peekedParticipants: [],
|
||||
pendingParticipants: [],
|
||||
raisedHands: new Set<number>(),
|
||||
remoteParticipants: [],
|
||||
remoteAudioLevels: new Map<number, number>(),
|
||||
|
@ -232,33 +296,238 @@ export function CallRequestNeeded(): JSX.Element {
|
|||
);
|
||||
}
|
||||
|
||||
export function GroupCallSafetyNumberChanged(): JSX.Element {
|
||||
export function CallLinkLobbyParticipantsKnown(): JSX.Element {
|
||||
return (
|
||||
<CallManager
|
||||
{...createProps({
|
||||
activeCall: {
|
||||
...getCommonActiveCallData(),
|
||||
callMode: CallMode.Group,
|
||||
connectionState: GroupCallConnectionState.Connected,
|
||||
conversationsWithSafetyNumberChanges: [
|
||||
{
|
||||
...getDefaultConversation({
|
||||
title: 'Aaron',
|
||||
}),
|
||||
},
|
||||
],
|
||||
conversationsByDemuxId: new Map<number, ConversationType>(),
|
||||
deviceCount: 0,
|
||||
joinState: GroupCallJoinState.Joined,
|
||||
localDemuxId: 1,
|
||||
maxDevices: 5,
|
||||
groupMembers: [],
|
||||
isConversationTooBigToRing: false,
|
||||
peekedParticipants: [],
|
||||
raisedHands: new Set<number>(),
|
||||
remoteParticipants: [],
|
||||
remoteAudioLevels: new Map<number, number>(),
|
||||
},
|
||||
activeCall: getActiveCallForCallLink(),
|
||||
callLink: FAKE_CALL_LINK,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CallLinkLobbyParticipants1Unknown(): JSX.Element {
|
||||
return (
|
||||
<CallManager
|
||||
{...createProps({
|
||||
activeCall: getActiveCallForCallLink({
|
||||
peekedParticipants: [getPlaceholderContact()],
|
||||
}),
|
||||
callLink: FAKE_CALL_LINK,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CallLinkLobbyParticipants1Known1Unknown(): JSX.Element {
|
||||
return (
|
||||
<CallManager
|
||||
{...createProps({
|
||||
activeCall: getActiveCallForCallLink({
|
||||
peekedParticipants: [allRemoteParticipants[0], getUnknownContact()],
|
||||
}),
|
||||
callLink: FAKE_CALL_LINK,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CallLinkLobbyParticipants1Known2Unknown(): JSX.Element {
|
||||
return (
|
||||
<CallManager
|
||||
{...createProps({
|
||||
activeCall: getActiveCallForCallLink({
|
||||
peekedParticipants: [
|
||||
getUnknownContact(),
|
||||
allRemoteParticipants[0],
|
||||
getUnknownContact(),
|
||||
],
|
||||
}),
|
||||
callLink: FAKE_CALL_LINK,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CallLinkLobbyParticipants1Known12Unknown(): JSX.Element {
|
||||
const peekedParticipants: Array<ConversationType> = [
|
||||
allRemoteParticipants[0],
|
||||
];
|
||||
for (let n = 12; n > 0; n -= 1) {
|
||||
peekedParticipants.push(getUnknownContact());
|
||||
}
|
||||
return (
|
||||
<CallManager
|
||||
{...createProps({
|
||||
activeCall: getActiveCallForCallLink({
|
||||
peekedParticipants,
|
||||
}),
|
||||
callLink: FAKE_CALL_LINK,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CallLinkLobbyParticipants3Unknown(): JSX.Element {
|
||||
return (
|
||||
<CallManager
|
||||
{...createProps({
|
||||
activeCall: getActiveCallForCallLink({
|
||||
peekedParticipants: [
|
||||
getUnknownContact(),
|
||||
getUnknownContact(),
|
||||
getUnknownContact(),
|
||||
],
|
||||
}),
|
||||
callLink: FAKE_CALL_LINK,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CallLinkWithJoinRequestsOne(): JSX.Element {
|
||||
return (
|
||||
<CallManager
|
||||
{...createProps({
|
||||
activeCall: getActiveCallForCallLink({
|
||||
connectionState: GroupCallConnectionState.Connected,
|
||||
joinState: GroupCallJoinState.Joined,
|
||||
peekedParticipants: [allRemoteParticipants[0]],
|
||||
pendingParticipants: [allRemoteParticipants[1]],
|
||||
showParticipantsList: false,
|
||||
}),
|
||||
callLink: FAKE_CALL_LINK_WITH_ADMIN_KEY,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CallLinkWithJoinRequestsTwo(): JSX.Element {
|
||||
return (
|
||||
<CallManager
|
||||
{...createProps({
|
||||
activeCall: getActiveCallForCallLink({
|
||||
connectionState: GroupCallConnectionState.Connected,
|
||||
joinState: GroupCallJoinState.Joined,
|
||||
peekedParticipants: [allRemoteParticipants[0]],
|
||||
pendingParticipants: allRemoteParticipants.slice(1, 3),
|
||||
showParticipantsList: false,
|
||||
}),
|
||||
callLink: FAKE_CALL_LINK_WITH_ADMIN_KEY,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CallLinkWithJoinRequestsMany(): JSX.Element {
|
||||
return (
|
||||
<CallManager
|
||||
{...createProps({
|
||||
activeCall: getActiveCallForCallLink({
|
||||
connectionState: GroupCallConnectionState.Connected,
|
||||
joinState: GroupCallJoinState.Joined,
|
||||
peekedParticipants: [allRemoteParticipants[0]],
|
||||
pendingParticipants: allRemoteParticipants.slice(1, 11),
|
||||
showParticipantsList: false,
|
||||
}),
|
||||
callLink: FAKE_CALL_LINK_WITH_ADMIN_KEY,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CallLinkWithJoinRequestUnknownContact(): JSX.Element {
|
||||
return (
|
||||
<CallManager
|
||||
{...createProps({
|
||||
activeCall: getActiveCallForCallLink({
|
||||
connectionState: GroupCallConnectionState.Connected,
|
||||
joinState: GroupCallJoinState.Joined,
|
||||
peekedParticipants: [allRemoteParticipants[0]],
|
||||
pendingParticipants: [
|
||||
getUnknownContact(),
|
||||
allRemoteParticipants[1],
|
||||
allRemoteParticipants[2],
|
||||
],
|
||||
showParticipantsList: false,
|
||||
}),
|
||||
callLink: FAKE_CALL_LINK_WITH_ADMIN_KEY,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CallLinkWithJoinRequestsSystemContact(): JSX.Element {
|
||||
return (
|
||||
<CallManager
|
||||
{...createProps({
|
||||
activeCall: getActiveCallForCallLink({
|
||||
connectionState: GroupCallConnectionState.Connected,
|
||||
joinState: GroupCallJoinState.Joined,
|
||||
peekedParticipants: [allRemoteParticipants[0]],
|
||||
pendingParticipants: [
|
||||
{ ...allRemoteParticipants[1], name: 'My System Contact Friend' },
|
||||
],
|
||||
showParticipantsList: false,
|
||||
}),
|
||||
callLink: FAKE_CALL_LINK_WITH_ADMIN_KEY,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CallLinkWithJoinRequestsSystemContactMany(): JSX.Element {
|
||||
return (
|
||||
<CallManager
|
||||
{...createProps({
|
||||
activeCall: getActiveCallForCallLink({
|
||||
connectionState: GroupCallConnectionState.Connected,
|
||||
joinState: GroupCallJoinState.Joined,
|
||||
peekedParticipants: [allRemoteParticipants[0]],
|
||||
pendingParticipants: [
|
||||
{ ...allRemoteParticipants[1], name: 'My System Contact Friend' },
|
||||
allRemoteParticipants[2],
|
||||
allRemoteParticipants[3],
|
||||
],
|
||||
showParticipantsList: false,
|
||||
}),
|
||||
callLink: FAKE_CALL_LINK_WITH_ADMIN_KEY,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CallLinkWithJoinRequestsParticipantsOpen(): JSX.Element {
|
||||
return (
|
||||
<CallManager
|
||||
{...createProps({
|
||||
activeCall: getActiveCallForCallLink({
|
||||
connectionState: GroupCallConnectionState.Connected,
|
||||
joinState: GroupCallJoinState.Joined,
|
||||
peekedParticipants: [allRemoteParticipants[0]],
|
||||
pendingParticipants: allRemoteParticipants.slice(1, 4),
|
||||
}),
|
||||
callLink: FAKE_CALL_LINK_WITH_ADMIN_KEY,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CallLinkWithUnknownContacts(): JSX.Element {
|
||||
return (
|
||||
<CallManager
|
||||
{...createProps({
|
||||
activeCall: getActiveCallForCallLink({
|
||||
connectionState: GroupCallConnectionState.Connected,
|
||||
joinState: GroupCallJoinState.Joined,
|
||||
remoteParticipants: [
|
||||
allRemoteParticipants[0],
|
||||
getUnknownParticipant(),
|
||||
getUnknownParticipant(),
|
||||
],
|
||||
}),
|
||||
callLink: FAKE_CALL_LINK_WITH_ADMIN_KEY,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -11,8 +11,6 @@ import { CallingParticipantsList } from './CallingParticipantsList';
|
|||
import { CallingSelectPresentingSourcesModal } from './CallingSelectPresentingSourcesModal';
|
||||
import { CallingPip } from './CallingPip';
|
||||
import { IncomingCallBar } from './IncomingCallBar';
|
||||
import type { SafetyNumberProps } from './SafetyNumberChangeDialog';
|
||||
import { SafetyNumberChangeDialog } from './SafetyNumberChangeDialog';
|
||||
import type {
|
||||
ActiveCallType,
|
||||
CallingConversationType,
|
||||
|
@ -28,13 +26,14 @@ import {
|
|||
GroupCallJoinState,
|
||||
} from '../types/Calling';
|
||||
import type { ConversationType } from '../state/ducks/conversations';
|
||||
import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
|
||||
import type {
|
||||
AcceptCallType,
|
||||
BatchUserActionPayloadType,
|
||||
CancelCallType,
|
||||
DeclineCallType,
|
||||
GroupCallParticipantInfoType,
|
||||
KeyChangeOkType,
|
||||
PendingUserActionPayloadType,
|
||||
RemoveClientType,
|
||||
SendGroupCallRaiseHandType,
|
||||
SendGroupCallReactionType,
|
||||
SetGroupCallVideoRequestType,
|
||||
|
@ -46,7 +45,7 @@ import type {
|
|||
} from '../state/ducks/calling';
|
||||
import { CallLinkRestrictions } from '../types/CallLink';
|
||||
import type { CallLinkType } from '../types/CallLink';
|
||||
import type { LocalizerType, ThemeType } from '../types/Util';
|
||||
import type { LocalizerType } from '../types/Util';
|
||||
import { missingCaseError } from '../util/missingCaseError';
|
||||
import { CallingToastProvider } from './CallingToast';
|
||||
import type { SmartReactionPicker } from '../state/smart/ReactionPicker';
|
||||
|
@ -55,8 +54,8 @@ import * as log from '../logging/log';
|
|||
import { isGroupOrAdhocActiveCall } from '../util/isGroupOrAdhocCall';
|
||||
import { CallingAdhocCallInfo } from './CallingAdhocCallInfo';
|
||||
import { callLinkRootKeyToUrl } from '../util/callLinkRootKeyToUrl';
|
||||
import { ToastType } from '../types/Toast';
|
||||
import type { ShowToastAction } from '../state/ducks/toast';
|
||||
import { usePrevious } from '../hooks/usePrevious';
|
||||
import { copyCallLink } from '../util/copyLinksWithToast';
|
||||
|
||||
const GROUP_CALL_RING_DURATION = 60 * 1000;
|
||||
|
||||
|
@ -78,6 +77,8 @@ export type GroupIncomingCall = Readonly<{
|
|||
remoteParticipants: Array<GroupCallParticipantInfoType>;
|
||||
}>;
|
||||
|
||||
export type CallingImageDataCache = Map<number, ImageData>;
|
||||
|
||||
export type PropsType = {
|
||||
activeCall?: ActiveCallType;
|
||||
availableCameras: Array<MediaDeviceInfo>;
|
||||
|
@ -89,24 +90,26 @@ export type PropsType = {
|
|||
conversationId: string,
|
||||
demuxId: number
|
||||
) => VideoFrameSource;
|
||||
getPreferredBadge: PreferredBadgeSelectorType;
|
||||
getIsSharingPhoneNumberWithEverybody: () => boolean;
|
||||
getPresentingSources: () => void;
|
||||
incomingCall: DirectIncomingCall | GroupIncomingCall | null;
|
||||
keyChangeOk: (_: KeyChangeOkType) => void;
|
||||
renderDeviceSelection: () => JSX.Element;
|
||||
renderReactionPicker: (
|
||||
props: React.ComponentProps<typeof SmartReactionPicker>
|
||||
) => JSX.Element;
|
||||
renderSafetyNumberViewer: (props: SafetyNumberProps) => JSX.Element;
|
||||
showContactModal: (contactId: string, conversationId?: string) => void;
|
||||
startCall: (payload: StartCallType) => void;
|
||||
toggleParticipants: () => void;
|
||||
acceptCall: (_: AcceptCallType) => void;
|
||||
approveUser: (payload: PendingUserActionPayloadType) => void;
|
||||
batchUserAction: (payload: BatchUserActionPayloadType) => void;
|
||||
bounceAppIconStart: () => unknown;
|
||||
bounceAppIconStop: () => unknown;
|
||||
declineCall: (_: DeclineCallType) => void;
|
||||
denyUser: (payload: PendingUserActionPayloadType) => void;
|
||||
hasInitialLoadCompleted: boolean;
|
||||
i18n: LocalizerType;
|
||||
isGroupCallRaiseHandEnabled: boolean;
|
||||
isGroupCallReactionsEnabled: boolean;
|
||||
me: ConversationType;
|
||||
notifyForCall: (
|
||||
conversationId: string,
|
||||
|
@ -115,6 +118,8 @@ export type PropsType = {
|
|||
) => unknown;
|
||||
openSystemPreferencesAction: () => unknown;
|
||||
playRingtone: () => unknown;
|
||||
removeClient: (payload: RemoveClientType) => void;
|
||||
blockClient: (payload: RemoveClientType) => void;
|
||||
sendGroupCallRaiseHand: (payload: SendGroupCallRaiseHandType) => void;
|
||||
sendGroupCallReaction: (payload: SendGroupCallReactionType) => void;
|
||||
setGroupCallVideoRequest: (_: SetGroupCallVideoRequestType) => void;
|
||||
|
@ -125,12 +130,14 @@ export type PropsType = {
|
|||
setOutgoingRing: (_: boolean) => void;
|
||||
setPresenting: (_?: PresentedSource) => void;
|
||||
setRendererCanvas: (_: SetRendererCanvasType) => void;
|
||||
showToast: ShowToastAction;
|
||||
showShareCallLinkViaSignal: (
|
||||
callLink: CallLinkType,
|
||||
i18n: LocalizerType
|
||||
) => void;
|
||||
stopRingtone: () => unknown;
|
||||
switchToPresentationView: () => void;
|
||||
switchFromPresentationView: () => void;
|
||||
hangUpActiveCall: (reason: string) => void;
|
||||
theme: ThemeType;
|
||||
togglePip: () => void;
|
||||
toggleScreenRecordingPermissionsDialog: () => unknown;
|
||||
toggleSettings: () => void;
|
||||
|
@ -138,31 +145,46 @@ export type PropsType = {
|
|||
pauseVoiceNotePlayer: () => void;
|
||||
} & Pick<ReactionPickerProps, 'renderEmojiPicker'>;
|
||||
|
||||
type ActiveCallManagerPropsType = PropsType & {
|
||||
type ActiveCallManagerPropsType = {
|
||||
activeCall: ActiveCallType;
|
||||
};
|
||||
} & Omit<
|
||||
PropsType,
|
||||
| 'acceptCall'
|
||||
| 'bounceAppIconStart'
|
||||
| 'bounceAppIconStop'
|
||||
| 'declineCall'
|
||||
| 'hasInitialLoadCompleted'
|
||||
| 'incomingCall'
|
||||
| 'notifyForCall'
|
||||
| 'playRingtone'
|
||||
| 'setIsCallActive'
|
||||
| 'stopRingtone'
|
||||
| 'isConversationTooBigToRing'
|
||||
>;
|
||||
|
||||
function ActiveCallManager({
|
||||
activeCall,
|
||||
approveUser,
|
||||
availableCameras,
|
||||
batchUserAction,
|
||||
blockClient,
|
||||
callLink,
|
||||
cancelCall,
|
||||
changeCallView,
|
||||
closeNeedPermissionScreen,
|
||||
denyUser,
|
||||
hangUpActiveCall,
|
||||
i18n,
|
||||
isGroupCallRaiseHandEnabled,
|
||||
isGroupCallReactionsEnabled,
|
||||
keyChangeOk,
|
||||
getIsSharingPhoneNumberWithEverybody,
|
||||
getGroupCallVideoFrameSource,
|
||||
getPreferredBadge,
|
||||
getPresentingSources,
|
||||
me,
|
||||
openSystemPreferencesAction,
|
||||
renderDeviceSelection,
|
||||
renderEmojiPicker,
|
||||
renderReactionPicker,
|
||||
renderSafetyNumberViewer,
|
||||
removeClient,
|
||||
sendGroupCallRaiseHand,
|
||||
sendGroupCallReaction,
|
||||
setGroupCallVideoRequest,
|
||||
|
@ -172,11 +194,11 @@ function ActiveCallManager({
|
|||
setPresenting,
|
||||
setRendererCanvas,
|
||||
setOutgoingRing,
|
||||
showToast,
|
||||
showContactModal,
|
||||
showShareCallLinkViaSignal,
|
||||
startCall,
|
||||
switchToPresentationView,
|
||||
switchFromPresentationView,
|
||||
theme,
|
||||
toggleParticipants,
|
||||
togglePip,
|
||||
toggleScreenRecordingPermissionsDialog,
|
||||
|
@ -218,6 +240,16 @@ function ActiveCallManager({
|
|||
pauseVoiceNotePlayer,
|
||||
]);
|
||||
|
||||
// For caching screenshare frames which update slowly, between Pip and CallScreen.
|
||||
const imageDataCache = React.useRef<CallingImageDataCache>(new Map());
|
||||
|
||||
const previousConversationId = usePrevious(conversation.id, conversation.id);
|
||||
useEffect(() => {
|
||||
if (conversation.id !== previousConversationId) {
|
||||
imageDataCache.current.clear();
|
||||
}
|
||||
}, [conversation.id, previousConversationId]);
|
||||
|
||||
const getGroupCallVideoFrameSourceForActiveCall = useCallback(
|
||||
(demuxId: number) => {
|
||||
return getGroupCallVideoFrameSource(conversation.id, demuxId);
|
||||
|
@ -243,14 +275,18 @@ function ActiveCallManager({
|
|||
|
||||
const link = callLinkRootKeyToUrl(callLink.rootKey);
|
||||
if (link) {
|
||||
await window.navigator.clipboard.writeText(link);
|
||||
showToast({ toastType: ToastType.CopiedCallLink });
|
||||
await copyCallLink(link);
|
||||
}
|
||||
}, [callLink, showToast]);
|
||||
}, [callLink]);
|
||||
|
||||
const onSafetyNumberDialogCancel = useCallback(() => {
|
||||
hangUpActiveCall('safety number dialog cancel');
|
||||
}, [hangUpActiveCall]);
|
||||
const handleShareCallLinkViaSignal = useCallback(() => {
|
||||
if (!callLink) {
|
||||
log.error('Missing call link');
|
||||
return;
|
||||
}
|
||||
|
||||
showShareCallLinkViaSignal(callLink, i18n);
|
||||
}, [callLink, i18n, showShareCallLinkViaSignal]);
|
||||
|
||||
let isCallFull: boolean;
|
||||
let showCallLobby: boolean;
|
||||
|
@ -258,7 +294,9 @@ function ActiveCallManager({
|
|||
| undefined
|
||||
| Array<Pick<ConversationType, 'id' | 'firstName' | 'title'>>;
|
||||
let isConvoTooBigToRing = false;
|
||||
let isAdhocAdminApprovalRequired = false;
|
||||
let isAdhocJoinRequestPending = false;
|
||||
let isCallLinkAdmin = false;
|
||||
|
||||
switch (activeCall.callMode) {
|
||||
case CallMode.Direct: {
|
||||
|
@ -287,15 +325,38 @@ function ActiveCallManager({
|
|||
isCallFull = activeCall.deviceCount >= activeCall.maxDevices;
|
||||
isConvoTooBigToRing = activeCall.isConversationTooBigToRing;
|
||||
({ groupMembers } = activeCall);
|
||||
isAdhocAdminApprovalRequired =
|
||||
!callLink?.adminKey &&
|
||||
callLink?.restrictions === CallLinkRestrictions.AdminApproval;
|
||||
isAdhocJoinRequestPending =
|
||||
callLink?.restrictions === CallLinkRestrictions.AdminApproval &&
|
||||
isAdhocAdminApprovalRequired &&
|
||||
activeCall.joinState === GroupCallJoinState.Pending;
|
||||
isCallLinkAdmin = Boolean(callLink?.adminKey);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw missingCaseError(activeCall);
|
||||
}
|
||||
|
||||
if (pip) {
|
||||
return (
|
||||
<CallingPip
|
||||
activeCall={activeCall}
|
||||
getGroupCallVideoFrameSource={getGroupCallVideoFrameSourceForActiveCall}
|
||||
imageDataCache={imageDataCache}
|
||||
hangUpActiveCall={hangUpActiveCall}
|
||||
hasLocalVideo={hasLocalVideo}
|
||||
i18n={i18n}
|
||||
setGroupCallVideoRequest={setGroupCallVideoRequestForConversation}
|
||||
setLocalPreview={setLocalPreview}
|
||||
setRendererCanvas={setRendererCanvas}
|
||||
switchToPresentationView={switchToPresentationView}
|
||||
switchFromPresentationView={switchFromPresentationView}
|
||||
togglePip={togglePip}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (showCallLobby) {
|
||||
return (
|
||||
<>
|
||||
|
@ -307,9 +368,13 @@ function ActiveCallManager({
|
|||
hasLocalAudio={hasLocalAudio}
|
||||
hasLocalVideo={hasLocalVideo}
|
||||
i18n={i18n}
|
||||
isAdhocAdminApprovalRequired={isAdhocAdminApprovalRequired}
|
||||
isAdhocJoinRequestPending={isAdhocJoinRequestPending}
|
||||
isCallFull={isCallFull}
|
||||
isConversationTooBigToRing={isConvoTooBigToRing}
|
||||
getIsSharingPhoneNumberWithEverybody={
|
||||
getIsSharingPhoneNumberWithEverybody
|
||||
}
|
||||
me={me}
|
||||
onCallCanceled={cancelActiveCall}
|
||||
onJoinCall={joinActiveCall}
|
||||
|
@ -321,6 +386,7 @@ function ActiveCallManager({
|
|||
setOutgoingRing={setOutgoingRing}
|
||||
showParticipantsList={showParticipantsList}
|
||||
toggleParticipants={toggleParticipants}
|
||||
togglePip={togglePip}
|
||||
toggleSettings={toggleSettings}
|
||||
/>
|
||||
{settingsDialogOpen && renderDeviceSelection()}
|
||||
|
@ -329,41 +395,31 @@ function ActiveCallManager({
|
|||
<CallingAdhocCallInfo
|
||||
callLink={callLink}
|
||||
i18n={i18n}
|
||||
isCallLinkAdmin={isCallLinkAdmin}
|
||||
isUnknownContactDiscrete={false}
|
||||
ourServiceId={me.serviceId}
|
||||
participants={peekedParticipants}
|
||||
onClose={toggleParticipants}
|
||||
onCopyCallLink={onCopyCallLink}
|
||||
onShareCallLinkViaSignal={handleShareCallLinkViaSignal}
|
||||
removeClient={removeClient}
|
||||
blockClient={blockClient}
|
||||
showContactModal={showContactModal}
|
||||
/>
|
||||
) : (
|
||||
<CallingParticipantsList
|
||||
conversationId={conversation.id}
|
||||
i18n={i18n}
|
||||
onClose={toggleParticipants}
|
||||
ourServiceId={me.serviceId}
|
||||
participants={peekedParticipants}
|
||||
showContactModal={showContactModal}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (pip) {
|
||||
return (
|
||||
<CallingPip
|
||||
activeCall={activeCall}
|
||||
getGroupCallVideoFrameSource={getGroupCallVideoFrameSourceForActiveCall}
|
||||
hangUpActiveCall={hangUpActiveCall}
|
||||
hasLocalVideo={hasLocalVideo}
|
||||
i18n={i18n}
|
||||
setGroupCallVideoRequest={setGroupCallVideoRequestForConversation}
|
||||
setLocalPreview={setLocalPreview}
|
||||
setRendererCanvas={setRendererCanvas}
|
||||
switchToPresentationView={switchToPresentationView}
|
||||
switchFromPresentationView={switchFromPresentationView}
|
||||
togglePip={togglePip}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
let isHandRaised = false;
|
||||
if (isGroupOrAdhocActiveCall(activeCall)) {
|
||||
const { raisedHands, localDemuxId } = activeCall;
|
||||
|
@ -383,6 +439,7 @@ function ActiveCallManager({
|
|||
hasRemoteVideo: hasLocalVideo,
|
||||
isHandRaised,
|
||||
presenting: Boolean(activeCall.presentingSource),
|
||||
demuxId: activeCall.localDemuxId,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
@ -391,14 +448,18 @@ function ActiveCallManager({
|
|||
<>
|
||||
<CallScreen
|
||||
activeCall={activeCall}
|
||||
approveUser={approveUser}
|
||||
batchUserAction={batchUserAction}
|
||||
changeCallView={changeCallView}
|
||||
denyUser={denyUser}
|
||||
getPresentingSources={getPresentingSources}
|
||||
getGroupCallVideoFrameSource={getGroupCallVideoFrameSourceForActiveCall}
|
||||
groupMembers={groupMembers}
|
||||
hangUpActiveCall={hangUpActiveCall}
|
||||
i18n={i18n}
|
||||
imageDataCache={imageDataCache}
|
||||
isCallLinkAdmin={isCallLinkAdmin}
|
||||
isGroupCallRaiseHandEnabled={isGroupCallRaiseHandEnabled}
|
||||
isGroupCallReactionsEnabled={isGroupCallReactionsEnabled}
|
||||
me={me}
|
||||
openSystemPreferencesAction={openSystemPreferencesAction}
|
||||
renderEmojiPicker={renderEmojiPicker}
|
||||
|
@ -434,65 +495,96 @@ function ActiveCallManager({
|
|||
<CallingAdhocCallInfo
|
||||
callLink={callLink}
|
||||
i18n={i18n}
|
||||
isCallLinkAdmin={isCallLinkAdmin}
|
||||
isUnknownContactDiscrete
|
||||
ourServiceId={me.serviceId}
|
||||
participants={groupCallParticipantsForParticipantsList}
|
||||
onClose={toggleParticipants}
|
||||
onCopyCallLink={onCopyCallLink}
|
||||
onShareCallLinkViaSignal={handleShareCallLinkViaSignal}
|
||||
removeClient={removeClient}
|
||||
blockClient={blockClient}
|
||||
showContactModal={showContactModal}
|
||||
/>
|
||||
) : (
|
||||
<CallingParticipantsList
|
||||
conversationId={conversation.id}
|
||||
i18n={i18n}
|
||||
onClose={toggleParticipants}
|
||||
ourServiceId={me.serviceId}
|
||||
participants={groupCallParticipantsForParticipantsList}
|
||||
showContactModal={showContactModal}
|
||||
/>
|
||||
))}
|
||||
{isGroupOrAdhocActiveCall(activeCall) &&
|
||||
activeCall.conversationsWithSafetyNumberChanges.length ? (
|
||||
<SafetyNumberChangeDialog
|
||||
confirmText={i18n('icu:continueCall')}
|
||||
contacts={[
|
||||
{
|
||||
story: undefined,
|
||||
contacts: activeCall.conversationsWithSafetyNumberChanges,
|
||||
},
|
||||
]}
|
||||
getPreferredBadge={getPreferredBadge}
|
||||
i18n={i18n}
|
||||
onCancel={onSafetyNumberDialogCancel}
|
||||
onConfirm={() => {
|
||||
keyChangeOk({ conversationId: activeCall.conversation.id });
|
||||
}}
|
||||
renderSafetyNumber={renderSafetyNumberViewer}
|
||||
theme={theme}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function CallManager(props: PropsType): JSX.Element | null {
|
||||
const {
|
||||
acceptCall,
|
||||
activeCall,
|
||||
bounceAppIconStart,
|
||||
bounceAppIconStop,
|
||||
declineCall,
|
||||
i18n,
|
||||
incomingCall,
|
||||
notifyForCall,
|
||||
playRingtone,
|
||||
stopRingtone,
|
||||
setIsCallActive,
|
||||
setOutgoingRing,
|
||||
} = props;
|
||||
|
||||
export function CallManager({
|
||||
acceptCall,
|
||||
activeCall,
|
||||
approveUser,
|
||||
availableCameras,
|
||||
batchUserAction,
|
||||
blockClient,
|
||||
bounceAppIconStart,
|
||||
bounceAppIconStop,
|
||||
callLink,
|
||||
cancelCall,
|
||||
changeCallView,
|
||||
closeNeedPermissionScreen,
|
||||
declineCall,
|
||||
denyUser,
|
||||
getGroupCallVideoFrameSource,
|
||||
getPresentingSources,
|
||||
hangUpActiveCall,
|
||||
hasInitialLoadCompleted,
|
||||
i18n,
|
||||
incomingCall,
|
||||
isConversationTooBigToRing,
|
||||
isGroupCallRaiseHandEnabled,
|
||||
getIsSharingPhoneNumberWithEverybody,
|
||||
me,
|
||||
notifyForCall,
|
||||
openSystemPreferencesAction,
|
||||
pauseVoiceNotePlayer,
|
||||
playRingtone,
|
||||
removeClient,
|
||||
renderDeviceSelection,
|
||||
renderEmojiPicker,
|
||||
renderReactionPicker,
|
||||
sendGroupCallRaiseHand,
|
||||
sendGroupCallReaction,
|
||||
setGroupCallVideoRequest,
|
||||
setIsCallActive,
|
||||
setLocalAudio,
|
||||
setLocalPreview,
|
||||
setLocalVideo,
|
||||
setOutgoingRing,
|
||||
setPresenting,
|
||||
setRendererCanvas,
|
||||
showContactModal,
|
||||
showShareCallLinkViaSignal,
|
||||
startCall,
|
||||
stopRingtone,
|
||||
switchFromPresentationView,
|
||||
switchToPresentationView,
|
||||
toggleParticipants,
|
||||
togglePip,
|
||||
toggleScreenRecordingPermissionsDialog,
|
||||
toggleSettings,
|
||||
}: PropsType): JSX.Element | null {
|
||||
const isCallActive = Boolean(activeCall);
|
||||
useEffect(() => {
|
||||
setIsCallActive(isCallActive);
|
||||
}, [isCallActive, setIsCallActive]);
|
||||
|
||||
const shouldRing = getShouldRing(props);
|
||||
const shouldRing = getShouldRing({
|
||||
activeCall,
|
||||
incomingCall,
|
||||
isConversationTooBigToRing,
|
||||
hasInitialLoadCompleted,
|
||||
});
|
||||
useEffect(() => {
|
||||
if (shouldRing) {
|
||||
log.info('CallManager: Playing ringtone');
|
||||
|
@ -528,8 +620,54 @@ export function CallManager(props: PropsType): JSX.Element | null {
|
|||
// `props` should logically have an `activeCall` at this point, but TypeScript can't
|
||||
// figure that out, so we pass it in again.
|
||||
return (
|
||||
<CallingToastProvider i18n={props.i18n}>
|
||||
<ActiveCallManager {...props} activeCall={activeCall} />
|
||||
<CallingToastProvider i18n={i18n}>
|
||||
<ActiveCallManager
|
||||
activeCall={activeCall}
|
||||
availableCameras={availableCameras}
|
||||
approveUser={approveUser}
|
||||
batchUserAction={batchUserAction}
|
||||
blockClient={blockClient}
|
||||
callLink={callLink}
|
||||
cancelCall={cancelCall}
|
||||
changeCallView={changeCallView}
|
||||
closeNeedPermissionScreen={closeNeedPermissionScreen}
|
||||
denyUser={denyUser}
|
||||
getGroupCallVideoFrameSource={getGroupCallVideoFrameSource}
|
||||
getPresentingSources={getPresentingSources}
|
||||
hangUpActiveCall={hangUpActiveCall}
|
||||
i18n={i18n}
|
||||
isGroupCallRaiseHandEnabled={isGroupCallRaiseHandEnabled}
|
||||
getIsSharingPhoneNumberWithEverybody={
|
||||
getIsSharingPhoneNumberWithEverybody
|
||||
}
|
||||
me={me}
|
||||
openSystemPreferencesAction={openSystemPreferencesAction}
|
||||
pauseVoiceNotePlayer={pauseVoiceNotePlayer}
|
||||
removeClient={removeClient}
|
||||
renderDeviceSelection={renderDeviceSelection}
|
||||
renderEmojiPicker={renderEmojiPicker}
|
||||
renderReactionPicker={renderReactionPicker}
|
||||
sendGroupCallRaiseHand={sendGroupCallRaiseHand}
|
||||
sendGroupCallReaction={sendGroupCallReaction}
|
||||
setGroupCallVideoRequest={setGroupCallVideoRequest}
|
||||
setLocalAudio={setLocalAudio}
|
||||
setLocalPreview={setLocalPreview}
|
||||
setLocalVideo={setLocalVideo}
|
||||
setOutgoingRing={setOutgoingRing}
|
||||
setPresenting={setPresenting}
|
||||
setRendererCanvas={setRendererCanvas}
|
||||
showContactModal={showContactModal}
|
||||
showShareCallLinkViaSignal={showShareCallLinkViaSignal}
|
||||
startCall={startCall}
|
||||
switchFromPresentationView={switchFromPresentationView}
|
||||
switchToPresentationView={switchToPresentationView}
|
||||
toggleParticipants={toggleParticipants}
|
||||
togglePip={togglePip}
|
||||
toggleScreenRecordingPermissionsDialog={
|
||||
toggleScreenRecordingPermissionsDialog
|
||||
}
|
||||
toggleSettings={toggleSettings}
|
||||
/>
|
||||
</CallingToastProvider>
|
||||
);
|
||||
}
|
||||
|
@ -581,9 +719,20 @@ function getShouldRing({
|
|||
activeCall,
|
||||
incomingCall,
|
||||
isConversationTooBigToRing,
|
||||
hasInitialLoadCompleted,
|
||||
}: Readonly<
|
||||
Pick<PropsType, 'activeCall' | 'incomingCall' | 'isConversationTooBigToRing'>
|
||||
Pick<
|
||||
PropsType,
|
||||
| 'activeCall'
|
||||
| 'incomingCall'
|
||||
| 'isConversationTooBigToRing'
|
||||
| 'hasInitialLoadCompleted'
|
||||
>
|
||||
>): boolean {
|
||||
if (!hasInitialLoadCompleted) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (incomingCall != null) {
|
||||
// don't ring a large group
|
||||
if (isConversationTooBigToRing) {
|
||||
|
|
|
@ -5,7 +5,7 @@ import React, { useRef, useEffect } from 'react';
|
|||
import type { LocalizerType } from '../types/Util';
|
||||
import { AvatarColors } from '../types/Colors';
|
||||
import { Avatar, AvatarSize } from './Avatar';
|
||||
import { Intl } from './Intl';
|
||||
import { I18n } from './I18n';
|
||||
import { ContactName } from './conversation/ContactName';
|
||||
import type { ConversationType } from '../state/ducks/conversations';
|
||||
|
||||
|
@ -13,7 +13,7 @@ export type Props = {
|
|||
conversation: Pick<
|
||||
ConversationType,
|
||||
| 'acceptedMessageRequest'
|
||||
| 'avatarPath'
|
||||
| 'avatarUrl'
|
||||
| 'color'
|
||||
| 'isMe'
|
||||
| 'name'
|
||||
|
@ -21,7 +21,7 @@ export type Props = {
|
|||
| 'profileName'
|
||||
| 'sharedGroupNames'
|
||||
| 'title'
|
||||
| 'unblurredAvatarPath'
|
||||
| 'unblurredAvatarUrl'
|
||||
>;
|
||||
i18n: LocalizerType;
|
||||
close: () => void;
|
||||
|
@ -46,7 +46,7 @@ export function CallNeedPermissionScreen({
|
|||
<div className="module-call-need-permission-screen">
|
||||
<Avatar
|
||||
acceptedMessageRequest={conversation.acceptedMessageRequest}
|
||||
avatarPath={conversation.avatarPath}
|
||||
avatarUrl={conversation.avatarUrl}
|
||||
badge={undefined}
|
||||
color={conversation.color || AvatarColors[0]}
|
||||
noteToSelf={false}
|
||||
|
@ -61,7 +61,7 @@ export function CallNeedPermissionScreen({
|
|||
/>
|
||||
|
||||
<p className="module-call-need-permission-screen__text">
|
||||
<Intl
|
||||
<I18n
|
||||
i18n={i18n}
|
||||
id="icu:callNeedPermission"
|
||||
components={{
|
||||
|
|
|
@ -18,7 +18,7 @@ import { CallReactionBurstEmoji } from './CallReactionBurstEmoji';
|
|||
const LIFETIME = 3000;
|
||||
|
||||
export type CallReactionBurstType = {
|
||||
value: string;
|
||||
values: Array<string>;
|
||||
};
|
||||
|
||||
type CallReactionBurstStateType = CallReactionBurstType & {
|
||||
|
@ -124,10 +124,10 @@ export function CallReactionBurstProvider({
|
|||
<CallReactionBurstContext.Provider value={contextValue}>
|
||||
{createPortal(
|
||||
<div className="CallReactionBursts">
|
||||
{bursts.map(({ value, key }) => (
|
||||
{bursts.map(({ values, key }) => (
|
||||
<CallReactionBurstEmoji
|
||||
key={key}
|
||||
value={value}
|
||||
values={values}
|
||||
onAnimationEnd={() => hideBurst(key)}
|
||||
/>
|
||||
))}
|
||||
|
|
|
@ -8,7 +8,7 @@ import { v4 as uuid } from 'uuid';
|
|||
import { Emojify } from './conversation/Emojify';
|
||||
|
||||
export type PropsType = {
|
||||
value: string;
|
||||
values: Array<string>;
|
||||
onAnimationEnd?: () => unknown;
|
||||
};
|
||||
|
||||
|
@ -25,31 +25,36 @@ type AnimationConfig = {
|
|||
velocity: number;
|
||||
};
|
||||
|
||||
export function CallReactionBurstEmoji({ value }: PropsType): JSX.Element {
|
||||
// values is an array of emojis, which is useful when bursting multi skin tone set of
|
||||
// emojis to get the correct representation
|
||||
export function CallReactionBurstEmoji({ values }: PropsType): JSX.Element {
|
||||
const [toY, setToY] = React.useState<number>(0);
|
||||
const fromY = -50;
|
||||
|
||||
const generateEmojiProps = React.useCallback(() => {
|
||||
return {
|
||||
key: uuid(),
|
||||
value,
|
||||
springConfig: {
|
||||
mass: random(10, 20),
|
||||
tension: random(45, 60),
|
||||
friction: random(20, 60),
|
||||
clamp: true,
|
||||
precision: 0,
|
||||
velocity: -0.01,
|
||||
},
|
||||
fromX: random(0, 20),
|
||||
toX: random(-30, 300),
|
||||
fromY,
|
||||
toY,
|
||||
toScale: random(1, 2.5, true),
|
||||
fromRotate: random(-45, 45),
|
||||
toRotate: random(-45, 45),
|
||||
};
|
||||
}, [fromY, toY, value]);
|
||||
const generateEmojiProps = React.useCallback(
|
||||
(index: number) => {
|
||||
return {
|
||||
key: uuid(),
|
||||
value: values[index % values.length],
|
||||
springConfig: {
|
||||
mass: random(10, 20),
|
||||
tension: random(45, 60),
|
||||
friction: random(20, 60),
|
||||
clamp: true,
|
||||
precision: 0,
|
||||
velocity: -0.01,
|
||||
},
|
||||
fromX: random(0, 20),
|
||||
toX: random(-30, 300),
|
||||
fromY,
|
||||
toY,
|
||||
toScale: random(1, 2.5, true),
|
||||
fromRotate: random(-45, 45),
|
||||
toRotate: random(-45, 45),
|
||||
};
|
||||
},
|
||||
[fromY, toY, values]
|
||||
);
|
||||
|
||||
// Calculate target Y position before first render. Emojis need to animate Y upwards
|
||||
// by the value of the container's top, plus the emoji's maximum height.
|
||||
|
@ -59,12 +64,12 @@ export function CallReactionBurstEmoji({ value }: PropsType): JSX.Element {
|
|||
const { top } = containerRef.current.getBoundingClientRect();
|
||||
const calculatedToY = -top;
|
||||
setToY(calculatedToY);
|
||||
setEmojis([{ ...generateEmojiProps(), toY: calculatedToY }]);
|
||||
setEmojis([{ ...generateEmojiProps(0), toY: calculatedToY }]);
|
||||
}
|
||||
}, [generateEmojiProps]);
|
||||
|
||||
const [emojis, setEmojis] = React.useState<Array<AnimatedEmojiProps>>([
|
||||
generateEmojiProps(),
|
||||
generateEmojiProps(0),
|
||||
]);
|
||||
|
||||
React.useEffect(() => {
|
||||
|
@ -74,14 +79,14 @@ export function CallReactionBurstEmoji({ value }: PropsType): JSX.Element {
|
|||
if (emojiCount + 1 >= NUM_EMOJIS) {
|
||||
clearInterval(timer);
|
||||
}
|
||||
return [...curEmojis, generateEmojiProps()];
|
||||
return [...curEmojis, generateEmojiProps(emojiCount)];
|
||||
});
|
||||
}, DELAY_BETWEEN_EMOJIS);
|
||||
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [fromY, toY, value, generateEmojiProps]);
|
||||
}, [fromY, toY, values, generateEmojiProps]);
|
||||
|
||||
return (
|
||||
<div className="CallReactionBurstEmoji" ref={containerRef}>
|
||||
|
|
|
@ -33,6 +33,7 @@ import {
|
|||
import { fakeGetGroupCallVideoFrameSource } from '../test-both/helpers/fakeGetGroupCallVideoFrameSource';
|
||||
import enMessages from '../../_locales/en/messages.json';
|
||||
import { CallingToastProvider, useCallingToasts } from './CallingToast';
|
||||
import type { CallingImageDataCache } from './CallManager';
|
||||
|
||||
const MAX_PARTICIPANTS = 75;
|
||||
const LOCAL_DEMUX_ID = 1;
|
||||
|
@ -41,7 +42,7 @@ const i18n = setupI18n('en', enMessages);
|
|||
|
||||
const conversation = getDefaultConversation({
|
||||
id: '3051234567',
|
||||
avatarPath: undefined,
|
||||
avatarUrl: undefined,
|
||||
color: AvatarColors[0],
|
||||
title: 'Rick Sanchez',
|
||||
name: 'Rick Sanchez',
|
||||
|
@ -67,6 +68,7 @@ type GroupCallOverrideProps = OverridePropsBase & {
|
|||
callMode: CallMode.Group;
|
||||
connectionState?: GroupCallConnectionState;
|
||||
peekedParticipants?: Array<ConversationType>;
|
||||
pendingParticipants?: Array<ConversationType>;
|
||||
raisedHands?: Set<number>;
|
||||
remoteParticipants?: Array<GroupCallRemoteParticipantType>;
|
||||
remoteAudioLevel?: number;
|
||||
|
@ -90,7 +92,7 @@ const createActiveDirectCallProp = (
|
|||
hasRemoteVideo: boolean;
|
||||
presenting: boolean;
|
||||
title: string;
|
||||
}
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
@ -124,7 +126,6 @@ const createActiveGroupCallProp = (overrideProps: GroupCallOverrideProps) => ({
|
|||
callMode: CallMode.Group as CallMode.Group,
|
||||
connectionState:
|
||||
overrideProps.connectionState || GroupCallConnectionState.Connected,
|
||||
conversationsWithSafetyNumberChanges: [],
|
||||
conversationsByDemuxId: getConversationsByDemuxId(overrideProps),
|
||||
joinState: GroupCallJoinState.Joined,
|
||||
localDemuxId: LOCAL_DEMUX_ID,
|
||||
|
@ -136,6 +137,7 @@ const createActiveGroupCallProp = (overrideProps: GroupCallOverrideProps) => ({
|
|||
isConversationTooBigToRing: false,
|
||||
peekedParticipants:
|
||||
overrideProps.peekedParticipants || overrideProps.remoteParticipants || [],
|
||||
pendingParticipants: overrideProps.pendingParticipants || [],
|
||||
raisedHands:
|
||||
overrideProps.raisedHands ||
|
||||
getRaisedHands(overrideProps) ||
|
||||
|
@ -182,13 +184,17 @@ const createProps = (
|
|||
}
|
||||
): PropsType => ({
|
||||
activeCall: createActiveCallProp(overrideProps),
|
||||
approveUser: action('approve-user'),
|
||||
batchUserAction: action('batch-user-action'),
|
||||
changeCallView: action('change-call-view'),
|
||||
denyUser: action('deny-user'),
|
||||
getGroupCallVideoFrameSource: fakeGetGroupCallVideoFrameSource,
|
||||
getPresentingSources: action('get-presenting-sources'),
|
||||
hangUpActiveCall: action('hang-up'),
|
||||
i18n,
|
||||
imageDataCache: React.createRef<CallingImageDataCache>(),
|
||||
isCallLinkAdmin: true,
|
||||
isGroupCallRaiseHandEnabled: true,
|
||||
isGroupCallReactionsEnabled: true,
|
||||
me: getDefaultConversation({
|
||||
color: AvatarColors[1],
|
||||
id: '6146087e-f7ef-457e-9a8d-47df1fdd6b25',
|
||||
|
@ -231,6 +237,7 @@ export default {
|
|||
title: 'Components/CallScreen',
|
||||
argTypes: {},
|
||||
args: {},
|
||||
excludeStories: ['allRemoteParticipants'],
|
||||
} satisfies Meta<PropsType>;
|
||||
|
||||
export function Default(): JSX.Element {
|
||||
|
@ -374,7 +381,7 @@ export function GroupCallYourHandRaised(): JSX.Element {
|
|||
const PARTICIPANT_EMOJIS = ['❤️', '🤔', '✨', '😂', '🦄'] as const;
|
||||
|
||||
// We generate these upfront so that the list is stable when you move the slider.
|
||||
const allRemoteParticipants = times(MAX_PARTICIPANTS).map(index => {
|
||||
export const allRemoteParticipants = times(MAX_PARTICIPANTS).map(index => {
|
||||
const mediaKeysReceived = (index + 1) % 20 !== 0;
|
||||
|
||||
return {
|
||||
|
@ -654,9 +661,9 @@ export function GroupCallReactions(): JSX.Element {
|
|||
})
|
||||
);
|
||||
|
||||
const activeCall = useReactionsEmitter(
|
||||
props.activeCall as ActiveGroupCallType
|
||||
);
|
||||
const activeCall = useReactionsEmitter({
|
||||
activeCall: props.activeCall as ActiveGroupCallType,
|
||||
});
|
||||
|
||||
return <CallScreen {...props} activeCall={activeCall} />;
|
||||
}
|
||||
|
@ -671,11 +678,30 @@ export function GroupCallReactionsSpam(): JSX.Element {
|
|||
})
|
||||
);
|
||||
|
||||
const activeCall = useReactionsEmitter(
|
||||
props.activeCall as ActiveGroupCallType,
|
||||
250
|
||||
const activeCall = useReactionsEmitter({
|
||||
activeCall: props.activeCall as ActiveGroupCallType,
|
||||
frequency: 250,
|
||||
});
|
||||
|
||||
return <CallScreen {...props} activeCall={activeCall} />;
|
||||
}
|
||||
|
||||
export function GroupCallReactionsSkinTones(): JSX.Element {
|
||||
const remoteParticipants = allRemoteParticipants.slice(0, 3);
|
||||
const [props] = React.useState(
|
||||
createProps({
|
||||
callMode: CallMode.Group,
|
||||
remoteParticipants,
|
||||
viewMode: CallViewMode.Overflow,
|
||||
})
|
||||
);
|
||||
|
||||
const activeCall = useReactionsEmitter({
|
||||
activeCall: props.activeCall as ActiveGroupCallType,
|
||||
frequency: 500,
|
||||
emojis: ['👍', '👍🏻', '👍🏼', '👍🏽', '👍🏾', '👍🏿', '❤️', '😂', '😮', '😢'],
|
||||
});
|
||||
|
||||
return <CallScreen {...props} activeCall={activeCall} />;
|
||||
}
|
||||
|
||||
|
@ -702,11 +728,17 @@ export function GroupCallReactionsManyInOrder(): JSX.Element {
|
|||
return <CallScreen {...props} />;
|
||||
}
|
||||
|
||||
function useReactionsEmitter(
|
||||
activeCall: ActiveGroupCallType,
|
||||
function useReactionsEmitter({
|
||||
activeCall,
|
||||
frequency = 2000,
|
||||
removeAfter = 5000
|
||||
) {
|
||||
removeAfter = 5000,
|
||||
emojis = DEFAULT_PREFERRED_REACTION_EMOJI,
|
||||
}: {
|
||||
activeCall: ActiveGroupCallType;
|
||||
frequency?: number;
|
||||
removeAfter?: number;
|
||||
emojis?: Array<string>;
|
||||
}) {
|
||||
const [call, setCall] = React.useState(activeCall);
|
||||
React.useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
|
@ -726,7 +758,7 @@ function useReactionsEmitter(
|
|||
{
|
||||
timestamp: timeNow,
|
||||
demuxId,
|
||||
value: sample(DEFAULT_PREFERRED_REACTION_EMOJI) as string,
|
||||
value: sample(emojis) as string,
|
||||
},
|
||||
];
|
||||
|
||||
|
@ -737,7 +769,7 @@ function useReactionsEmitter(
|
|||
});
|
||||
}, frequency);
|
||||
return () => clearInterval(interval);
|
||||
}, [frequency, removeAfter, call]);
|
||||
}, [emojis, frequency, removeAfter, call]);
|
||||
return call;
|
||||
}
|
||||
|
||||
|
|
|
@ -3,11 +3,13 @@
|
|||
|
||||
import type { ReactNode } from 'react';
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { isEqual, noop, sortBy } from 'lodash';
|
||||
import { isEqual, noop } from 'lodash';
|
||||
import classNames from 'classnames';
|
||||
import type { VideoFrameSource } from '@signalapp/ringrtc';
|
||||
import type {
|
||||
ActiveCallStateType,
|
||||
BatchUserActionPayloadType,
|
||||
PendingUserActionPayloadType,
|
||||
SendGroupCallRaiseHandType,
|
||||
SendGroupCallReactionType,
|
||||
SetLocalAudioType,
|
||||
|
@ -87,16 +89,23 @@ import {
|
|||
} from './CallReactionBurst';
|
||||
import { isGroupOrAdhocActiveCall } from '../util/isGroupOrAdhocCall';
|
||||
import { assertDev } from '../util/assert';
|
||||
import { emojiToData } from './emoji/lib';
|
||||
import { CallingPendingParticipants } from './CallingPendingParticipants';
|
||||
import type { CallingImageDataCache } from './CallManager';
|
||||
|
||||
export type PropsType = {
|
||||
activeCall: ActiveCallType;
|
||||
approveUser: (payload: PendingUserActionPayloadType) => void;
|
||||
batchUserAction: (payload: BatchUserActionPayloadType) => void;
|
||||
denyUser: (payload: PendingUserActionPayloadType) => void;
|
||||
getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource;
|
||||
getPresentingSources: () => void;
|
||||
groupMembers?: Array<Pick<ConversationType, 'id' | 'firstName' | 'title'>>;
|
||||
hangUpActiveCall: (reason: string) => void;
|
||||
i18n: LocalizerType;
|
||||
imageDataCache: React.RefObject<CallingImageDataCache>;
|
||||
isCallLinkAdmin: boolean;
|
||||
isGroupCallRaiseHandEnabled: boolean;
|
||||
isGroupCallReactionsEnabled: boolean;
|
||||
me: ConversationType;
|
||||
openSystemPreferencesAction: () => unknown;
|
||||
renderReactionPicker: (
|
||||
|
@ -178,14 +187,18 @@ function CallDuration({
|
|||
|
||||
export function CallScreen({
|
||||
activeCall,
|
||||
approveUser,
|
||||
batchUserAction,
|
||||
changeCallView,
|
||||
denyUser,
|
||||
getGroupCallVideoFrameSource,
|
||||
getPresentingSources,
|
||||
groupMembers,
|
||||
hangUpActiveCall,
|
||||
i18n,
|
||||
imageDataCache,
|
||||
isCallLinkAdmin,
|
||||
isGroupCallRaiseHandEnabled,
|
||||
isGroupCallReactionsEnabled,
|
||||
me,
|
||||
openSystemPreferencesAction,
|
||||
renderEmojiPicker,
|
||||
|
@ -397,6 +410,11 @@ export function CallScreen({
|
|||
throw missingCaseError(activeCall);
|
||||
}
|
||||
|
||||
const pendingParticipants =
|
||||
activeCall.callMode === CallMode.Adhoc && isCallLinkAdmin
|
||||
? activeCall.pendingParticipants
|
||||
: [];
|
||||
|
||||
let lonelyInCallNode: ReactNode;
|
||||
let localPreviewNode: ReactNode;
|
||||
|
||||
|
@ -414,7 +432,7 @@ export function CallScreen({
|
|||
{isSendingVideo ? (
|
||||
<video ref={localVideoRef} autoPlay />
|
||||
) : (
|
||||
<CallBackgroundBlur avatarPath={me.avatarPath}>
|
||||
<CallBackgroundBlur avatarUrl={me.avatarUrl}>
|
||||
<div className="module-calling__spacer module-calling__camera-is-off-spacer" />
|
||||
<div className="module-calling__camera-is-off">
|
||||
{i18n('icu:calling__your-video-is-off')}
|
||||
|
@ -435,10 +453,10 @@ export function CallScreen({
|
|||
autoPlay
|
||||
/>
|
||||
) : (
|
||||
<CallBackgroundBlur avatarPath={me.avatarPath}>
|
||||
<CallBackgroundBlur avatarUrl={me.avatarUrl}>
|
||||
<Avatar
|
||||
acceptedMessageRequest
|
||||
avatarPath={me.avatarPath}
|
||||
avatarUrl={me.avatarUrl}
|
||||
badge={undefined}
|
||||
color={me.color || AvatarColors[0]}
|
||||
noteToSelf={false}
|
||||
|
@ -473,6 +491,7 @@ export function CallScreen({
|
|||
|
||||
const controlsFadedOut = !showControls && !isAudioOnly && isConnected;
|
||||
const controlsFadeClass = classNames({
|
||||
'module-ongoing-call__controls': true,
|
||||
'module-ongoing-call__controls--fadeIn':
|
||||
(showControls || isAudioOnly) && !isConnected,
|
||||
'module-ongoing-call__controls--fadeOut': controlsFadedOut,
|
||||
|
@ -540,24 +559,34 @@ export function CallScreen({
|
|||
}
|
||||
|
||||
const renderRaisedHandsToast = React.useCallback(
|
||||
(hands: Array<number>) => {
|
||||
// Sort "You" to the front.
|
||||
const names = sortBy(hands, demuxId =>
|
||||
demuxId === localDemuxId ? 0 : 1
|
||||
).map(demuxId =>
|
||||
demuxId === localDemuxId
|
||||
? i18n('icu:you')
|
||||
: conversationsByDemuxId.get(demuxId)?.title
|
||||
);
|
||||
(demuxIds: Array<number>) => {
|
||||
const names: Array<string> = [];
|
||||
let isYourHandRaised = false;
|
||||
for (const demuxId of demuxIds) {
|
||||
if (demuxId === localDemuxId) {
|
||||
isYourHandRaised = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const handConversation = conversationsByDemuxId.get(demuxId);
|
||||
if (!handConversation) {
|
||||
continue;
|
||||
}
|
||||
|
||||
names.push(handConversation.title);
|
||||
}
|
||||
|
||||
const count = names.length;
|
||||
const name = names[0] ?? '';
|
||||
const otherName = names[1] ?? '';
|
||||
|
||||
let message: string;
|
||||
let buttonOverride: JSX.Element | undefined;
|
||||
const count = names.length;
|
||||
switch (count) {
|
||||
case 0:
|
||||
return undefined;
|
||||
case 1:
|
||||
if (names[0] === i18n('icu:you')) {
|
||||
if (isYourHandRaised) {
|
||||
message = i18n('icu:CallControls__RaiseHandsToast--you');
|
||||
buttonOverride = (
|
||||
<button
|
||||
|
@ -570,22 +599,37 @@ export function CallScreen({
|
|||
);
|
||||
} else {
|
||||
message = i18n('icu:CallControls__RaiseHandsToast--one', {
|
||||
name: names[0] ?? '',
|
||||
name,
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 2:
|
||||
message = i18n('icu:CallControls__RaiseHandsToast--two', {
|
||||
name: names[0] ?? '',
|
||||
otherName: names[1] ?? '',
|
||||
});
|
||||
if (isYourHandRaised) {
|
||||
message = i18n('icu:CallControls__RaiseHandsToast--you-and-one', {
|
||||
otherName,
|
||||
});
|
||||
} else {
|
||||
message = i18n('icu:CallControls__RaiseHandsToast--two', {
|
||||
name,
|
||||
otherName,
|
||||
});
|
||||
}
|
||||
break;
|
||||
default:
|
||||
message = i18n('icu:CallControls__RaiseHandsToast--more', {
|
||||
name: names[0] ?? '',
|
||||
otherName: names[1] ?? '',
|
||||
overflowCount: names.length - 2,
|
||||
});
|
||||
default: {
|
||||
const overflowCount = count - 2;
|
||||
if (isYourHandRaised) {
|
||||
message = i18n('icu:CallControls__RaiseHandsToast--you-and-more', {
|
||||
otherName,
|
||||
overflowCount,
|
||||
});
|
||||
} else {
|
||||
message = i18n('icu:CallControls__RaiseHandsToast--more', {
|
||||
name: names[0] ?? '',
|
||||
otherName,
|
||||
overflowCount,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="CallingRaisedHandsToast__Content">
|
||||
|
@ -676,6 +720,7 @@ export function CallScreen({
|
|||
<GroupCallRemoteParticipants
|
||||
callViewMode={activeCall.viewMode}
|
||||
getGroupCallVideoFrameSource={getGroupCallVideoFrameSource}
|
||||
imageDataCache={imageDataCache}
|
||||
i18n={i18n}
|
||||
remoteParticipants={activeCall.remoteParticipants}
|
||||
setGroupCallVideoRequest={setGroupCallVideoRequest}
|
||||
|
@ -812,6 +857,15 @@ export function CallScreen({
|
|||
renderRaisedHandsToast={renderRaisedHandsToast}
|
||||
i18n={i18n}
|
||||
/>
|
||||
{pendingParticipants.length ? (
|
||||
<CallingPendingParticipants
|
||||
i18n={i18n}
|
||||
participants={pendingParticipants}
|
||||
approveUser={approveUser}
|
||||
batchUserAction={batchUserAction}
|
||||
denyUser={denyUser}
|
||||
/>
|
||||
) : null}
|
||||
{/* We render the local preview first and set the footer flex direction to row-reverse
|
||||
to ensure the preview is visible at low viewport widths. */}
|
||||
<div className="module-ongoing-call__footer">
|
||||
|
@ -850,20 +904,19 @@ export function CallScreen({
|
|||
className="CallControls__ReactionPickerContainer"
|
||||
ref={reactionPickerContainerRef}
|
||||
>
|
||||
{isGroupCallReactionsEnabled &&
|
||||
renderReactionPicker({
|
||||
ref: reactionPickerRef,
|
||||
onClose: () => setShowReactionPicker(false),
|
||||
onPick: emoji => {
|
||||
setShowReactionPicker(false);
|
||||
sendGroupCallReaction({
|
||||
callMode: activeCall.callMode,
|
||||
conversationId: conversation.id,
|
||||
value: emoji,
|
||||
});
|
||||
},
|
||||
renderEmojiPicker,
|
||||
})}
|
||||
{renderReactionPicker({
|
||||
ref: reactionPickerRef,
|
||||
onClose: () => setShowReactionPicker(false),
|
||||
onPick: emoji => {
|
||||
setShowReactionPicker(false);
|
||||
sendGroupCallReaction({
|
||||
callMode: activeCall.callMode,
|
||||
conversationId: conversation.id,
|
||||
value: emoji,
|
||||
});
|
||||
},
|
||||
renderEmojiPicker,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
@ -884,14 +937,6 @@ export function CallScreen({
|
|||
onClick={toggleAudio}
|
||||
tooltipDirection={TooltipPlacement.Top}
|
||||
/>
|
||||
<CallingButton
|
||||
buttonType={presentingButtonType}
|
||||
i18n={i18n}
|
||||
onMouseEnter={onControlsMouseEnter}
|
||||
onMouseLeave={onControlsMouseLeave}
|
||||
onClick={togglePresenting}
|
||||
tooltipDirection={TooltipPlacement.Top}
|
||||
/>
|
||||
{isGroupCallRaiseHandEnabled && raiseHandButtonType && (
|
||||
<CallingButton
|
||||
buttonType={raiseHandButtonType}
|
||||
|
@ -902,7 +947,15 @@ export function CallScreen({
|
|||
tooltipDirection={TooltipPlacement.Top}
|
||||
/>
|
||||
)}
|
||||
{isGroupCallReactionsEnabled && reactButtonType && (
|
||||
<CallingButton
|
||||
buttonType={presentingButtonType}
|
||||
i18n={i18n}
|
||||
onMouseEnter={onControlsMouseEnter}
|
||||
onMouseLeave={onControlsMouseLeave}
|
||||
onClick={togglePresenting}
|
||||
tooltipDirection={TooltipPlacement.Top}
|
||||
/>
|
||||
{reactButtonType && (
|
||||
<div
|
||||
className={classNames('CallControls__ReactButtonContainer', {
|
||||
'CallControls__ReactButtonContainer--menu-shown':
|
||||
|
@ -1048,7 +1101,13 @@ function useReactionsToast(props: UseReactionsToastType): void {
|
|||
const reactionsShown = useRef<
|
||||
Map<
|
||||
string,
|
||||
{ value: string; isBursted: boolean; expireAt: number; demuxId: number }
|
||||
{
|
||||
value: string;
|
||||
originalValue: string;
|
||||
isBursted: boolean;
|
||||
expireAt: number;
|
||||
demuxId: number;
|
||||
}
|
||||
>
|
||||
>(new Map());
|
||||
const burstsShown = useRef<Map<string, number>>(new Map());
|
||||
|
@ -1094,8 +1153,13 @@ function useReactionsToast(props: UseReactionsToastType): void {
|
|||
recentBurstTime &&
|
||||
recentBurstTime + REACTIONS_BURST_TRAILING_WINDOW > time
|
||||
);
|
||||
// Normalize skin tone emoji to calculate burst threshold, but save original
|
||||
// value to show in the burst animation
|
||||
const emojiData = emojiToData(value);
|
||||
const normalizedValue = emojiData?.unified ?? value;
|
||||
reactionsShown.current.set(key, {
|
||||
value,
|
||||
value: normalizedValue,
|
||||
originalValue: value,
|
||||
isBursted,
|
||||
expireAt: timestamp + REACTIONS_BURST_WINDOW,
|
||||
demuxId,
|
||||
|
@ -1158,6 +1222,7 @@ function useReactionsToast(props: UseReactionsToastType): void {
|
|||
}
|
||||
|
||||
burstsShown.current.set(value, time);
|
||||
const values: Array<string> = [];
|
||||
reactionKeys.forEach(key => {
|
||||
const reactionShown = reactionsShown.current.get(key);
|
||||
if (!reactionShown) {
|
||||
|
@ -1165,8 +1230,9 @@ function useReactionsToast(props: UseReactionsToastType): void {
|
|||
}
|
||||
|
||||
reactionShown.isBursted = true;
|
||||
values.push(reactionShown.originalValue);
|
||||
});
|
||||
showBurst({ value });
|
||||
showBurst({ values });
|
||||
|
||||
if (burstsShown.current.size >= REACTIONS_BURST_MAX_IN_SHORT_WINDOW) {
|
||||
break;
|
||||
|
|
|
@ -33,7 +33,7 @@ function createParticipant(
|
|||
sharingScreen: Boolean(participantProps.sharingScreen),
|
||||
videoAspectRatio: 1.3,
|
||||
...getDefaultConversationWithServiceId({
|
||||
avatarPath: participantProps.avatarPath,
|
||||
avatarUrl: participantProps.avatarUrl,
|
||||
color: sample(AvatarColors),
|
||||
isBlocked: Boolean(participantProps.isBlocked),
|
||||
name: participantProps.name,
|
||||
|
@ -49,8 +49,10 @@ function getCallLink(overrideProps: Partial<CallLinkType> = {}): CallLinkType {
|
|||
return {
|
||||
roomId: 'abcd1234abcd1234abcd1234abcd1234abcd1234',
|
||||
rootKey: 'abcd-abcd-abcd-abcd-abcd-abcd-abcd-abcd',
|
||||
adminKey: null,
|
||||
name: 'Axolotl Discuss',
|
||||
restrictions: CallLinkRestrictions.None,
|
||||
revoked: false,
|
||||
expiration: Date.now() + 30 * 24 * 60 * 60 * 1000,
|
||||
...overrideProps,
|
||||
};
|
||||
|
@ -59,10 +61,16 @@ function getCallLink(overrideProps: Partial<CallLinkType> = {}): CallLinkType {
|
|||
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
||||
callLink: getCallLink(overrideProps.callLink || {}),
|
||||
i18n,
|
||||
isCallLinkAdmin: overrideProps.isCallLinkAdmin || false,
|
||||
isUnknownContactDiscrete: overrideProps.isUnknownContactDiscrete || false,
|
||||
ourServiceId: generateAci(),
|
||||
participants: overrideProps.participants || [],
|
||||
onClose: action('on-close'),
|
||||
onCopyCallLink: action('on-copy-call-link'),
|
||||
onShareCallLinkViaSignal: action('on-share-call-link-via-signal'),
|
||||
removeClient: overrideProps.removeClient || action('remove-client'),
|
||||
blockClient: overrideProps.blockClient || action('block-client'),
|
||||
showContactModal: action('show-contact-modal'),
|
||||
});
|
||||
|
||||
export default {
|
||||
|
@ -134,3 +142,35 @@ export function Overflow(): JSX.Element {
|
|||
});
|
||||
return <CallingAdhocCallInfo {...props} />;
|
||||
}
|
||||
|
||||
export function AsAdmin(): JSX.Element {
|
||||
const props = createProps({
|
||||
participants: [
|
||||
createParticipant({
|
||||
title: 'Son Goku',
|
||||
}),
|
||||
createParticipant({
|
||||
hasRemoteAudio: true,
|
||||
hasRemoteVideo: true,
|
||||
presenting: true,
|
||||
name: 'Rage Trunks',
|
||||
title: 'Rage Trunks',
|
||||
}),
|
||||
createParticipant({
|
||||
hasRemoteAudio: true,
|
||||
title: 'Prince Vegeta',
|
||||
}),
|
||||
createParticipant({
|
||||
hasRemoteAudio: true,
|
||||
hasRemoteVideo: true,
|
||||
name: 'Goku',
|
||||
title: 'Goku',
|
||||
}),
|
||||
createParticipant({
|
||||
title: 'Someone With A Really Long Name',
|
||||
}),
|
||||
],
|
||||
isCallLinkAdmin: true,
|
||||
});
|
||||
return <CallingAdhocCallInfo {...props} />;
|
||||
}
|
||||
|
|
|
@ -1,11 +1,10 @@
|
|||
// Copyright 2024 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
/* eslint-disable react/no-array-index-key */
|
||||
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { partition } from 'lodash';
|
||||
import { Avatar, AvatarSize } from './Avatar';
|
||||
import { ContactName } from './conversation/ContactName';
|
||||
import { InContactsIcon } from './InContactsIcon';
|
||||
|
@ -16,145 +15,407 @@ import { sortByTitle } from '../util/sortByTitle';
|
|||
import type { ConversationType } from '../state/ducks/conversations';
|
||||
import { ModalHost } from './ModalHost';
|
||||
import { isInSystemContacts } from '../util/isInSystemContacts';
|
||||
import type { RemoveClientType } from '../state/ducks/calling';
|
||||
import { AVATAR_COLOR_COUNT, AvatarColors } from '../types/Colors';
|
||||
import { Button } from './Button';
|
||||
import { Modal } from './Modal';
|
||||
import { Theme } from '../util/theme';
|
||||
import { ConfirmationDialog } from './ConfirmationDialog';
|
||||
|
||||
const MAX_UNKNOWN_AVATARS_COUNT = 3;
|
||||
|
||||
type ParticipantType = ConversationType & {
|
||||
hasRemoteAudio?: boolean;
|
||||
hasRemoteVideo?: boolean;
|
||||
isHandRaised?: boolean;
|
||||
presenting?: boolean;
|
||||
demuxId?: number;
|
||||
};
|
||||
|
||||
export type PropsType = {
|
||||
readonly callLink: CallLinkType;
|
||||
readonly i18n: LocalizerType;
|
||||
readonly isCallLinkAdmin: boolean;
|
||||
readonly isUnknownContactDiscrete: boolean;
|
||||
readonly ourServiceId: ServiceIdString | undefined;
|
||||
readonly participants: Array<ParticipantType>;
|
||||
readonly onClose: () => void;
|
||||
readonly onCopyCallLink: () => void;
|
||||
readonly onShareCallLinkViaSignal: () => void;
|
||||
readonly removeClient: (payload: RemoveClientType) => void;
|
||||
readonly blockClient: (payload: RemoveClientType) => void;
|
||||
readonly showContactModal: (
|
||||
contactId: string,
|
||||
conversationId?: string
|
||||
) => void;
|
||||
};
|
||||
|
||||
type UnknownContactsPropsType = {
|
||||
readonly i18n: LocalizerType;
|
||||
readonly isInAdditionToKnownContacts: boolean;
|
||||
readonly participants: Array<ParticipantType>;
|
||||
readonly showUnknownContactDialog: () => void;
|
||||
};
|
||||
|
||||
function UnknownContacts({
|
||||
i18n,
|
||||
isInAdditionToKnownContacts,
|
||||
participants,
|
||||
showUnknownContactDialog,
|
||||
}: UnknownContactsPropsType): JSX.Element {
|
||||
const renderUnknownAvatar = React.useCallback(
|
||||
({
|
||||
participant,
|
||||
key,
|
||||
size,
|
||||
}: {
|
||||
participant: ParticipantType;
|
||||
key: React.Key;
|
||||
size: AvatarSize;
|
||||
}) => {
|
||||
const colorIndex = participant.serviceId
|
||||
? (parseInt(participant.serviceId.slice(-4), 16) || 0) %
|
||||
AVATAR_COLOR_COUNT
|
||||
: 0;
|
||||
return (
|
||||
<Avatar
|
||||
acceptedMessageRequest={participant.acceptedMessageRequest}
|
||||
avatarUrl={participant.avatarUrl}
|
||||
badge={undefined}
|
||||
className="CallingAdhocCallInfo__UnknownContactAvatar"
|
||||
color={AvatarColors[colorIndex]}
|
||||
conversationType="direct"
|
||||
key={key}
|
||||
i18n={i18n}
|
||||
isMe={participant.isMe}
|
||||
profileName={participant.profileName}
|
||||
title={participant.title}
|
||||
sharedGroupNames={participant.sharedGroupNames}
|
||||
size={size}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[i18n]
|
||||
);
|
||||
|
||||
const visibleParticipants = participants.slice(0, MAX_UNKNOWN_AVATARS_COUNT);
|
||||
let avatarSize: AvatarSize;
|
||||
if (visibleParticipants.length === 1) {
|
||||
avatarSize = AvatarSize.THIRTY_SIX;
|
||||
} else if (visibleParticipants.length === 2) {
|
||||
avatarSize = AvatarSize.THIRTY;
|
||||
} else {
|
||||
avatarSize = AvatarSize.TWENTY_EIGHT;
|
||||
}
|
||||
|
||||
return (
|
||||
<li
|
||||
className="module-calling-participants-list__contact"
|
||||
key="unknown-contacts"
|
||||
>
|
||||
<div className="module-calling-participants-list__avatar-and-name">
|
||||
<div
|
||||
className={classNames(
|
||||
'CallingAdhocCallInfo__UnknownContactAvatarSet',
|
||||
'module-calling-participants-list__avatar-and-name'
|
||||
)}
|
||||
>
|
||||
{visibleParticipants.map((participant, key) =>
|
||||
renderUnknownAvatar({ participant, key, size: avatarSize })
|
||||
)}
|
||||
<div className="module-contact-name module-calling-participants-list__name">
|
||||
{i18n(
|
||||
isInAdditionToKnownContacts
|
||||
? 'icu:CallingAdhocCallInfo__UnknownContactLabel--in-addition'
|
||||
: 'icu:CallingAdhocCallInfo__UnknownContactLabel',
|
||||
{ count: participants.length }
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
aria-label="icu:CallingAdhocCallInfo__UnknownContactInfoButton"
|
||||
className="CallingAdhocCallInfo__UnknownContactInfoButton module-calling-participants-list__status-icon module-calling-participants-list__unknown-contact"
|
||||
onClick={showUnknownContactDialog}
|
||||
type="button"
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function CallingAdhocCallInfo({
|
||||
i18n,
|
||||
isCallLinkAdmin,
|
||||
isUnknownContactDiscrete,
|
||||
ourServiceId,
|
||||
participants,
|
||||
blockClient,
|
||||
onClose,
|
||||
onCopyCallLink,
|
||||
onShareCallLinkViaSignal,
|
||||
removeClient,
|
||||
showContactModal,
|
||||
}: PropsType): JSX.Element | null {
|
||||
const [isUnknownContactDialogVisible, setIsUnknownContactDialogVisible] =
|
||||
React.useState(false);
|
||||
const [removeClientDialogState, setRemoveClientDialogState] = React.useState<{
|
||||
demuxId: number;
|
||||
name: string;
|
||||
} | null>(null);
|
||||
|
||||
const hideUnknownContactDialog = React.useCallback(
|
||||
() => setIsUnknownContactDialogVisible(false),
|
||||
[setIsUnknownContactDialogVisible]
|
||||
);
|
||||
const onClickShareCallLinkViaSignal = React.useCallback(() => {
|
||||
onClose();
|
||||
onShareCallLinkViaSignal();
|
||||
}, [onClose, onShareCallLinkViaSignal]);
|
||||
|
||||
const [visibleParticipants, unknownParticipants] = React.useMemo<
|
||||
[Array<ParticipantType>, Array<ParticipantType>]
|
||||
>(
|
||||
() =>
|
||||
partition(
|
||||
participants,
|
||||
(participant: ParticipantType) =>
|
||||
isUnknownContactDiscrete || Boolean(participant.titleNoDefault)
|
||||
),
|
||||
[isUnknownContactDiscrete, participants]
|
||||
);
|
||||
const sortedParticipants = React.useMemo<Array<ParticipantType>>(
|
||||
() => sortByTitle(participants),
|
||||
[participants]
|
||||
() => sortByTitle(visibleParticipants),
|
||||
[visibleParticipants]
|
||||
);
|
||||
|
||||
const renderParticipant = React.useCallback(
|
||||
(participant: ParticipantType, key: React.Key) => (
|
||||
<button
|
||||
aria-label={i18n('icu:calling__ParticipantInfoButton')}
|
||||
className="module-calling-participants-list__contact"
|
||||
disabled={participant.isMe}
|
||||
// It's tempting to use `participant.serviceId` as the `key`
|
||||
// here, but that can result in duplicate keys for
|
||||
// participants who have joined on multiple devices.
|
||||
key={key}
|
||||
onClick={() => {
|
||||
if (participant.isMe) {
|
||||
return;
|
||||
}
|
||||
|
||||
onClose();
|
||||
showContactModal(participant.id);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<div className="module-calling-participants-list__avatar-and-name">
|
||||
<Avatar
|
||||
acceptedMessageRequest={participant.acceptedMessageRequest}
|
||||
avatarUrl={participant.avatarUrl}
|
||||
badge={undefined}
|
||||
color={participant.color}
|
||||
conversationType="direct"
|
||||
i18n={i18n}
|
||||
isMe={participant.isMe}
|
||||
profileName={participant.profileName}
|
||||
title={participant.title}
|
||||
sharedGroupNames={participant.sharedGroupNames}
|
||||
size={AvatarSize.THIRTY_SIX}
|
||||
/>
|
||||
{ourServiceId && participant.serviceId === ourServiceId ? (
|
||||
<span className="module-calling-participants-list__name">
|
||||
{i18n('icu:you')}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<ContactName
|
||||
module="module-calling-participants-list__name"
|
||||
title={participant.title}
|
||||
/>
|
||||
{isInSystemContacts(participant) ? (
|
||||
<span>
|
||||
{' '}
|
||||
<InContactsIcon
|
||||
className="module-calling-participants-list__contact-icon"
|
||||
i18n={i18n}
|
||||
/>
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={classNames(
|
||||
'module-calling-participants-list__status-icon',
|
||||
participant.isHandRaised &&
|
||||
'module-calling-participants-list__hand-raised'
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={classNames(
|
||||
'module-calling-participants-list__status-icon',
|
||||
participant.presenting &&
|
||||
'module-calling-participants-list__presenting',
|
||||
!participant.hasRemoteVideo &&
|
||||
'module-calling-participants-list__muted--video'
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={classNames(
|
||||
'module-calling-participants-list__status-icon',
|
||||
!participant.hasRemoteAudio &&
|
||||
'module-calling-participants-list__muted--audio'
|
||||
)}
|
||||
/>
|
||||
{isCallLinkAdmin &&
|
||||
participant.demuxId &&
|
||||
!(ourServiceId && participant.serviceId === ourServiceId) ? (
|
||||
<button
|
||||
aria-label={i18n('icu:CallingAdhocCallInfo__RemoveClient')}
|
||||
className={classNames(
|
||||
'CallingAdhocCallInfo__RemoveClient',
|
||||
'module-calling-participants-list__status-icon',
|
||||
'module-calling-participants-list__remove'
|
||||
)}
|
||||
onClick={event => {
|
||||
if (!participant.demuxId) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
setRemoveClientDialogState({
|
||||
demuxId: participant.demuxId,
|
||||
name: participant.title,
|
||||
});
|
||||
}}
|
||||
type="button"
|
||||
/>
|
||||
) : null}
|
||||
</button>
|
||||
),
|
||||
[
|
||||
i18n,
|
||||
isCallLinkAdmin,
|
||||
onClose,
|
||||
ourServiceId,
|
||||
setRemoveClientDialogState,
|
||||
showContactModal,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
<ModalHost
|
||||
modalName="CallingAdhocCallInfo"
|
||||
moduleClassName="CallingAdhocCallInfo"
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="CallingAdhocCallInfo module-calling-participants-list">
|
||||
<div className="module-calling-participants-list__header">
|
||||
<div className="module-calling-participants-list__title">
|
||||
{participants.length
|
||||
? i18n('icu:calling__in-this-call', {
|
||||
people: participants.length,
|
||||
})
|
||||
: i18n('icu:calling__in-this-call--zero')}
|
||||
<>
|
||||
{removeClientDialogState != null ? (
|
||||
<ConfirmationDialog
|
||||
dialogName="CallingAdhocCallInfo.removeClientDialog"
|
||||
moduleClassName="CallingAdhocCallInfo__RemoveClientDialog"
|
||||
actions={[
|
||||
{
|
||||
action: () =>
|
||||
blockClient({ demuxId: removeClientDialogState.demuxId }),
|
||||
style: 'negative',
|
||||
text: i18n(
|
||||
'icu:CallingAdhocCallInfo__RemoveClientDialogButton--block'
|
||||
),
|
||||
},
|
||||
{
|
||||
action: () =>
|
||||
removeClient({ demuxId: removeClientDialogState.demuxId }),
|
||||
style: 'negative',
|
||||
text: i18n(
|
||||
'icu:CallingAdhocCallInfo__RemoveClientDialogButton--remove'
|
||||
),
|
||||
},
|
||||
]}
|
||||
cancelText={i18n('icu:cancel')}
|
||||
i18n={i18n}
|
||||
theme={Theme.Dark}
|
||||
onClose={() => setRemoveClientDialogState(null)}
|
||||
>
|
||||
{i18n('icu:CallingAdhocCallInfo__RemoveClientDialogBody', {
|
||||
name: removeClientDialogState.name,
|
||||
})}
|
||||
</ConfirmationDialog>
|
||||
) : null}
|
||||
{isUnknownContactDialogVisible ? (
|
||||
<Modal
|
||||
modalName="CallingAdhocCallInfo.UnknownContactInfo"
|
||||
moduleClassName="CallingAdhocCallInfo__UnknownContactInfoDialog"
|
||||
i18n={i18n}
|
||||
modalFooter={
|
||||
<Button onClick={hideUnknownContactDialog}>
|
||||
{i18n('icu:CallingAdhocCallInfo__UnknownContactInfoDialogOk')}
|
||||
</Button>
|
||||
}
|
||||
onClose={hideUnknownContactDialog}
|
||||
theme={Theme.Dark}
|
||||
>
|
||||
{i18n('icu:CallingAdhocCallInfo__UnknownContactInfoDialogBody')}
|
||||
</Modal>
|
||||
) : null}
|
||||
<ModalHost
|
||||
modalName="CallingAdhocCallInfo"
|
||||
moduleClassName="CallingAdhocCallInfo"
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="CallingAdhocCallInfo module-calling-participants-list">
|
||||
<div className="module-calling-participants-list__header">
|
||||
<div className="module-calling-participants-list__title">
|
||||
{participants.length
|
||||
? i18n('icu:calling__in-this-call', {
|
||||
people: participants.length,
|
||||
})
|
||||
: i18n('icu:calling__in-this-call--zero')}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="module-calling-participants-list__close"
|
||||
onClick={onClose}
|
||||
tabIndex={0}
|
||||
aria-label={i18n('icu:close')}
|
||||
/>
|
||||
</div>
|
||||
<ul className="module-calling-participants-list__list">
|
||||
{sortedParticipants.map(renderParticipant)}
|
||||
{unknownParticipants.length > 0 && (
|
||||
<UnknownContacts
|
||||
i18n={i18n}
|
||||
isInAdditionToKnownContacts={Boolean(
|
||||
visibleParticipants.length
|
||||
)}
|
||||
participants={unknownParticipants}
|
||||
showUnknownContactDialog={() =>
|
||||
setIsUnknownContactDialogVisible(true)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</ul>
|
||||
<div className="CallingAdhocCallInfo__Divider" />
|
||||
<div className="CallingAdhocCallInfo__CallLinkInfo">
|
||||
<button
|
||||
className="CallingAdhocCallInfo__MenuItem"
|
||||
onClick={onCopyCallLink}
|
||||
type="button"
|
||||
>
|
||||
<span className="CallingAdhocCallInfo__MenuItemIcon CallingAdhocCallInfo__MenuItemIcon--copy-link" />
|
||||
<span className="CallingAdhocCallInfo__MenuItemText">
|
||||
{i18n('icu:CallingAdhocCallInfo__CopyLink')}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
className="CallingAdhocCallInfo__MenuItem"
|
||||
onClick={onClickShareCallLinkViaSignal}
|
||||
type="button"
|
||||
>
|
||||
<span className="CallingAdhocCallInfo__MenuItemIcon CallingAdhocCallInfo__MenuItemIcon--share-via-signal" />
|
||||
<span className="CallingAdhocCallInfo__MenuItemText">
|
||||
{i18n('icu:CallingAdhocCallInfo__ShareViaSignal')}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="module-calling-participants-list__close"
|
||||
onClick={onClose}
|
||||
tabIndex={0}
|
||||
aria-label={i18n('icu:close')}
|
||||
/>
|
||||
</div>
|
||||
<ul className="module-calling-participants-list__list">
|
||||
{sortedParticipants.map(
|
||||
(participant: ParticipantType, index: number) => (
|
||||
<li
|
||||
className="module-calling-participants-list__contact"
|
||||
// It's tempting to use `participant.serviceId` as the `key`
|
||||
// here, but that can result in duplicate keys for
|
||||
// participants who have joined on multiple devices.
|
||||
key={index}
|
||||
>
|
||||
<div className="module-calling-participants-list__avatar-and-name">
|
||||
<Avatar
|
||||
acceptedMessageRequest={participant.acceptedMessageRequest}
|
||||
avatarPath={participant.avatarPath}
|
||||
badge={undefined}
|
||||
color={participant.color}
|
||||
conversationType="direct"
|
||||
i18n={i18n}
|
||||
isMe={participant.isMe}
|
||||
profileName={participant.profileName}
|
||||
title={participant.title}
|
||||
sharedGroupNames={participant.sharedGroupNames}
|
||||
size={AvatarSize.THIRTY_TWO}
|
||||
/>
|
||||
{ourServiceId && participant.serviceId === ourServiceId ? (
|
||||
<span className="module-calling-participants-list__name">
|
||||
{i18n('icu:you')}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<ContactName
|
||||
module="module-calling-participants-list__name"
|
||||
title={participant.title}
|
||||
/>
|
||||
{isInSystemContacts(participant) ? (
|
||||
<span>
|
||||
{' '}
|
||||
<InContactsIcon
|
||||
className="module-calling-participants-list__contact-icon"
|
||||
i18n={i18n}
|
||||
/>
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={classNames(
|
||||
'module-calling-participants-list__status-icon',
|
||||
participant.isHandRaised &&
|
||||
'module-calling-participants-list__hand-raised'
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={classNames(
|
||||
'module-calling-participants-list__status-icon',
|
||||
participant.presenting &&
|
||||
'module-calling-participants-list__presenting',
|
||||
!participant.hasRemoteVideo &&
|
||||
'module-calling-participants-list__muted--video'
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={classNames(
|
||||
'module-calling-participants-list__status-icon',
|
||||
!participant.hasRemoteAudio &&
|
||||
'module-calling-participants-list__muted--audio'
|
||||
)}
|
||||
/>
|
||||
</li>
|
||||
)
|
||||
)}
|
||||
</ul>
|
||||
<div className="CallingAdhocCallInfo__Divider" />
|
||||
<div className="CallingAdhocCallInfo__CallLinkInfo">
|
||||
<button
|
||||
className="CallingAdhocCallInfo__MenuItem"
|
||||
onClick={onCopyCallLink}
|
||||
type="button"
|
||||
>
|
||||
<span className="CallingAdhocCallInfo__MenuItemIcon CallingAdhocCallInfo__MenuItemIcon--copy-link" />
|
||||
<span className="CallingAdhocCallInfo__MenuItemText">
|
||||
{i18n('icu:CallingAdhocCallInfo__CopyLink')}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalHost>
|
||||
</ModalHost>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -111,34 +111,42 @@ export function CallingButton({
|
|||
tooltipContent = i18n('icu:CallingButton--more-options');
|
||||
}
|
||||
|
||||
const buttonContent = (
|
||||
<button
|
||||
aria-label={tooltipContent}
|
||||
className={classNames(
|
||||
'CallingButton__icon',
|
||||
`CallingButton__icon--${classNameSuffix}`
|
||||
)}
|
||||
disabled={disabled}
|
||||
id={uniqueButtonId}
|
||||
onClick={onClick}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
type="button"
|
||||
>
|
||||
<div />
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="CallingButton">
|
||||
<Tooltip
|
||||
className="CallingButton__tooltip"
|
||||
wrapperClassName={classNames(
|
||||
'CallingButton__button-container',
|
||||
!isVisible && 'CallingButton__button-container--hidden'
|
||||
)}
|
||||
content={tooltipContent}
|
||||
direction={tooltipDirection}
|
||||
theme={Theme.Dark}
|
||||
>
|
||||
<button
|
||||
aria-label={tooltipContent}
|
||||
className={classNames(
|
||||
'CallingButton__icon',
|
||||
`CallingButton__icon--${classNameSuffix}`
|
||||
{tooltipContent === '' ? (
|
||||
<div className="CallingButton__button-container">{buttonContent}</div>
|
||||
) : (
|
||||
<Tooltip
|
||||
className="CallingButton__tooltip"
|
||||
wrapperClassName={classNames(
|
||||
'CallingButton__button-container',
|
||||
!isVisible && 'CallingButton__button-container--hidden'
|
||||
)}
|
||||
disabled={disabled}
|
||||
id={uniqueButtonId}
|
||||
onClick={onClick}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
type="button"
|
||||
content={tooltipContent}
|
||||
direction={tooltipDirection}
|
||||
theme={Theme.Dark}
|
||||
>
|
||||
<div />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{buttonContent}
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -20,6 +20,7 @@ import {
|
|||
} from '../test-both/helpers/getDefaultConversation';
|
||||
import { CallingToastProvider } from './CallingToast';
|
||||
import { CallMode } from '../types/Calling';
|
||||
import { getDefaultCallLinkConversation } from '../test-both/helpers/fakeCallLink';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
|
@ -33,15 +34,24 @@ const camera = {
|
|||
},
|
||||
};
|
||||
|
||||
const getConversation = (callMode: CallMode) => {
|
||||
if (callMode === CallMode.Group) {
|
||||
return getDefaultConversation({
|
||||
title: 'Tahoe Trip',
|
||||
type: 'group',
|
||||
});
|
||||
}
|
||||
|
||||
if (callMode === CallMode.Adhoc) {
|
||||
return getDefaultCallLinkConversation();
|
||||
}
|
||||
|
||||
return getDefaultConversation();
|
||||
};
|
||||
|
||||
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => {
|
||||
const callMode = overrideProps.callMode ?? CallMode.Direct;
|
||||
const conversation =
|
||||
callMode === CallMode.Group
|
||||
? getDefaultConversation({
|
||||
title: 'Tahoe Trip',
|
||||
type: 'group',
|
||||
})
|
||||
: getDefaultConversation();
|
||||
const conversation = getConversation(callMode);
|
||||
|
||||
return {
|
||||
availableCameras: overrideProps.availableCameras || [camera],
|
||||
|
@ -55,9 +65,13 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => {
|
|||
hasLocalAudio: overrideProps.hasLocalAudio ?? true,
|
||||
hasLocalVideo: overrideProps.hasLocalVideo ?? false,
|
||||
i18n,
|
||||
isAdhocJoinRequestPending: false,
|
||||
isAdhocAdminApprovalRequired:
|
||||
overrideProps.isAdhocAdminApprovalRequired ?? false,
|
||||
isAdhocJoinRequestPending: overrideProps.isAdhocJoinRequestPending ?? false,
|
||||
isConversationTooBigToRing: false,
|
||||
isCallFull: overrideProps.isCallFull ?? false,
|
||||
getIsSharingPhoneNumberWithEverybody:
|
||||
overrideProps.getIsSharingPhoneNumberWithEverybody ?? (() => false),
|
||||
me:
|
||||
overrideProps.me ||
|
||||
getDefaultConversation({
|
||||
|
@ -75,6 +89,7 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => {
|
|||
setOutgoingRing: action('set-outgoing-ring'),
|
||||
showParticipantsList: overrideProps.showParticipantsList ?? false,
|
||||
toggleParticipants: action('toggle-participants'),
|
||||
togglePip: action('toggle-pip'),
|
||||
toggleSettings: action('toggle-settings'),
|
||||
};
|
||||
};
|
||||
|
@ -114,7 +129,7 @@ export function NoCameraLocalAvatar(): JSX.Element {
|
|||
const props = createProps({
|
||||
availableCameras: [],
|
||||
me: getDefaultConversation({
|
||||
avatarPath: '/fixtures/kitten-4-112-112.jpg',
|
||||
avatarUrl: '/fixtures/kitten-4-112-112.jpg',
|
||||
color: AvatarColors[0],
|
||||
id: generateUuid(),
|
||||
serviceId: generateAci(),
|
||||
|
@ -205,3 +220,29 @@ export function GroupCallWith0PeekedParticipantsBigGroup(): JSX.Element {
|
|||
});
|
||||
return <CallingLobby {...props} />;
|
||||
}
|
||||
|
||||
export function CallLink(): JSX.Element {
|
||||
const props = createProps({
|
||||
callMode: CallMode.Adhoc,
|
||||
});
|
||||
return <CallingLobby {...props} />;
|
||||
}
|
||||
|
||||
// Due to storybook font loading, if you directly load this story then
|
||||
// the button width is not calculated correctly
|
||||
export function CallLinkAdminApproval(): JSX.Element {
|
||||
const props = createProps({
|
||||
callMode: CallMode.Adhoc,
|
||||
isAdhocAdminApprovalRequired: true,
|
||||
});
|
||||
return <CallingLobby {...props} />;
|
||||
}
|
||||
|
||||
export function CallLinkJoinRequestPending(): JSX.Element {
|
||||
const props = createProps({
|
||||
callMode: CallMode.Adhoc,
|
||||
isAdhocAdminApprovalRequired: true,
|
||||
isAdhocJoinRequestPending: true,
|
||||
});
|
||||
return <CallingLobby {...props} />;
|
||||
}
|
||||
|
|
|
@ -28,7 +28,8 @@ import type { ConversationType } from '../state/ducks/conversations';
|
|||
import { useCallingToasts } from './CallingToast';
|
||||
import { CallingButtonToastsContainer } from './CallingToastManager';
|
||||
import { isGroupOrAdhocCallMode } from '../util/isGroupOrAdhocCall';
|
||||
import { isSharingPhoneNumberWithEverybody } from '../util/phoneNumberSharingMode';
|
||||
import { Button, ButtonVariant } from './Button';
|
||||
import { SpinnerV2 } from './SpinnerV2';
|
||||
|
||||
export type PropsType = {
|
||||
availableCameras: Array<MediaDeviceInfo>;
|
||||
|
@ -36,7 +37,7 @@ export type PropsType = {
|
|||
conversation: Pick<
|
||||
CallingConversationType,
|
||||
| 'acceptedMessageRequest'
|
||||
| 'avatarPath'
|
||||
| 'avatarUrl'
|
||||
| 'color'
|
||||
| 'isMe'
|
||||
| 'memberships'
|
||||
|
@ -48,8 +49,9 @@ export type PropsType = {
|
|||
| 'systemNickname'
|
||||
| 'title'
|
||||
| 'type'
|
||||
| 'unblurredAvatarPath'
|
||||
| 'unblurredAvatarUrl'
|
||||
>;
|
||||
getIsSharingPhoneNumberWithEverybody: () => boolean;
|
||||
groupMembers?: Array<
|
||||
Pick<
|
||||
ConversationType,
|
||||
|
@ -59,11 +61,12 @@ export type PropsType = {
|
|||
hasLocalAudio: boolean;
|
||||
hasLocalVideo: boolean;
|
||||
i18n: LocalizerType;
|
||||
isAdhocAdminApprovalRequired: boolean;
|
||||
isAdhocJoinRequestPending: boolean;
|
||||
isConversationTooBigToRing: boolean;
|
||||
isCallFull?: boolean;
|
||||
me: Readonly<
|
||||
Pick<ConversationType, 'avatarPath' | 'color' | 'id' | 'serviceId'>
|
||||
Pick<ConversationType, 'avatarUrl' | 'color' | 'id' | 'serviceId'>
|
||||
>;
|
||||
onCallCanceled: () => void;
|
||||
onJoinCall: () => void;
|
||||
|
@ -75,6 +78,7 @@ export type PropsType = {
|
|||
setOutgoingRing: (_: boolean) => void;
|
||||
showParticipantsList: boolean;
|
||||
toggleParticipants: () => void;
|
||||
togglePip: () => void;
|
||||
toggleSettings: () => void;
|
||||
};
|
||||
|
||||
|
@ -86,9 +90,11 @@ export function CallingLobby({
|
|||
hasLocalAudio,
|
||||
hasLocalVideo,
|
||||
i18n,
|
||||
isAdhocAdminApprovalRequired,
|
||||
isAdhocJoinRequestPending,
|
||||
isCallFull = false,
|
||||
isConversationTooBigToRing,
|
||||
getIsSharingPhoneNumberWithEverybody,
|
||||
me,
|
||||
onCallCanceled,
|
||||
onJoinCall,
|
||||
|
@ -98,6 +104,7 @@ export function CallingLobby({
|
|||
setLocalVideo,
|
||||
setOutgoingRing,
|
||||
toggleParticipants,
|
||||
togglePip,
|
||||
toggleSettings,
|
||||
outgoingRing,
|
||||
}: PropsType): JSX.Element {
|
||||
|
@ -119,6 +126,10 @@ export function CallingLobby({
|
|||
setOutgoingRing(!outgoingRing);
|
||||
}, [outgoingRing, setOutgoingRing]);
|
||||
|
||||
const togglePipForCallingHeader = isAdhocJoinRequestPending
|
||||
? togglePip
|
||||
: undefined;
|
||||
|
||||
React.useEffect(() => {
|
||||
setLocalPreview({ element: localVideoRef });
|
||||
|
||||
|
@ -155,14 +166,16 @@ export function CallingLobby({
|
|||
|
||||
const isOnline = useIsOnline();
|
||||
|
||||
const [isCallConnecting, setIsCallConnecting] = React.useState(false);
|
||||
const [isCallConnecting, setIsCallConnecting] = React.useState(
|
||||
isAdhocJoinRequestPending || false
|
||||
);
|
||||
|
||||
// eslint-disable-next-line no-nested-ternary
|
||||
const videoButtonType = hasLocalVideo
|
||||
? CallingButtonType.VIDEO_ON
|
||||
: availableCameras.length === 0
|
||||
? CallingButtonType.VIDEO_DISABLED
|
||||
: CallingButtonType.VIDEO_OFF;
|
||||
? CallingButtonType.VIDEO_DISABLED
|
||||
: CallingButtonType.VIDEO_OFF;
|
||||
|
||||
const audioButtonType = hasLocalAudio
|
||||
? CallingButtonType.AUDIO_ON
|
||||
|
@ -200,13 +213,18 @@ export function CallingLobby({
|
|||
}
|
||||
|
||||
const canJoin = !isCallFull && !isCallConnecting && isOnline;
|
||||
const canLeave =
|
||||
(isAdhocAdminApprovalRequired && isCallConnecting) ||
|
||||
isAdhocJoinRequestPending;
|
||||
|
||||
let callingLobbyJoinButtonVariant: CallingLobbyJoinButtonVariant;
|
||||
if (isCallFull) {
|
||||
callingLobbyJoinButtonVariant = CallingLobbyJoinButtonVariant.CallIsFull;
|
||||
} else if (isCallConnecting) {
|
||||
callingLobbyJoinButtonVariant = CallingLobbyJoinButtonVariant.Loading;
|
||||
} else if (peekedParticipants.length) {
|
||||
} else if (isAdhocAdminApprovalRequired) {
|
||||
callingLobbyJoinButtonVariant = CallingLobbyJoinButtonVariant.AskToJoin;
|
||||
} else if (peekedParticipants.length || callMode === CallMode.Adhoc) {
|
||||
callingLobbyJoinButtonVariant = CallingLobbyJoinButtonVariant.Join;
|
||||
} else {
|
||||
callingLobbyJoinButtonVariant = CallingLobbyJoinButtonVariant.Start;
|
||||
|
@ -247,7 +265,16 @@ export function CallingLobby({
|
|||
useWasInitiallyMutedToast(hasLocalAudio, i18n);
|
||||
|
||||
return (
|
||||
<FocusTrap>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
allowOutsideClick: ({ target }) => {
|
||||
if (!target || !(target instanceof HTMLElement)) {
|
||||
return false;
|
||||
}
|
||||
return target.matches('.Toast, .Toast *');
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div className="module-calling__container dark-theme">
|
||||
{shouldShowLocalVideo ? (
|
||||
<video
|
||||
|
@ -258,7 +285,7 @@ export function CallingLobby({
|
|||
) : (
|
||||
<CallBackgroundBlur
|
||||
className="module-CallingLobby__local-preview module-CallingLobby__local-preview--camera-is-off"
|
||||
avatarPath={me.avatarPath}
|
||||
avatarUrl={me.avatarUrl}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
@ -266,6 +293,7 @@ export function CallingLobby({
|
|||
i18n={i18n}
|
||||
isGroupCall={isGroupOrAdhocCall}
|
||||
participantCount={peekedParticipants.length}
|
||||
togglePip={togglePipForCallingHeader}
|
||||
toggleSettings={toggleSettings}
|
||||
onCancel={onCallCanceled}
|
||||
/>
|
||||
|
@ -292,13 +320,25 @@ export function CallingLobby({
|
|||
{i18n('icu:calling__your-video-is-off')}
|
||||
</div>
|
||||
|
||||
{callMode === CallMode.Adhoc && (
|
||||
<div className="CallingLobby__CallLinkNotice">
|
||||
{isSharingPhoneNumberWithEverybody()
|
||||
? i18n('icu:CallingLobby__CallLinkNotice--phone-sharing')
|
||||
: i18n('icu:CallingLobby__CallLinkNotice')}
|
||||
</div>
|
||||
)}
|
||||
{/* eslint-disable-next-line no-nested-ternary */}
|
||||
{callMode === CallMode.Adhoc ? (
|
||||
isAdhocJoinRequestPending ? (
|
||||
<div className="CallingLobby__CallLinkNotice CallingLobby__CallLinkNotice--join-request-pending">
|
||||
<SpinnerV2
|
||||
className="CallingLobby__CallLinkJoinRequestPendingSpinner"
|
||||
size={16}
|
||||
strokeWidth={3}
|
||||
/>
|
||||
{i18n('icu:CallingLobby__CallLinkNotice--join-request-pending')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="CallingLobby__CallLinkNotice">
|
||||
{getIsSharingPhoneNumberWithEverybody()
|
||||
? i18n('icu:CallingLobby__CallLinkNotice--phone-sharing')
|
||||
: i18n('icu:CallingLobby__CallLinkNotice')}
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
|
||||
<CallingButtonToastsContainer
|
||||
hasLocalAudio={hasLocalAudio}
|
||||
|
@ -336,15 +376,25 @@ export function CallingLobby({
|
|||
/>
|
||||
</div>
|
||||
<div className="CallControls__JoinLeaveButtonContainer">
|
||||
<CallingLobbyJoinButton
|
||||
disabled={!canJoin}
|
||||
i18n={i18n}
|
||||
onClick={() => {
|
||||
setIsCallConnecting(true);
|
||||
onJoinCall();
|
||||
}}
|
||||
variant={callingLobbyJoinButtonVariant}
|
||||
/>
|
||||
{canLeave ? (
|
||||
<Button
|
||||
className="CallControls__JoinLeaveButton CallControls__JoinLeaveButton--hangup"
|
||||
onClick={onCallCanceled}
|
||||
variant={ButtonVariant.Destructive}
|
||||
>
|
||||
{i18n('icu:CallControls__JoinLeaveButton--hangup-group')}
|
||||
</Button>
|
||||
) : (
|
||||
<CallingLobbyJoinButton
|
||||
disabled={!canJoin}
|
||||
i18n={i18n}
|
||||
onClick={() => {
|
||||
setIsCallConnecting(true);
|
||||
onJoinCall();
|
||||
}}
|
||||
variant={callingLobbyJoinButtonVariant}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="module-calling__spacer CallControls__OuterSpacer" />
|
||||
|
|
|
@ -14,6 +14,7 @@ export enum CallingLobbyJoinButtonVariant {
|
|||
Join = 'Join',
|
||||
Loading = 'Loading',
|
||||
Start = 'Start',
|
||||
AskToJoin = 'AskToJoin',
|
||||
}
|
||||
|
||||
type PropsType = {
|
||||
|
@ -55,6 +56,9 @@ export function CallingLobbyJoinButton({
|
|||
[CallingLobbyJoinButtonVariant.Start]: i18n(
|
||||
'icu:CallingLobbyJoinButton--start'
|
||||
),
|
||||
[CallingLobbyJoinButtonVariant.AskToJoin]: i18n(
|
||||
'icu:CallingLobbyJoinButton--ask-to-join'
|
||||
),
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
@ -31,7 +31,7 @@ function createParticipant(
|
|||
sharingScreen: Boolean(participantProps.sharingScreen),
|
||||
videoAspectRatio: 1.3,
|
||||
...getDefaultConversationWithServiceId({
|
||||
avatarPath: participantProps.avatarPath,
|
||||
avatarUrl: participantProps.avatarUrl,
|
||||
color: sample(AvatarColors),
|
||||
isBlocked: Boolean(participantProps.isBlocked),
|
||||
name: participantProps.name,
|
||||
|
@ -43,9 +43,11 @@ function createParticipant(
|
|||
|
||||
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
||||
i18n,
|
||||
conversationId: 'fake-conversation-id',
|
||||
onClose: action('on-close'),
|
||||
ourServiceId: generateAci(),
|
||||
participants: overrideProps.participants || [],
|
||||
showContactModal: action('show-contact-modal'),
|
||||
});
|
||||
|
||||
export default {
|
||||
|
|
|
@ -26,18 +26,25 @@ type ParticipantType = ConversationType & {
|
|||
};
|
||||
|
||||
export type PropsType = {
|
||||
readonly conversationId: string;
|
||||
readonly i18n: LocalizerType;
|
||||
readonly onClose: () => void;
|
||||
readonly ourServiceId: ServiceIdString | undefined;
|
||||
readonly participants: Array<ParticipantType>;
|
||||
readonly showContactModal: (
|
||||
contactId: string,
|
||||
conversationId?: string
|
||||
) => void;
|
||||
};
|
||||
|
||||
export const CallingParticipantsList = React.memo(
|
||||
function CallingParticipantsListInner({
|
||||
conversationId,
|
||||
i18n,
|
||||
onClose,
|
||||
ourServiceId,
|
||||
participants,
|
||||
showContactModal,
|
||||
}: PropsType) {
|
||||
const [root, setRoot] = React.useState<HTMLElement | null>(null);
|
||||
|
||||
|
@ -96,22 +103,33 @@ export const CallingParticipantsList = React.memo(
|
|||
aria-label={i18n('icu:close')}
|
||||
/>
|
||||
</div>
|
||||
<ul className="module-calling-participants-list__list">
|
||||
<div className="module-calling-participants-list__list">
|
||||
{sortedParticipants.map(
|
||||
(participant: ParticipantType, index: number) => (
|
||||
<li
|
||||
<button
|
||||
aria-label={i18n('icu:calling__ParticipantInfoButton')}
|
||||
className="module-calling-participants-list__contact"
|
||||
disabled={participant.isMe}
|
||||
// It's tempting to use `participant.serviceId` as the `key`
|
||||
// here, but that can result in duplicate keys for
|
||||
// participants who have joined on multiple devices.
|
||||
key={index}
|
||||
onClick={() => {
|
||||
if (participant.isMe) {
|
||||
return;
|
||||
}
|
||||
|
||||
onClose();
|
||||
showContactModal(participant.id, conversationId);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<div className="module-calling-participants-list__avatar-and-name">
|
||||
<Avatar
|
||||
acceptedMessageRequest={
|
||||
participant.acceptedMessageRequest
|
||||
}
|
||||
avatarPath={participant.avatarPath}
|
||||
avatarUrl={participant.avatarUrl}
|
||||
badge={undefined}
|
||||
color={participant.color}
|
||||
conversationType="direct"
|
||||
|
@ -134,13 +152,10 @@ export const CallingParticipantsList = React.memo(
|
|||
title={participant.title}
|
||||
/>
|
||||
{isInSystemContacts(participant) ? (
|
||||
<span>
|
||||
{' '}
|
||||
<InContactsIcon
|
||||
className="module-calling-participants-list__contact-icon"
|
||||
i18n={i18n}
|
||||
/>
|
||||
</span>
|
||||
<InContactsIcon
|
||||
className="module-calling-participants-list__contact-icon"
|
||||
i18n={i18n}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
@ -168,10 +183,10 @@ export const CallingParticipantsList = React.memo(
|
|||
'module-calling-participants-list__muted--audio'
|
||||
)}
|
||||
/>
|
||||
</li>
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FocusTrap>,
|
||||
|
|
91
ts/components/CallingPendingParticipants.stories.tsx
Normal file
91
ts/components/CallingPendingParticipants.stories.tsx
Normal file
|
@ -0,0 +1,91 @@
|
|||
// Copyright 2024 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import * as React from 'react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import type { Meta } from '@storybook/react';
|
||||
import type { PropsType } from './CallingPendingParticipants';
|
||||
import { CallingPendingParticipants } from './CallingPendingParticipants';
|
||||
import { setupI18n } from '../util/setupI18n';
|
||||
import enMessages from '../../_locales/en/messages.json';
|
||||
import { allRemoteParticipants } from './CallScreen.stories';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
const createProps = (storyProps: Partial<PropsType> = {}): PropsType => ({
|
||||
i18n,
|
||||
participants: [allRemoteParticipants[0], allRemoteParticipants[1]],
|
||||
approveUser: action('approve-user'),
|
||||
batchUserAction: action('batch-user-action'),
|
||||
denyUser: action('deny-user'),
|
||||
...storyProps,
|
||||
});
|
||||
|
||||
export default {
|
||||
title: 'Components/CallingPendingParticipants',
|
||||
argTypes: {},
|
||||
args: {},
|
||||
} satisfies Meta<PropsType>;
|
||||
|
||||
export function One(): JSX.Element {
|
||||
return (
|
||||
<CallingPendingParticipants
|
||||
{...createProps({
|
||||
participants: [allRemoteParticipants[0]],
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function Two(): JSX.Element {
|
||||
return (
|
||||
<CallingPendingParticipants
|
||||
{...createProps({
|
||||
participants: allRemoteParticipants.slice(0, 2),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function Many(): JSX.Element {
|
||||
return (
|
||||
<CallingPendingParticipants
|
||||
{...createProps({
|
||||
participants: allRemoteParticipants.slice(0, 10),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExpandedOne(): JSX.Element {
|
||||
return (
|
||||
<CallingPendingParticipants
|
||||
{...createProps({
|
||||
defaultIsExpanded: true,
|
||||
participants: [allRemoteParticipants[0]],
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExpandedTwo(): JSX.Element {
|
||||
return (
|
||||
<CallingPendingParticipants
|
||||
{...createProps({
|
||||
defaultIsExpanded: true,
|
||||
participants: allRemoteParticipants.slice(0, 2),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExpandedMany(): JSX.Element {
|
||||
return (
|
||||
<CallingPendingParticipants
|
||||
{...createProps({
|
||||
defaultIsExpanded: true,
|
||||
participants: allRemoteParticipants.slice(0, 10),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
352
ts/components/CallingPendingParticipants.tsx
Normal file
352
ts/components/CallingPendingParticipants.tsx
Normal file
|
@ -0,0 +1,352 @@
|
|||
// Copyright 2024 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
/* eslint-disable react/no-array-index-key */
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { noop } from 'lodash';
|
||||
import { Avatar, AvatarSize } from './Avatar';
|
||||
import { ContactName } from './conversation/ContactName';
|
||||
import { InContactsIcon } from './InContactsIcon';
|
||||
import type { LocalizerType } from '../types/Util';
|
||||
import type { ConversationType } from '../state/ducks/conversations';
|
||||
import { isInSystemContacts } from '../util/isInSystemContacts';
|
||||
import type {
|
||||
BatchUserActionPayloadType,
|
||||
PendingUserActionPayloadType,
|
||||
} from '../state/ducks/calling';
|
||||
import { Button, ButtonVariant } from './Button';
|
||||
import type { ServiceIdString } from '../types/ServiceId';
|
||||
import { handleOutsideClick } from '../util/handleOutsideClick';
|
||||
import { Theme } from '../util/theme';
|
||||
import { ConfirmationDialog } from './ConfirmationDialog';
|
||||
|
||||
enum ConfirmDialogState {
|
||||
None = 'None',
|
||||
ApproveAll = 'ApproveAll',
|
||||
DenyAll = 'DenyAll',
|
||||
}
|
||||
|
||||
export type PropsType = {
|
||||
readonly i18n: LocalizerType;
|
||||
readonly participants: Array<ConversationType>;
|
||||
// For storybook
|
||||
readonly defaultIsExpanded?: boolean;
|
||||
readonly approveUser: (payload: PendingUserActionPayloadType) => void;
|
||||
readonly batchUserAction: (payload: BatchUserActionPayloadType) => void;
|
||||
readonly denyUser: (payload: PendingUserActionPayloadType) => void;
|
||||
};
|
||||
|
||||
export function CallingPendingParticipants({
|
||||
defaultIsExpanded,
|
||||
i18n,
|
||||
participants,
|
||||
approveUser,
|
||||
batchUserAction,
|
||||
denyUser,
|
||||
}: PropsType): JSX.Element | null {
|
||||
const [isExpanded, setIsExpanded] = useState(defaultIsExpanded ?? false);
|
||||
const [confirmDialogState, setConfirmDialogState] =
|
||||
React.useState<ConfirmDialogState>(ConfirmDialogState.None);
|
||||
const [serviceIdsStagedForAction, setServiceIdsStagedForAction] =
|
||||
React.useState<Array<ServiceIdString>>([]);
|
||||
|
||||
const expandedListRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleHideAllRequests = useCallback(() => {
|
||||
setIsExpanded(false);
|
||||
}, [setIsExpanded]);
|
||||
|
||||
// When opening the "Approve all" confirm dialog, save the current list of participants
|
||||
// to ensure we only approve users who the admin has checked. If additional people
|
||||
// request to join while the dialog is open, we don't auto approve those.
|
||||
const stageServiceIdsForAction = useCallback(() => {
|
||||
const serviceIds: Array<ServiceIdString> = [];
|
||||
participants.forEach(participant => {
|
||||
if (participant.serviceId) {
|
||||
serviceIds.push(participant.serviceId);
|
||||
}
|
||||
});
|
||||
setServiceIdsStagedForAction(serviceIds);
|
||||
}, [participants, setServiceIdsStagedForAction]);
|
||||
|
||||
const hideConfirmDialog = useCallback(() => {
|
||||
setConfirmDialogState(ConfirmDialogState.None);
|
||||
setServiceIdsStagedForAction([]);
|
||||
}, [setConfirmDialogState]);
|
||||
|
||||
const handleApprove = useCallback(
|
||||
(participant: ConversationType) => {
|
||||
const { serviceId } = participant;
|
||||
if (!serviceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
approveUser({ serviceId });
|
||||
},
|
||||
[approveUser]
|
||||
);
|
||||
|
||||
const handleDeny = useCallback(
|
||||
(participant: ConversationType) => {
|
||||
const { serviceId } = participant;
|
||||
if (!serviceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
denyUser({ serviceId });
|
||||
},
|
||||
[denyUser]
|
||||
);
|
||||
|
||||
const handleApproveAll = useCallback(() => {
|
||||
batchUserAction({
|
||||
action: 'approve',
|
||||
serviceIds: serviceIdsStagedForAction,
|
||||
});
|
||||
hideConfirmDialog();
|
||||
}, [serviceIdsStagedForAction, batchUserAction, hideConfirmDialog]);
|
||||
|
||||
const handleDenyAll = useCallback(() => {
|
||||
batchUserAction({
|
||||
action: 'deny',
|
||||
serviceIds: serviceIdsStagedForAction,
|
||||
});
|
||||
hideConfirmDialog();
|
||||
}, [serviceIdsStagedForAction, batchUserAction, hideConfirmDialog]);
|
||||
|
||||
const renderApprovalButtons = useCallback(
|
||||
(participant: ConversationType) => {
|
||||
if (participant.serviceId == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
aria-label={i18n('icu:CallingPendingParticipants__DenyUser')}
|
||||
className="CallingPendingParticipants__PendingActionButton CallingButton__icon"
|
||||
onClick={() => handleDeny(participant)}
|
||||
variant={ButtonVariant.Destructive}
|
||||
>
|
||||
<span className="CallingPendingParticipants__PendingActionButtonIcon CallingPendingParticipants__PendingActionButtonIcon--Deny" />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label={i18n('icu:CallingPendingParticipants__ApproveUser')}
|
||||
className="CallingPendingParticipants__PendingActionButton CallingButton__icon"
|
||||
onClick={() => handleApprove(participant)}
|
||||
variant={ButtonVariant.Calling}
|
||||
>
|
||||
<span className="CallingPendingParticipants__PendingActionButtonIcon CallingPendingParticipants__PendingActionButtonIcon--Approve" />
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
},
|
||||
[i18n, handleApprove, handleDeny]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isExpanded) {
|
||||
return noop;
|
||||
}
|
||||
return handleOutsideClick(
|
||||
() => {
|
||||
handleHideAllRequests();
|
||||
return true;
|
||||
},
|
||||
{
|
||||
containerElements: [expandedListRef],
|
||||
name: 'CallingPendingParticipantsList.expandedList',
|
||||
}
|
||||
);
|
||||
}, [isExpanded, handleHideAllRequests]);
|
||||
|
||||
if (confirmDialogState === ConfirmDialogState.ApproveAll) {
|
||||
return (
|
||||
<ConfirmationDialog
|
||||
dialogName="CallingPendingParticipants.confirmDialog"
|
||||
actions={[
|
||||
{
|
||||
action: handleApproveAll,
|
||||
style: 'affirmative',
|
||||
text: i18n('icu:CallingPendingParticipants__ApproveAll'),
|
||||
},
|
||||
]}
|
||||
cancelText={i18n('icu:cancel')}
|
||||
i18n={i18n}
|
||||
theme={Theme.Dark}
|
||||
title={i18n(
|
||||
'icu:CallingPendingParticipants__ConfirmDialogTitle--ApproveAll',
|
||||
{ count: serviceIdsStagedForAction.length }
|
||||
)}
|
||||
onClose={hideConfirmDialog}
|
||||
>
|
||||
{i18n('icu:CallingPendingParticipants__ConfirmDialogBody--ApproveAll', {
|
||||
count: serviceIdsStagedForAction.length,
|
||||
})}
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
}
|
||||
|
||||
if (confirmDialogState === ConfirmDialogState.DenyAll) {
|
||||
return (
|
||||
<ConfirmationDialog
|
||||
dialogName="CallingPendingParticipants.confirmDialog"
|
||||
actions={[
|
||||
{
|
||||
action: handleDenyAll,
|
||||
style: 'affirmative',
|
||||
text: i18n('icu:CallingPendingParticipants__DenyAll'),
|
||||
},
|
||||
]}
|
||||
cancelText={i18n('icu:cancel')}
|
||||
i18n={i18n}
|
||||
theme={Theme.Dark}
|
||||
title={i18n(
|
||||
'icu:CallingPendingParticipants__ConfirmDialogTitle--DenyAll',
|
||||
{ count: serviceIdsStagedForAction.length }
|
||||
)}
|
||||
onClose={hideConfirmDialog}
|
||||
>
|
||||
{i18n('icu:CallingPendingParticipants__ConfirmDialogBody--DenyAll', {
|
||||
count: serviceIdsStagedForAction.length,
|
||||
})}
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
}
|
||||
|
||||
if (isExpanded) {
|
||||
return (
|
||||
<div
|
||||
className="CallingPendingParticipants CallingPendingParticipants--Expanded module-calling-participants-list"
|
||||
ref={expandedListRef}
|
||||
>
|
||||
<div className="module-calling-participants-list__header">
|
||||
<div className="module-calling-participants-list__title">
|
||||
{i18n('icu:CallingPendingParticipants__RequestsToJoin', {
|
||||
count: participants.length,
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="module-calling-participants-list__close"
|
||||
onClick={handleHideAllRequests}
|
||||
tabIndex={0}
|
||||
aria-label={i18n('icu:close')}
|
||||
/>
|
||||
</div>
|
||||
<ul className="module-calling-participants-list__list">
|
||||
{participants.map((participant: ConversationType, index: number) => (
|
||||
<li
|
||||
className="module-calling-participants-list__contact"
|
||||
key={index}
|
||||
>
|
||||
<div className="module-calling-participants-list__avatar-and-name">
|
||||
<Avatar
|
||||
acceptedMessageRequest={participant.acceptedMessageRequest}
|
||||
avatarUrl={participant.avatarUrl}
|
||||
badge={undefined}
|
||||
color={participant.color}
|
||||
conversationType="direct"
|
||||
i18n={i18n}
|
||||
isMe={participant.isMe}
|
||||
profileName={participant.profileName}
|
||||
title={participant.title}
|
||||
sharedGroupNames={participant.sharedGroupNames}
|
||||
size={AvatarSize.THIRTY_SIX}
|
||||
/>
|
||||
<ContactName
|
||||
module="module-calling-participants-list__name"
|
||||
title={participant.title}
|
||||
/>
|
||||
{isInSystemContacts(participant) ? (
|
||||
<span>
|
||||
{' '}
|
||||
<InContactsIcon
|
||||
className="module-calling-participants-list__contact-icon"
|
||||
i18n={i18n}
|
||||
/>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{renderApprovalButtons(participant)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="CallingPendingParticipants__ActionPanel">
|
||||
<Button
|
||||
className="CallingPendingParticipants__ActionPanelButton CallingPendingParticipants__ActionPanelButton--DenyAll"
|
||||
variant={ButtonVariant.Destructive}
|
||||
onClick={() => {
|
||||
stageServiceIdsForAction();
|
||||
setConfirmDialogState(ConfirmDialogState.DenyAll);
|
||||
}}
|
||||
>
|
||||
{i18n('icu:CallingPendingParticipants__DenyAll')}
|
||||
</Button>
|
||||
<Button
|
||||
className="CallingPendingParticipants__ActionPanelButton CallingPendingParticipants__ActionPanelButton--ApproveAll"
|
||||
variant={ButtonVariant.Calling}
|
||||
onClick={() => {
|
||||
stageServiceIdsForAction();
|
||||
setConfirmDialogState(ConfirmDialogState.ApproveAll);
|
||||
}}
|
||||
>
|
||||
{i18n('icu:CallingPendingParticipants__ApproveAll')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const participant = participants[0];
|
||||
return (
|
||||
<div className="CallingPendingParticipants CallingPendingParticipants--Compact module-calling-participants-list">
|
||||
<div className="CallingPendingParticipants__CompactParticipant">
|
||||
<div className="module-calling-participants-list__avatar-and-name">
|
||||
<Avatar
|
||||
acceptedMessageRequest={participant.acceptedMessageRequest}
|
||||
avatarUrl={participant.avatarUrl}
|
||||
badge={undefined}
|
||||
color={participant.color}
|
||||
conversationType="direct"
|
||||
i18n={i18n}
|
||||
isMe={participant.isMe}
|
||||
profileName={participant.profileName}
|
||||
title={participant.title}
|
||||
sharedGroupNames={participant.sharedGroupNames}
|
||||
size={AvatarSize.FORTY_EIGHT}
|
||||
/>
|
||||
<div className="CallingPendingParticipants__CompactParticipantNameColumn">
|
||||
<div className="CallingPendingParticipants__ParticipantName">
|
||||
<ContactName title={participant.title} />
|
||||
{isInSystemContacts(participant) ? (
|
||||
<InContactsIcon
|
||||
className="module-calling-participants-list__contact-icon"
|
||||
i18n={i18n}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="CallingPendingParticipants__WouldLikeToJoin">
|
||||
{i18n('icu:CallingPendingParticipants__WouldLikeToJoin')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{renderApprovalButtons(participant)}
|
||||
</div>
|
||||
{participants.length > 1 && (
|
||||
<div className="CallingPendingParticipants__ShowAllRequestsButtonContainer">
|
||||
<button
|
||||
className="CallingPendingParticipants__ShowAllRequestsButton"
|
||||
onClick={() => setIsExpanded(true)}
|
||||
type="button"
|
||||
>
|
||||
{i18n('icu:CallingPendingParticipants__AdditionalRequests', {
|
||||
count: participants.length - 1,
|
||||
})}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -26,7 +26,7 @@ const i18n = setupI18n('en', enMessages);
|
|||
|
||||
const conversation: ConversationType = getDefaultConversation({
|
||||
id: '3051234567',
|
||||
avatarPath: undefined,
|
||||
avatarUrl: undefined,
|
||||
color: AvatarColors[0],
|
||||
title: 'Rick Sanchez',
|
||||
name: 'Rick Sanchez',
|
||||
|
@ -98,7 +98,7 @@ export function ContactWithAvatarAndNoVideo(args: PropsType): JSX.Element {
|
|||
...getDefaultCall({}),
|
||||
conversation: {
|
||||
...conversation,
|
||||
avatarPath: 'https://www.fillmurray.com/64/64',
|
||||
avatarUrl: 'https://www.fillmurray.com/64/64',
|
||||
},
|
||||
remoteParticipants: [
|
||||
{ hasRemoteVideo: false, presenting: false, title: 'Julian' },
|
||||
|
@ -131,7 +131,6 @@ export function GroupCall(args: PropsType): JSX.Element {
|
|||
...getCommonActiveCallData({}),
|
||||
callMode: CallMode.Group as CallMode.Group,
|
||||
connectionState: GroupCallConnectionState.Connected,
|
||||
conversationsWithSafetyNumberChanges: [],
|
||||
conversationsByDemuxId: new Map<number, ConversationType>(),
|
||||
groupMembers: times(3, () => getDefaultConversation()),
|
||||
isConversationTooBigToRing: false,
|
||||
|
@ -140,6 +139,7 @@ export function GroupCall(args: PropsType): JSX.Element {
|
|||
maxDevices: 5,
|
||||
deviceCount: 0,
|
||||
peekedParticipants: [],
|
||||
pendingParticipants: [],
|
||||
raisedHands: new Set<number>(),
|
||||
remoteParticipants: [],
|
||||
remoteAudioLevels: new Map<number, number>(),
|
||||
|
|
|
@ -13,6 +13,7 @@ import type {
|
|||
} from '../state/ducks/calling';
|
||||
import { missingCaseError } from '../util/missingCaseError';
|
||||
import { useActivateSpeakerViewOnPresenting } from '../hooks/useActivateSpeakerViewOnPresenting';
|
||||
import type { CallingImageDataCache } from './CallManager';
|
||||
|
||||
enum PositionMode {
|
||||
BeingDragged,
|
||||
|
@ -54,6 +55,7 @@ export type PropsType = {
|
|||
hangUpActiveCall: (reason: string) => void;
|
||||
hasLocalVideo: boolean;
|
||||
i18n: LocalizerType;
|
||||
imageDataCache: React.RefObject<CallingImageDataCache>;
|
||||
setGroupCallVideoRequest: (
|
||||
_: Array<GroupCallVideoRequest>,
|
||||
speakerHeight: number
|
||||
|
@ -75,6 +77,7 @@ export function CallingPip({
|
|||
getGroupCallVideoFrameSource,
|
||||
hangUpActiveCall,
|
||||
hasLocalVideo,
|
||||
imageDataCache,
|
||||
i18n,
|
||||
setGroupCallVideoRequest,
|
||||
setLocalPreview,
|
||||
|
@ -304,6 +307,7 @@ export function CallingPip({
|
|||
<CallingPipRemoteVideo
|
||||
activeCall={activeCall}
|
||||
getGroupCallVideoFrameSource={getGroupCallVideoFrameSource}
|
||||
imageDataCache={imageDataCache}
|
||||
i18n={i18n}
|
||||
setRendererCanvas={setRendererCanvas}
|
||||
setGroupCallVideoRequest={setGroupCallVideoRequest}
|
||||
|
|
|
@ -14,7 +14,7 @@ import type {
|
|||
GroupCallRemoteParticipantType,
|
||||
GroupCallVideoRequest,
|
||||
} from '../types/Calling';
|
||||
import { CallMode } from '../types/Calling';
|
||||
import { CallMode, GroupCallJoinState } from '../types/Calling';
|
||||
import { AvatarColors } from '../types/Colors';
|
||||
import type { SetRendererCanvasType } from '../state/ducks/calling';
|
||||
import { useGetCallingFrameBuffer } from '../calling/useGetCallingFrameBuffer';
|
||||
|
@ -25,6 +25,7 @@ import { nonRenderedRemoteParticipant } from '../util/ringrtc/nonRenderedRemoteP
|
|||
import { isReconnecting } from '../util/callingIsReconnecting';
|
||||
import { isGroupOrAdhocActiveCall } from '../util/isGroupOrAdhocCall';
|
||||
import { assertDev } from '../util/assert';
|
||||
import type { CallingImageDataCache } from './CallManager';
|
||||
|
||||
// This value should be kept in sync with the hard-coded CSS height. It should also be
|
||||
// less than `MAX_FRAME_HEIGHT`.
|
||||
|
@ -39,8 +40,9 @@ function NoVideo({
|
|||
}): JSX.Element {
|
||||
const {
|
||||
acceptedMessageRequest,
|
||||
avatarPath,
|
||||
avatarUrl,
|
||||
color,
|
||||
type: conversationType,
|
||||
isMe,
|
||||
phoneNumber,
|
||||
profileName,
|
||||
|
@ -50,15 +52,15 @@ function NoVideo({
|
|||
|
||||
return (
|
||||
<div className="module-calling-pip__video--remote">
|
||||
<CallBackgroundBlur avatarPath={avatarPath}>
|
||||
<CallBackgroundBlur avatarUrl={avatarUrl}>
|
||||
<div className="module-calling-pip__video--avatar">
|
||||
<Avatar
|
||||
acceptedMessageRequest={acceptedMessageRequest}
|
||||
avatarPath={avatarPath}
|
||||
avatarUrl={avatarUrl}
|
||||
badge={undefined}
|
||||
color={color || AvatarColors[0]}
|
||||
noteToSelf={false}
|
||||
conversationType="direct"
|
||||
conversationType={conversationType}
|
||||
i18n={i18n}
|
||||
isMe={isMe}
|
||||
phoneNumber={phoneNumber}
|
||||
|
@ -77,6 +79,7 @@ export type PropsType = {
|
|||
activeCall: ActiveCallType;
|
||||
getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource;
|
||||
i18n: LocalizerType;
|
||||
imageDataCache: React.RefObject<CallingImageDataCache>;
|
||||
setGroupCallVideoRequest: (
|
||||
_: Array<GroupCallVideoRequest>,
|
||||
speakerHeight: number
|
||||
|
@ -87,6 +90,7 @@ export type PropsType = {
|
|||
export function CallingPipRemoteVideo({
|
||||
activeCall,
|
||||
getGroupCallVideoFrameSource,
|
||||
imageDataCache,
|
||||
i18n,
|
||||
setGroupCallVideoRequest,
|
||||
setRendererCanvas,
|
||||
|
@ -103,6 +107,10 @@ export function CallingPipRemoteVideo({
|
|||
return undefined;
|
||||
}
|
||||
|
||||
if (activeCall.joinState !== GroupCallJoinState.Joined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return maxBy(activeCall.remoteParticipants, participant =>
|
||||
participant.presenting ? Infinity : participant.speakerTime || -Infinity
|
||||
);
|
||||
|
@ -176,6 +184,7 @@ export function CallingPipRemoteVideo({
|
|||
<GroupCallRemoteParticipant
|
||||
getFrameBuffer={getGroupCallFrameBuffer}
|
||||
getGroupCallVideoFrameSource={getGroupCallVideoFrameSource}
|
||||
imageDataCache={imageDataCache}
|
||||
i18n={i18n}
|
||||
isInPip
|
||||
remoteParticipant={activeGroupCallSpeaker}
|
||||
|
|
|
@ -20,7 +20,7 @@ export type PropsType = {
|
|||
conversation: Pick<
|
||||
CallingConversationType,
|
||||
| 'acceptedMessageRequest'
|
||||
| 'avatarPath'
|
||||
| 'avatarUrl'
|
||||
| 'color'
|
||||
| 'isMe'
|
||||
| 'phoneNumber'
|
||||
|
@ -30,7 +30,7 @@ export type PropsType = {
|
|||
| 'systemNickname'
|
||||
| 'title'
|
||||
| 'type'
|
||||
| 'unblurredAvatarPath'
|
||||
| 'unblurredAvatarUrl'
|
||||
>;
|
||||
i18n: LocalizerType;
|
||||
me: Pick<ConversationType, 'id' | 'serviceId'>;
|
||||
|
@ -186,7 +186,7 @@ export function CallingPreCallInfo({
|
|||
return (
|
||||
<div className="module-CallingPreCallInfo">
|
||||
<Avatar
|
||||
avatarPath={conversation.avatarPath}
|
||||
avatarUrl={conversation.avatarUrl}
|
||||
badge={undefined}
|
||||
color={conversation.color}
|
||||
acceptedMessageRequest={conversation.acceptedMessageRequest}
|
||||
|
@ -198,7 +198,7 @@ export function CallingPreCallInfo({
|
|||
sharedGroupNames={conversation.sharedGroupNames}
|
||||
size={AvatarSize.NINETY_SIX}
|
||||
title={conversation.title}
|
||||
unblurredAvatarPath={conversation.unblurredAvatarPath}
|
||||
unblurredAvatarUrl={conversation.unblurredAvatarUrl}
|
||||
i18n={i18n}
|
||||
/>
|
||||
<div className="module-CallingPreCallInfo__title">
|
||||
|
|
|
@ -35,7 +35,7 @@ const i18n = setupI18n('en', enMessages);
|
|||
|
||||
const conversation = getDefaultConversationWithServiceId({
|
||||
id: '3051234567',
|
||||
avatarPath: undefined,
|
||||
avatarUrl: undefined,
|
||||
color: AvatarColors[0],
|
||||
title: 'Rick Sanchez',
|
||||
name: 'Rick Sanchez',
|
||||
|
|
|
@ -77,6 +77,12 @@ export function CallingRaisedHandsList({
|
|||
{i18n('icu:CallingRaisedHandsList__Title', {
|
||||
count: participants.length,
|
||||
})}
|
||||
{participants.length > 1 ? (
|
||||
<span className="CallingRaisedHandsList__TitleHint">
|
||||
{' '}
|
||||
{i18n('icu:CallingRaisedHandsList__TitleHint')}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
@ -95,7 +101,7 @@ export function CallingRaisedHandsList({
|
|||
<div className="CallingRaisedHandsList__AvatarAndName module-calling-participants-list__avatar-and-name">
|
||||
<Avatar
|
||||
acceptedMessageRequest={participant.acceptedMessageRequest}
|
||||
avatarPath={participant.avatarPath}
|
||||
avatarUrl={participant.avatarUrl}
|
||||
badge={undefined}
|
||||
color={participant.color}
|
||||
conversationType="direct"
|
||||
|
|
|
@ -9,6 +9,7 @@ import type { PropsType } from './CallingScreenSharingController';
|
|||
import { CallingScreenSharingController } from './CallingScreenSharingController';
|
||||
|
||||
import { setupI18n } from '../util/setupI18n';
|
||||
import { ScreenShareStatus } from '../types/Calling';
|
||||
import enMessages from '../../_locales/en/messages.json';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
@ -18,6 +19,7 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
|||
onCloseController: action('on-close-controller'),
|
||||
onStopSharing: action('on-stop-sharing'),
|
||||
presentedSourceName: overrideProps.presentedSourceName || 'Application',
|
||||
status: overrideProps.status || ScreenShareStatus.Connected,
|
||||
});
|
||||
|
||||
export default {
|
||||
|
@ -38,3 +40,13 @@ export function ReallyLongAppName(): JSX.Element {
|
|||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function Reconnecting(): JSX.Element {
|
||||
return (
|
||||
<CallingScreenSharingController
|
||||
{...createProps({
|
||||
status: ScreenShareStatus.Reconnecting,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -4,11 +4,13 @@
|
|||
import React from 'react';
|
||||
import { Button, ButtonVariant } from './Button';
|
||||
import type { LocalizerType } from '../types/Util';
|
||||
import { ScreenShareStatus } from '../types/Calling';
|
||||
|
||||
export type PropsType = {
|
||||
i18n: LocalizerType;
|
||||
onCloseController: () => unknown;
|
||||
onStopSharing: () => unknown;
|
||||
status: ScreenShareStatus;
|
||||
presentedSourceName: string;
|
||||
};
|
||||
|
||||
|
@ -16,15 +18,22 @@ export function CallingScreenSharingController({
|
|||
i18n,
|
||||
onCloseController,
|
||||
onStopSharing,
|
||||
status,
|
||||
presentedSourceName,
|
||||
}: PropsType): JSX.Element {
|
||||
let text: string;
|
||||
|
||||
if (status === ScreenShareStatus.Reconnecting) {
|
||||
text = i18n('icu:calling__presenting--reconnecting');
|
||||
} else {
|
||||
text = i18n('icu:calling__presenting--info', {
|
||||
window: presentedSourceName,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="module-CallingScreenSharingController">
|
||||
<div className="module-CallingScreenSharingController__text">
|
||||
{i18n('icu:calling__presenting--info', {
|
||||
window: presentedSourceName,
|
||||
})}
|
||||
</div>
|
||||
<div className="module-CallingScreenSharingController__text">{text}</div>
|
||||
<div className="module-CallingScreenSharingController__buttons">
|
||||
<Button
|
||||
className="module-CallingScreenSharingController__button"
|
||||
|
|
|
@ -29,20 +29,43 @@ import {
|
|||
GroupCallStatus,
|
||||
isSameCallHistoryGroup,
|
||||
} from '../types/CallDisposition';
|
||||
import { formatDateTimeShort } from '../util/timestamp';
|
||||
import { formatDateTimeShort, isMoreRecentThan } from '../util/timestamp';
|
||||
import type { ConversationType } from '../state/ducks/conversations';
|
||||
import * as log from '../logging/log';
|
||||
import { refMerger } from '../util/refMerger';
|
||||
import { drop } from '../util/drop';
|
||||
import { strictAssert } from '../util/assert';
|
||||
import { UserText } from './UserText';
|
||||
import { Intl } from './Intl';
|
||||
import { I18n } from './I18n';
|
||||
import { NavSidebarSearchHeader } from './NavSidebar';
|
||||
import { SizeObserver } from '../hooks/useSizeObserver';
|
||||
import { formatCallHistoryGroup } from '../util/callDisposition';
|
||||
import {
|
||||
formatCallHistoryGroup,
|
||||
getCallIdFromEra,
|
||||
} from '../util/callDisposition';
|
||||
import { CallsNewCallButton } from './CallsNewCall';
|
||||
import { Tooltip, TooltipPlacement } from './Tooltip';
|
||||
import { Theme } from '../util/theme';
|
||||
import type { CallingConversationType } from '../types/Calling';
|
||||
import { CallMode } from '../types/Calling';
|
||||
import type { CallLinkType } from '../types/CallLink';
|
||||
import {
|
||||
callLinkToConversation,
|
||||
getPlaceholderCallLinkConversation,
|
||||
} from '../util/callLinks';
|
||||
import type { CallsTabSelectedView } from './CallsTab';
|
||||
import type { CallStateType } from '../state/selectors/calling';
|
||||
import {
|
||||
isGroupOrAdhocCallMode,
|
||||
isGroupOrAdhocCallState,
|
||||
} from '../util/isGroupOrAdhocCall';
|
||||
import { isAnybodyInGroupCall } from '../state/ducks/callingHelpers';
|
||||
import type {
|
||||
ActiveCallStateType,
|
||||
PeekNotConnectedGroupCallType,
|
||||
} from '../state/ducks/calling';
|
||||
import { DAY, MINUTE, SECOND } from '../util/durations';
|
||||
import type { StartCallData } from './ConfirmLeaveCallModal';
|
||||
|
||||
function Timestamp({
|
||||
i18n,
|
||||
|
@ -103,7 +126,8 @@ const defaultPendingState: SearchState = {
|
|||
};
|
||||
|
||||
type CallsListProps = Readonly<{
|
||||
hasActiveCall: boolean;
|
||||
activeCall: ActiveCallStateType | undefined;
|
||||
canCreateCallLinks: boolean;
|
||||
getCallHistoryGroupsCount: (
|
||||
options: CallHistoryFilterOptions
|
||||
) => Promise<number>;
|
||||
|
@ -112,22 +136,29 @@ type CallsListProps = Readonly<{
|
|||
pagination: CallHistoryPagination
|
||||
) => Promise<Array<CallHistoryGroup>>;
|
||||
callHistoryEdition: number;
|
||||
getAdhocCall: (roomId: string) => CallStateType | undefined;
|
||||
getCall: (id: string) => CallStateType | undefined;
|
||||
getCallLink: (id: string) => CallLinkType | undefined;
|
||||
getConversation: (id: string) => ConversationType | void;
|
||||
hangUpActiveCall: (reason: string) => void;
|
||||
i18n: LocalizerType;
|
||||
selectedCallHistoryGroup: CallHistoryGroup | null;
|
||||
onCreateCallLink: () => void;
|
||||
onOutgoingAudioCallInConversation: (conversationId: string) => void;
|
||||
onOutgoingVideoCallInConversation: (conversationId: string) => void;
|
||||
onSelectCallHistoryGroup: (
|
||||
conversationId: string,
|
||||
selectedCallHistoryGroup: CallHistoryGroup
|
||||
) => void;
|
||||
onChangeCallsTabSelectedView: (selectedView: CallsTabSelectedView) => void;
|
||||
peekNotConnectedGroupCall: (options: PeekNotConnectedGroupCallType) => void;
|
||||
startCallLinkLobbyByRoomId: (options: { roomId: string }) => void;
|
||||
toggleConfirmLeaveCallModal: (options: StartCallData | null) => void;
|
||||
togglePip: () => void;
|
||||
}>;
|
||||
|
||||
const CALL_LIST_ITEM_ROW_HEIGHT = 62;
|
||||
|
||||
function rowHeight() {
|
||||
return CALL_LIST_ITEM_ROW_HEIGHT;
|
||||
}
|
||||
const INACTIVE_CALL_LINKS_TO_PEEK = 10;
|
||||
const INACTIVE_CALL_LINK_AGE_THRESHOLD = 10 * DAY;
|
||||
const INACTIVE_CALL_LINK_PEEK_INTERVAL = 5 * MINUTE;
|
||||
const PEEK_BATCH_COUNT = 10;
|
||||
const PEEK_QUEUE_INTERVAL = 30 * SECOND;
|
||||
|
||||
function isSameOptions(
|
||||
a: CallHistoryFilterOptions,
|
||||
|
@ -136,22 +167,34 @@ function isSameOptions(
|
|||
return a.query === b.query && a.status === b.status;
|
||||
}
|
||||
|
||||
type SpecialRows = 'CreateCallLink' | 'EmptyState';
|
||||
type Row = CallHistoryGroup | SpecialRows;
|
||||
|
||||
export function CallsList({
|
||||
hasActiveCall,
|
||||
activeCall,
|
||||
canCreateCallLinks,
|
||||
getCallHistoryGroupsCount,
|
||||
getCallHistoryGroups,
|
||||
callHistoryEdition,
|
||||
getAdhocCall,
|
||||
getCall,
|
||||
getCallLink,
|
||||
getConversation,
|
||||
i18n,
|
||||
selectedCallHistoryGroup,
|
||||
onCreateCallLink,
|
||||
onOutgoingAudioCallInConversation,
|
||||
onOutgoingVideoCallInConversation,
|
||||
onSelectCallHistoryGroup,
|
||||
onChangeCallsTabSelectedView,
|
||||
peekNotConnectedGroupCall,
|
||||
startCallLinkLobbyByRoomId,
|
||||
toggleConfirmLeaveCallModal,
|
||||
togglePip,
|
||||
}: CallsListProps): JSX.Element {
|
||||
const infiniteLoaderRef = useRef<InfiniteLoader>(null);
|
||||
const listRef = useRef<List>(null);
|
||||
const [queryInput, setQueryInput] = useState('');
|
||||
const [status, setStatus] = useState(CallHistoryFilterStatus.All);
|
||||
const [statusInput, setStatusInput] = useState(CallHistoryFilterStatus.All);
|
||||
const [searchState, setSearchState] = useState(defaultInitState);
|
||||
|
||||
const prevOptionsRef = useRef<CallHistoryFilterOptions | null>(null);
|
||||
|
@ -159,18 +202,316 @@ export function CallsList({
|
|||
const getCallHistoryGroupsCountRef = useRef(getCallHistoryGroupsCount);
|
||||
const getCallHistoryGroupsRef = useRef(getCallHistoryGroups);
|
||||
|
||||
const searchStateQuery = searchState.options?.query ?? '';
|
||||
const searchStateStatus =
|
||||
searchState.options?.status ?? CallHistoryFilterStatus.All;
|
||||
const searchFiltering =
|
||||
searchStateQuery !== '' ||
|
||||
searchStateStatus !== CallHistoryFilterStatus.All;
|
||||
const searchPending = searchState.state === 'pending';
|
||||
|
||||
const rows = useMemo(() => {
|
||||
let results: ReadonlyArray<Row> = searchState.results?.items ?? [];
|
||||
if (results.length === 0) {
|
||||
results = ['EmptyState'];
|
||||
}
|
||||
if (!searchFiltering && canCreateCallLinks) {
|
||||
results = ['CreateCallLink', ...results];
|
||||
}
|
||||
return results;
|
||||
}, [searchState.results?.items, searchFiltering, canCreateCallLinks]);
|
||||
|
||||
const rowCount = rows.length;
|
||||
|
||||
const searchStateItemsRef = useRef<ReadonlyArray<CallHistoryGroup> | null>(
|
||||
null
|
||||
);
|
||||
const peekQueueRef = useRef<Set<string>>(new Set());
|
||||
const peekQueueArgsRef = useRef<Map<string, PeekNotConnectedGroupCallType>>(
|
||||
new Map()
|
||||
);
|
||||
const inactiveCallLinksPeekedAtRef = useRef<Map<string, number>>(new Map());
|
||||
const peekQueueTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (peekQueueTimerRef.current != null) {
|
||||
clearInterval(peekQueueTimerRef.current);
|
||||
peekQueueTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
getCallHistoryGroupsCountRef.current = getCallHistoryGroupsCount;
|
||||
getCallHistoryGroupsRef.current = getCallHistoryGroups;
|
||||
}, [getCallHistoryGroupsCount, getCallHistoryGroups]);
|
||||
|
||||
const getConversationForItem = useCallback(
|
||||
(item: CallHistoryGroup | null): CallingConversationType | null => {
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isAdhoc = item?.type === CallType.Adhoc;
|
||||
if (isAdhoc) {
|
||||
const callLink = isAdhoc ? getCallLink(item.peerId) : null;
|
||||
if (callLink) {
|
||||
return callLinkToConversation(callLink, i18n);
|
||||
}
|
||||
return getPlaceholderCallLinkConversation(item.peerId, i18n);
|
||||
}
|
||||
|
||||
return getConversation(item.peerId) ?? null;
|
||||
},
|
||||
[getCallLink, getConversation, i18n]
|
||||
);
|
||||
|
||||
const getCallByPeerId = useCallback(
|
||||
({
|
||||
mode,
|
||||
peerId,
|
||||
}: {
|
||||
mode: CallMode | undefined;
|
||||
peerId: string | undefined;
|
||||
}): CallStateType | undefined => {
|
||||
if (!peerId || !mode) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === CallMode.Adhoc) {
|
||||
return getAdhocCall(peerId);
|
||||
}
|
||||
|
||||
const conversation = getConversation(peerId);
|
||||
if (!conversation) {
|
||||
return;
|
||||
}
|
||||
|
||||
return getCall(conversation.id);
|
||||
},
|
||||
[getAdhocCall, getCall, getConversation]
|
||||
);
|
||||
|
||||
const getIsCallActive = useCallback(
|
||||
({
|
||||
callHistoryGroup,
|
||||
}: {
|
||||
callHistoryGroup: CallHistoryGroup | null;
|
||||
}): boolean => {
|
||||
if (!callHistoryGroup) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { mode, peerId } = callHistoryGroup;
|
||||
const call = getCallByPeerId({ mode, peerId });
|
||||
if (!call) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isGroupOrAdhocCallState(call)) {
|
||||
if (!isAnybodyInGroupCall(call.peekInfo)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (mode === CallMode.Group) {
|
||||
const eraId = call.peekInfo?.eraId;
|
||||
if (!eraId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const callId = getCallIdFromEra(eraId);
|
||||
return callHistoryGroup.children.some(
|
||||
groupItem => groupItem.callId === callId
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// We can't tell from CallHistory alone whether a 1:1 call is active
|
||||
return false;
|
||||
},
|
||||
[getCallByPeerId]
|
||||
);
|
||||
|
||||
const getIsInCall = useCallback(
|
||||
({
|
||||
activeCallConversationId,
|
||||
callHistoryGroup,
|
||||
conversation,
|
||||
isActive,
|
||||
}: {
|
||||
activeCallConversationId: string | undefined;
|
||||
callHistoryGroup: CallHistoryGroup | null;
|
||||
conversation: CallingConversationType | null;
|
||||
isActive: boolean;
|
||||
}): boolean => {
|
||||
if (!callHistoryGroup) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { mode, peerId } = callHistoryGroup;
|
||||
|
||||
if (mode === CallMode.Adhoc) {
|
||||
return peerId === activeCallConversationId;
|
||||
}
|
||||
|
||||
// For direct conversations, we know the call is active if it's the active call!
|
||||
if (mode === CallMode.Direct) {
|
||||
return Boolean(
|
||||
conversation && conversation?.id === activeCallConversationId
|
||||
);
|
||||
}
|
||||
|
||||
// For group and adhoc calls, a call has to have members in it (see getIsCallActive)
|
||||
return Boolean(
|
||||
isActive &&
|
||||
conversation &&
|
||||
conversation?.id === activeCallConversationId
|
||||
);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// If the call is already enqueued then this is a no op.
|
||||
const maybeEnqueueCallPeek = useCallback((item: CallHistoryGroup): void => {
|
||||
const { mode: callMode, peerId } = item;
|
||||
const queue = peekQueueRef.current;
|
||||
if (queue.has(peerId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isGroupOrAdhocCallMode(callMode)) {
|
||||
peekQueueArgsRef.current.set(peerId, {
|
||||
callMode,
|
||||
conversationId: peerId,
|
||||
});
|
||||
queue.add(peerId);
|
||||
} else {
|
||||
log.error(`Trying to peek unsupported call mode ${callMode}`);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Get the oldest inserted peerIds by iterating the Set in insertion order.
|
||||
const getPeerIdsToPeek = useCallback((): ReadonlyArray<string> => {
|
||||
const peerIds: Array<string> = [];
|
||||
for (const peerId of peekQueueRef.current) {
|
||||
peerIds.push(peerId);
|
||||
if (peerIds.length === PEEK_BATCH_COUNT) {
|
||||
return peerIds;
|
||||
}
|
||||
}
|
||||
|
||||
return peerIds;
|
||||
}, []);
|
||||
|
||||
const doCallPeeks = useCallback((): void => {
|
||||
const peerIds = getPeerIdsToPeek();
|
||||
for (const peerId of peerIds) {
|
||||
const peekArgs = peekQueueArgsRef.current.get(peerId);
|
||||
if (peekArgs) {
|
||||
inactiveCallLinksPeekedAtRef.current.set(peerId, new Date().getTime());
|
||||
peekNotConnectedGroupCall(peekArgs);
|
||||
}
|
||||
|
||||
peekQueueRef.current.delete(peerId);
|
||||
peekQueueArgsRef.current.delete(peerId);
|
||||
}
|
||||
}, [getPeerIdsToPeek, peekNotConnectedGroupCall]);
|
||||
|
||||
const enqueueCallPeeks = useCallback(
|
||||
(callItems: ReadonlyArray<CallHistoryGroup>, isFirstRun: boolean): void => {
|
||||
let peekCount = 0;
|
||||
let inactiveCallLinksToPeek = 0;
|
||||
for (const item of callItems) {
|
||||
const { mode } = item;
|
||||
if (isGroupOrAdhocCallMode(mode)) {
|
||||
const isActive = getIsCallActive({ callHistoryGroup: item });
|
||||
|
||||
if (isActive) {
|
||||
// Don't peek if you're already in the call.
|
||||
const activeCallConversationId = activeCall?.conversationId;
|
||||
if (activeCallConversationId) {
|
||||
const conversation = getConversationForItem(item);
|
||||
const isInCall = getIsInCall({
|
||||
activeCallConversationId,
|
||||
callHistoryGroup: item,
|
||||
conversation,
|
||||
isActive,
|
||||
});
|
||||
if (isInCall) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
maybeEnqueueCallPeek(item);
|
||||
peekCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
mode === CallMode.Adhoc &&
|
||||
isFirstRun &&
|
||||
inactiveCallLinksToPeek < INACTIVE_CALL_LINKS_TO_PEEK &&
|
||||
isMoreRecentThan(item.timestamp, INACTIVE_CALL_LINK_AGE_THRESHOLD)
|
||||
) {
|
||||
const peekedAt = inactiveCallLinksPeekedAtRef.current.get(
|
||||
item.peerId
|
||||
);
|
||||
if (
|
||||
peekedAt &&
|
||||
isMoreRecentThan(peekedAt, INACTIVE_CALL_LINK_PEEK_INTERVAL)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
maybeEnqueueCallPeek(item);
|
||||
inactiveCallLinksToPeek += 1;
|
||||
peekCount += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (peekCount === 0) {
|
||||
return;
|
||||
}
|
||||
log.info(`Found ${peekCount} calls to peek.`);
|
||||
|
||||
if (peekQueueTimerRef.current != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
log.info('Starting background call peek.');
|
||||
peekQueueTimerRef.current = setInterval(() => {
|
||||
if (searchStateItemsRef.current) {
|
||||
enqueueCallPeeks(searchStateItemsRef.current, false);
|
||||
}
|
||||
|
||||
if (peekQueueRef.current.size > 0) {
|
||||
doCallPeeks();
|
||||
}
|
||||
}, PEEK_QUEUE_INTERVAL);
|
||||
|
||||
doCallPeeks();
|
||||
},
|
||||
[
|
||||
activeCall?.conversationId,
|
||||
doCallPeeks,
|
||||
getConversationForItem,
|
||||
getIsCallActive,
|
||||
getIsInCall,
|
||||
maybeEnqueueCallPeek,
|
||||
]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
|
||||
async function search() {
|
||||
const options: CallHistoryFilterOptions = {
|
||||
query: queryInput.toLowerCase().normalize().trim(),
|
||||
status,
|
||||
status: statusInput,
|
||||
};
|
||||
|
||||
let timer = setTimeout(() => {
|
||||
|
@ -209,6 +550,11 @@ export function CallsList({
|
|||
return;
|
||||
}
|
||||
|
||||
if (results) {
|
||||
enqueueCallPeeks(results.items, true);
|
||||
searchStateItemsRef.current = results.items;
|
||||
}
|
||||
|
||||
// Only commit the new search state once the results are ready
|
||||
setSearchState({
|
||||
state: results == null ? 'rejected' : 'fulfilled',
|
||||
|
@ -236,7 +582,7 @@ export function CallsList({
|
|||
return () => {
|
||||
controller.abort();
|
||||
};
|
||||
}, [queryInput, status, callHistoryEdition]);
|
||||
}, [queryInput, statusInput, callHistoryEdition, enqueueCallPeeks]);
|
||||
|
||||
const loadMoreRows = useCallback(
|
||||
async (props: IndexRange) => {
|
||||
|
@ -269,6 +615,8 @@ export function CallsList({
|
|||
return;
|
||||
}
|
||||
|
||||
enqueueCallPeeks(groups, false);
|
||||
|
||||
setSearchState(prevSearchState => {
|
||||
strictAssert(
|
||||
prevSearchState.results != null,
|
||||
|
@ -276,6 +624,7 @@ export function CallsList({
|
|||
);
|
||||
const newItems = prevSearchState.results.items.slice();
|
||||
newItems.splice(startIndex, stopIndex, ...groups);
|
||||
searchStateItemsRef.current = newItems;
|
||||
return {
|
||||
...prevSearchState,
|
||||
results: {
|
||||
|
@ -288,7 +637,7 @@ export function CallsList({
|
|||
log.error('CallsList#loadMoreRows error fetching', error);
|
||||
}
|
||||
},
|
||||
[searchState]
|
||||
[enqueueCallPeeks, searchState]
|
||||
);
|
||||
|
||||
const isRowLoaded = useCallback(
|
||||
|
@ -298,16 +647,92 @@ export function CallsList({
|
|||
[searchState]
|
||||
);
|
||||
|
||||
const rowHeight = useCallback(
|
||||
({ index }: Index) => {
|
||||
const item = rows.at(index) ?? null;
|
||||
|
||||
if (item === 'EmptyState') {
|
||||
// arbitary large number so the empty state can be as big as it wants,
|
||||
// scrolling should always be locked when the list is empty
|
||||
return 9999;
|
||||
}
|
||||
|
||||
return CALL_LIST_ITEM_ROW_HEIGHT;
|
||||
},
|
||||
[rows]
|
||||
);
|
||||
|
||||
const rowRenderer = useCallback(
|
||||
({ key, index, style }: ListRowProps) => {
|
||||
const item = searchState.results?.items.at(index) ?? null;
|
||||
const conversation = item != null ? getConversation(item.peerId) : null;
|
||||
const item = rows.at(index) ?? null;
|
||||
|
||||
if (
|
||||
searchState.state === 'pending' ||
|
||||
item == null ||
|
||||
conversation == null
|
||||
) {
|
||||
if (item === 'CreateCallLink') {
|
||||
return (
|
||||
<div key={key} style={style}>
|
||||
<ListTile
|
||||
moduleClassName="CallsList__ItemTile"
|
||||
title={
|
||||
<span className="CallsList__ItemTitle">
|
||||
{i18n('icu:CallsList__CreateCallLink')}
|
||||
</span>
|
||||
}
|
||||
leading={
|
||||
<Avatar
|
||||
acceptedMessageRequest
|
||||
conversationType="callLink"
|
||||
i18n={i18n}
|
||||
isMe={false}
|
||||
title=""
|
||||
sharedGroupNames={[]}
|
||||
size={AvatarSize.THIRTY_SIX}
|
||||
badge={undefined}
|
||||
className="CallsList__ItemAvatar"
|
||||
/>
|
||||
}
|
||||
onClick={onCreateCallLink}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (item === 'EmptyState') {
|
||||
return (
|
||||
<div key={key} className="CallsList__EmptyState" style={style}>
|
||||
{searchStateQuery === '' ? (
|
||||
i18n('icu:CallsList__EmptyState--noQuery')
|
||||
) : (
|
||||
<I18n
|
||||
i18n={i18n}
|
||||
id="icu:CallsList__EmptyState--hasQuery"
|
||||
components={{
|
||||
query: <UserText text={searchStateQuery} />,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const conversation = getConversationForItem(item);
|
||||
const activeCallConversationId = activeCall?.conversationId;
|
||||
|
||||
const isActive = getIsCallActive({
|
||||
callHistoryGroup: item,
|
||||
});
|
||||
const isInCall = getIsInCall({
|
||||
activeCallConversationId,
|
||||
callHistoryGroup: item,
|
||||
conversation,
|
||||
isActive,
|
||||
});
|
||||
|
||||
const isAdhoc = item?.type === CallType.Adhoc;
|
||||
const isCallButtonVisible = Boolean(
|
||||
!isAdhoc || (isAdhoc && getCallLink(item.peerId))
|
||||
);
|
||||
const isActiveVisible = Boolean(isCallButtonVisible && item && isActive);
|
||||
|
||||
if (searchPending || item == null || conversation == null) {
|
||||
return (
|
||||
<div key={key} style={style}>
|
||||
<ListTile
|
||||
|
@ -333,12 +758,18 @@ export function CallsList({
|
|||
item.direction === CallDirection.Incoming &&
|
||||
(item.status === DirectCallStatus.Missed ||
|
||||
item.status === GroupCallStatus.Missed);
|
||||
const wasDeclined =
|
||||
item.direction === CallDirection.Incoming &&
|
||||
(item.status === DirectCallStatus.Declined ||
|
||||
item.status === GroupCallStatus.Declined);
|
||||
|
||||
let statusText;
|
||||
if (wasMissed) {
|
||||
statusText = i18n('icu:CallsList__ItemCallInfo--Missed');
|
||||
} else if (item.type === CallType.Group) {
|
||||
statusText = i18n('icu:CallsList__ItemCallInfo--GroupCall');
|
||||
} else if (wasDeclined) {
|
||||
statusText = i18n('icu:CallsList__ItemCallInfo--Declined');
|
||||
} else if (isAdhoc) {
|
||||
statusText = i18n('icu:CallsList__ItemCallInfo--CallLink');
|
||||
} else if (item.direction === CallDirection.Outgoing) {
|
||||
statusText = i18n('icu:CallsList__ItemCallInfo--Outgoing');
|
||||
} else if (item.direction === CallDirection.Incoming) {
|
||||
|
@ -347,13 +778,52 @@ export function CallsList({
|
|||
strictAssert(false, 'Cannot format call');
|
||||
}
|
||||
|
||||
const inCallAndNotThisOne = !isInCall && activeCall;
|
||||
const callButton = (
|
||||
<CallsNewCallButton
|
||||
callType={item.type}
|
||||
isActive={isActiveVisible}
|
||||
isInCall={isInCall}
|
||||
isEnabled={!inCallAndNotThisOne}
|
||||
onClick={() => {
|
||||
if (isInCall) {
|
||||
togglePip();
|
||||
} else if (activeCall) {
|
||||
if (isAdhoc) {
|
||||
toggleConfirmLeaveCallModal({
|
||||
type: 'adhoc-roomId',
|
||||
roomId: item.peerId,
|
||||
});
|
||||
} else {
|
||||
toggleConfirmLeaveCallModal({
|
||||
type: 'conversation',
|
||||
conversationId: conversation.id,
|
||||
isVideoCall: item.type !== CallType.Audio,
|
||||
});
|
||||
}
|
||||
} else if (isAdhoc) {
|
||||
startCallLinkLobbyByRoomId({ roomId: item.peerId });
|
||||
} else if (conversation) {
|
||||
if (item.type === CallType.Audio) {
|
||||
onOutgoingAudioCallInConversation(conversation.id);
|
||||
} else {
|
||||
onOutgoingVideoCallInConversation(conversation.id);
|
||||
}
|
||||
}
|
||||
}}
|
||||
i18n={i18n}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
style={style}
|
||||
data-type={item.type}
|
||||
className={classNames('CallsList__Item', {
|
||||
'CallsList__Item--selected': isSelected,
|
||||
'CallsList__Item--missed': wasMissed,
|
||||
'CallsList__Item--declined': wasDeclined,
|
||||
})}
|
||||
>
|
||||
<ListTile
|
||||
|
@ -362,8 +832,9 @@ export function CallsList({
|
|||
leading={
|
||||
<Avatar
|
||||
acceptedMessageRequest
|
||||
avatarPath={conversation.avatarPath}
|
||||
conversationType="group"
|
||||
avatarUrl={conversation.avatarUrl}
|
||||
color={conversation.color}
|
||||
conversationType={conversation.type}
|
||||
i18n={i18n}
|
||||
isMe={false}
|
||||
title={conversation.title}
|
||||
|
@ -373,19 +844,7 @@ export function CallsList({
|
|||
className="CallsList__ItemAvatar"
|
||||
/>
|
||||
}
|
||||
trailing={
|
||||
<CallsNewCallButton
|
||||
callType={item.type}
|
||||
hasActiveCall={hasActiveCall}
|
||||
onClick={() => {
|
||||
if (item.type === CallType.Audio) {
|
||||
onOutgoingAudioCallInConversation(conversation.id);
|
||||
} else {
|
||||
onOutgoingVideoCallInConversation(conversation.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
}
|
||||
trailing={isCallButtonVisible ? callButton : undefined}
|
||||
title={
|
||||
<span
|
||||
className="CallsList__ItemTitle"
|
||||
|
@ -399,24 +858,53 @@ export function CallsList({
|
|||
<span className="CallsList__ItemCallInfo">
|
||||
{item.children.length > 1 ? `(${item.children.length}) ` : ''}
|
||||
{statusText} ·{' '}
|
||||
<Timestamp i18n={i18n} timestamp={item.timestamp} />
|
||||
{isActiveVisible ? (
|
||||
i18n('icu:CallsList__ItemCallInfo--Active')
|
||||
) : (
|
||||
<Timestamp i18n={i18n} timestamp={item.timestamp} />
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
onClick={() => {
|
||||
onSelectCallHistoryGroup(conversation.id, item);
|
||||
if (isAdhoc) {
|
||||
onChangeCallsTabSelectedView({
|
||||
type: 'callLink',
|
||||
roomId: item.peerId,
|
||||
callHistoryGroup: item,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (conversation == null) {
|
||||
return;
|
||||
}
|
||||
onChangeCallsTabSelectedView({
|
||||
type: 'conversation',
|
||||
conversationId: conversation.id,
|
||||
callHistoryGroup: item,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[
|
||||
hasActiveCall,
|
||||
searchState,
|
||||
getConversation,
|
||||
activeCall,
|
||||
rows,
|
||||
searchStateQuery,
|
||||
searchPending,
|
||||
getCallLink,
|
||||
getConversationForItem,
|
||||
getIsCallActive,
|
||||
getIsInCall,
|
||||
selectedCallHistoryGroup,
|
||||
onSelectCallHistoryGroup,
|
||||
onChangeCallsTabSelectedView,
|
||||
onCreateCallLink,
|
||||
onOutgoingAudioCallInConversation,
|
||||
onOutgoingVideoCallInConversation,
|
||||
startCallLinkLobbyByRoomId,
|
||||
toggleConfirmLeaveCallModal,
|
||||
togglePip,
|
||||
i18n,
|
||||
]
|
||||
);
|
||||
|
@ -433,18 +921,13 @@ export function CallsList({
|
|||
}, []);
|
||||
|
||||
const handleStatusToggle = useCallback(() => {
|
||||
setStatus(prevStatus => {
|
||||
setStatusInput(prevStatus => {
|
||||
return prevStatus === CallHistoryFilterStatus.All
|
||||
? CallHistoryFilterStatus.Missed
|
||||
: CallHistoryFilterStatus.All;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const filteringByMissed = status === CallHistoryFilterStatus.Missed;
|
||||
|
||||
const hasEmptyResults = searchState.results?.count === 0;
|
||||
const currentQuery = searchState.options?.query ?? '';
|
||||
|
||||
return (
|
||||
<>
|
||||
<NavSidebarSearchHeader>
|
||||
|
@ -463,10 +946,11 @@ export function CallsList({
|
|||
>
|
||||
<button
|
||||
className={classNames('CallsList__ToggleFilterByMissed', {
|
||||
'CallsList__ToggleFilterByMissed--pressed': filteringByMissed,
|
||||
'CallsList__ToggleFilterByMissed--pressed':
|
||||
statusInput === CallHistoryFilterStatus.Missed,
|
||||
})}
|
||||
type="button"
|
||||
aria-pressed={filteringByMissed}
|
||||
aria-pressed={statusInput === CallHistoryFilterStatus.Missed}
|
||||
aria-roledescription={i18n(
|
||||
'icu:CallsList__ToggleFilterByMissed__RoleDescription'
|
||||
)}
|
||||
|
@ -479,22 +963,6 @@ export function CallsList({
|
|||
</Tooltip>
|
||||
</NavSidebarSearchHeader>
|
||||
|
||||
{hasEmptyResults && (
|
||||
<p className="CallsList__EmptyState">
|
||||
{currentQuery === '' ? (
|
||||
i18n('icu:CallsList__EmptyState--noQuery')
|
||||
) : (
|
||||
<Intl
|
||||
i18n={i18n}
|
||||
id="icu:CallsList__EmptyState--hasQuery"
|
||||
components={{
|
||||
query: <UserText text={currentQuery} />,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<SizeObserver>
|
||||
{(ref, size) => {
|
||||
return (
|
||||
|
@ -504,7 +972,7 @@ export function CallsList({
|
|||
ref={infiniteLoaderRef}
|
||||
isRowLoaded={isRowLoaded}
|
||||
loadMoreRows={loadMoreRows}
|
||||
rowCount={searchState.results?.count}
|
||||
rowCount={rowCount}
|
||||
minimumBatchSize={100}
|
||||
threshold={30}
|
||||
>
|
||||
|
@ -512,13 +980,14 @@ export function CallsList({
|
|||
return (
|
||||
<List
|
||||
className={classNames('CallsList__List', {
|
||||
'CallsList__List--loading':
|
||||
searchState.state === 'pending',
|
||||
'CallsList__List--disableScrolling':
|
||||
searchState.results == null ||
|
||||
searchState.results.count === 0,
|
||||
})}
|
||||
ref={refMerger(listRef, registerChild)}
|
||||
width={size.width}
|
||||
height={size.height}
|
||||
rowCount={searchState.results?.count ?? 0}
|
||||
rowCount={rowCount}
|
||||
rowHeight={rowHeight}
|
||||
rowRenderer={rowRenderer}
|
||||
onRowsRendered={onRowsRendered}
|
||||
|
|
|
@ -6,24 +6,28 @@ import React, { useCallback, useMemo, useState } from 'react';
|
|||
import { partition } from 'lodash';
|
||||
import type { ListRowProps } from 'react-virtualized';
|
||||
import { List } from 'react-virtualized';
|
||||
import classNames from 'classnames';
|
||||
import type { ConversationType } from '../state/ducks/conversations';
|
||||
import type { LocalizerType } from '../types/I18N';
|
||||
import { SearchInput } from './SearchInput';
|
||||
import { filterAndSortConversationsByRecent } from '../util/filterAndSortConversations';
|
||||
import { filterAndSortConversations } from '../util/filterAndSortConversations';
|
||||
import { NavSidebarSearchHeader } from './NavSidebar';
|
||||
import { ListTile } from './ListTile';
|
||||
import { strictAssert } from '../util/assert';
|
||||
import { UserText } from './UserText';
|
||||
import { Avatar, AvatarSize } from './Avatar';
|
||||
import { Intl } from './Intl';
|
||||
import { I18n } from './I18n';
|
||||
import { SizeObserver } from '../hooks/useSizeObserver';
|
||||
import { CallType } from '../types/CallDisposition';
|
||||
import type { CallsTabSelectedView } from './CallsTab';
|
||||
import { Tooltip, TooltipPlacement } from './Tooltip';
|
||||
import { offsetDistanceModifier } from '../util/popperUtil';
|
||||
|
||||
type CallsNewCallProps = Readonly<{
|
||||
hasActiveCall: boolean;
|
||||
allConversations: ReadonlyArray<ConversationType>;
|
||||
i18n: LocalizerType;
|
||||
onSelectConversation: (conversationId: string) => void;
|
||||
onChangeCallsTabSelectedView: (selectedView: CallsTabSelectedView) => void;
|
||||
onOutgoingAudioCallInConversation: (conversationId: string) => void;
|
||||
onOutgoingVideoCallInConversation: (conversationId: string) => void;
|
||||
regionCode: string | undefined;
|
||||
|
@ -35,40 +39,78 @@ type Row =
|
|||
|
||||
export function CallsNewCallButton({
|
||||
callType,
|
||||
hasActiveCall,
|
||||
isEnabled,
|
||||
isActive,
|
||||
isInCall,
|
||||
i18n,
|
||||
onClick,
|
||||
}: {
|
||||
callType: CallType;
|
||||
hasActiveCall: boolean;
|
||||
isActive: boolean;
|
||||
isEnabled: boolean;
|
||||
isInCall: boolean;
|
||||
i18n: LocalizerType;
|
||||
onClick: () => void;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
let innerContent: React.ReactNode | string;
|
||||
let tooltipContent = '';
|
||||
if (!isEnabled) {
|
||||
tooltipContent = i18n('icu:ContactModal--already-in-call');
|
||||
}
|
||||
// Note: isActive is only set for groups and adhoc calls
|
||||
if (isActive) {
|
||||
innerContent = isInCall
|
||||
? i18n('icu:CallsNewCallButton--return')
|
||||
: i18n('icu:joinOngoingCall');
|
||||
} else if (callType === CallType.Audio) {
|
||||
innerContent = (
|
||||
<span className="CallsNewCall__ItemIcon CallsNewCall__ItemIcon--Phone" />
|
||||
);
|
||||
} else {
|
||||
innerContent = (
|
||||
<span className="CallsNewCall__ItemIcon CallsNewCall__ItemIcon--Video" />
|
||||
);
|
||||
}
|
||||
|
||||
const buttonContent = (
|
||||
<button
|
||||
type="button"
|
||||
className="CallsNewCall__ItemActionButton"
|
||||
aria-disabled={hasActiveCall}
|
||||
className={classNames(
|
||||
'CallsNewCall__ItemActionButton',
|
||||
isActive ? 'CallsNewCall__ItemActionButton--join-call' : undefined,
|
||||
isEnabled
|
||||
? undefined
|
||||
: 'CallsNewCall__ItemActionButton--join-call-disabled'
|
||||
)}
|
||||
aria-label={tooltipContent}
|
||||
onClick={event => {
|
||||
event.stopPropagation();
|
||||
if (!hasActiveCall) {
|
||||
onClick();
|
||||
}
|
||||
onClick();
|
||||
}}
|
||||
>
|
||||
{callType === CallType.Audio && (
|
||||
<span className="CallsNewCall__ItemIcon CallsNewCall__ItemIcon--Phone" />
|
||||
)}
|
||||
{callType !== CallType.Audio && (
|
||||
<span className="CallsNewCall__ItemIcon CallsNewCall__ItemIcon--Video" />
|
||||
)}
|
||||
{innerContent}
|
||||
</button>
|
||||
);
|
||||
|
||||
return tooltipContent === '' ? (
|
||||
buttonContent
|
||||
) : (
|
||||
<Tooltip
|
||||
className="CallsNewCall__ItemActionButtonTooltip"
|
||||
content={tooltipContent}
|
||||
direction={TooltipPlacement.Top}
|
||||
popperModifiers={[offsetDistanceModifier(15)]}
|
||||
>
|
||||
{buttonContent}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export function CallsNewCall({
|
||||
hasActiveCall,
|
||||
allConversations,
|
||||
i18n,
|
||||
onSelectConversation,
|
||||
onChangeCallsTabSelectedView,
|
||||
onOutgoingAudioCallInConversation,
|
||||
onOutgoingVideoCallInConversation,
|
||||
regionCode,
|
||||
|
@ -89,11 +131,7 @@ export function CallsNewCall({
|
|||
if (query === '') {
|
||||
return activeConversations;
|
||||
}
|
||||
return filterAndSortConversationsByRecent(
|
||||
activeConversations,
|
||||
query,
|
||||
regionCode
|
||||
);
|
||||
return filterAndSortConversations(activeConversations, query, regionCode);
|
||||
}, [activeConversations, query, regionCode]);
|
||||
|
||||
const [groupConversations, directConversations] = useMemo(() => {
|
||||
|
@ -177,13 +215,15 @@ export function CallsNewCall({
|
|||
);
|
||||
}
|
||||
|
||||
const isNewCallEnabled = !hasActiveCall;
|
||||
|
||||
return (
|
||||
<div key={key} style={style}>
|
||||
<ListTile
|
||||
leading={
|
||||
<Avatar
|
||||
acceptedMessageRequest
|
||||
avatarPath={item.conversation.avatarPath}
|
||||
avatarUrl={item.conversation.avatarUrl}
|
||||
conversationType="group"
|
||||
i18n={i18n}
|
||||
isMe={false}
|
||||
|
@ -199,24 +239,38 @@ export function CallsNewCall({
|
|||
{item.conversation.type === 'direct' && (
|
||||
<CallsNewCallButton
|
||||
callType={CallType.Audio}
|
||||
hasActiveCall={hasActiveCall}
|
||||
isActive={false}
|
||||
isEnabled={isNewCallEnabled}
|
||||
isInCall={false}
|
||||
onClick={() => {
|
||||
onOutgoingAudioCallInConversation(item.conversation.id);
|
||||
if (isNewCallEnabled) {
|
||||
onOutgoingAudioCallInConversation(item.conversation.id);
|
||||
}
|
||||
}}
|
||||
i18n={i18n}
|
||||
/>
|
||||
)}
|
||||
<CallsNewCallButton
|
||||
// It's okay if this is a group
|
||||
callType={CallType.Video}
|
||||
hasActiveCall={hasActiveCall}
|
||||
isActive={false}
|
||||
isEnabled={isNewCallEnabled}
|
||||
isInCall={false}
|
||||
onClick={() => {
|
||||
onOutgoingVideoCallInConversation(item.conversation.id);
|
||||
if (isNewCallEnabled) {
|
||||
onOutgoingVideoCallInConversation(item.conversation.id);
|
||||
}
|
||||
}}
|
||||
i18n={i18n}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
onClick={() => {
|
||||
onSelectConversation(item.conversation.id);
|
||||
onChangeCallsTabSelectedView({
|
||||
type: 'conversation',
|
||||
conversationId: item.conversation.id,
|
||||
callHistoryGroup: null,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
@ -226,7 +280,7 @@ export function CallsNewCall({
|
|||
rows,
|
||||
i18n,
|
||||
hasActiveCall,
|
||||
onSelectConversation,
|
||||
onChangeCallsTabSelectedView,
|
||||
onOutgoingAudioCallInConversation,
|
||||
onOutgoingVideoCallInConversation,
|
||||
]
|
||||
|
@ -248,7 +302,7 @@ export function CallsNewCall({
|
|||
{query === '' ? (
|
||||
i18n('icu:CallsNewCall__EmptyState--noQuery')
|
||||
) : (
|
||||
<Intl
|
||||
<I18n
|
||||
i18n={i18n}
|
||||
id="icu:CallsNewCall__EmptyState--hasQuery"
|
||||
components={{
|
||||
|
|
|
@ -13,11 +13,17 @@ import type {
|
|||
} from '../types/CallDisposition';
|
||||
import { CallsNewCall } from './CallsNewCall';
|
||||
import { useEscapeHandling } from '../hooks/useEscapeHandling';
|
||||
import type { ActiveCallStateType } from '../state/ducks/calling';
|
||||
import type {
|
||||
ActiveCallStateType,
|
||||
PeekNotConnectedGroupCallType,
|
||||
} from '../state/ducks/calling';
|
||||
import { ContextMenu } from './ContextMenu';
|
||||
import { ConfirmationDialog } from './ConfirmationDialog';
|
||||
import type { UnreadStats } from '../util/countUnreadStats';
|
||||
import type { WidthBreakpoint } from './_util';
|
||||
import type { CallLinkType } from '../types/CallLink';
|
||||
import type { CallStateType } from '../state/selectors/calling';
|
||||
import type { StartCallData } from './ConfirmLeaveCallModal';
|
||||
|
||||
enum CallsTabSidebarView {
|
||||
CallsListView,
|
||||
|
@ -36,7 +42,12 @@ type CallsTabProps = Readonly<{
|
|||
pagination: CallHistoryPagination
|
||||
) => Promise<Array<CallHistoryGroup>>;
|
||||
callHistoryEdition: number;
|
||||
canCreateCallLinks: boolean;
|
||||
getAdhocCall: (roomId: string) => CallStateType | undefined;
|
||||
getCall: (id: string) => CallStateType | undefined;
|
||||
getCallLink: (id: string) => CallLinkType | undefined;
|
||||
getConversation: (id: string) => ConversationType | void;
|
||||
hangUpActiveCall: (reason: string) => void;
|
||||
hasFailedStorySends: boolean;
|
||||
hasPendingUpdate: boolean;
|
||||
i18n: LocalizerType;
|
||||
|
@ -44,9 +55,15 @@ type CallsTabProps = Readonly<{
|
|||
onClearCallHistory: () => void;
|
||||
onMarkCallHistoryRead: (conversationId: string, callId: string) => void;
|
||||
onToggleNavTabsCollapse: (navTabsCollapsed: boolean) => void;
|
||||
onCreateCallLink: () => void;
|
||||
onOutgoingAudioCallInConversation: (conversationId: string) => void;
|
||||
onOutgoingVideoCallInConversation: (conversationId: string) => void;
|
||||
peekNotConnectedGroupCall: (options: PeekNotConnectedGroupCallType) => void;
|
||||
preferredLeftPaneWidth: number;
|
||||
renderCallLinkDetails: (
|
||||
roomId: string,
|
||||
callHistoryGroup: CallHistoryGroup
|
||||
) => JSX.Element;
|
||||
renderConversationDetails: (
|
||||
conversationId: string,
|
||||
callHistoryGroup: CallHistoryGroup | null
|
||||
|
@ -56,8 +73,23 @@ type CallsTabProps = Readonly<{
|
|||
}) => JSX.Element;
|
||||
regionCode: string | undefined;
|
||||
savePreferredLeftPaneWidth: (preferredLeftPaneWidth: number) => void;
|
||||
startCallLinkLobbyByRoomId: (options: { roomId: string }) => void;
|
||||
toggleConfirmLeaveCallModal: (options: StartCallData | null) => void;
|
||||
togglePip: () => void;
|
||||
}>;
|
||||
|
||||
export type CallsTabSelectedView =
|
||||
| {
|
||||
type: 'conversation';
|
||||
conversationId: string;
|
||||
callHistoryGroup: CallHistoryGroup | null;
|
||||
}
|
||||
| {
|
||||
type: 'callLink';
|
||||
roomId: string;
|
||||
callHistoryGroup: CallHistoryGroup;
|
||||
};
|
||||
|
||||
export function CallsTab({
|
||||
activeCall,
|
||||
allConversations,
|
||||
|
@ -65,7 +97,12 @@ export function CallsTab({
|
|||
getCallHistoryGroupsCount,
|
||||
getCallHistoryGroups,
|
||||
callHistoryEdition,
|
||||
canCreateCallLinks,
|
||||
getAdhocCall,
|
||||
getCall,
|
||||
getCallLink,
|
||||
getConversation,
|
||||
hangUpActiveCall,
|
||||
hasFailedStorySends,
|
||||
hasPendingUpdate,
|
||||
i18n,
|
||||
|
@ -73,48 +110,48 @@ export function CallsTab({
|
|||
onClearCallHistory,
|
||||
onMarkCallHistoryRead,
|
||||
onToggleNavTabsCollapse,
|
||||
onCreateCallLink,
|
||||
onOutgoingAudioCallInConversation,
|
||||
onOutgoingVideoCallInConversation,
|
||||
peekNotConnectedGroupCall,
|
||||
preferredLeftPaneWidth,
|
||||
renderCallLinkDetails,
|
||||
renderConversationDetails,
|
||||
renderToastManager,
|
||||
regionCode,
|
||||
savePreferredLeftPaneWidth,
|
||||
startCallLinkLobbyByRoomId,
|
||||
toggleConfirmLeaveCallModal,
|
||||
togglePip,
|
||||
}: CallsTabProps): JSX.Element {
|
||||
const [sidebarView, setSidebarView] = useState(
|
||||
CallsTabSidebarView.CallsListView
|
||||
);
|
||||
const [selected, setSelected] = useState<{
|
||||
conversationId: string;
|
||||
callHistoryGroup: CallHistoryGroup | null;
|
||||
} | null>(null);
|
||||
const [selectedView, setSelectedViewInner] =
|
||||
useState<CallsTabSelectedView | null>(null);
|
||||
const [selectedViewKey, setSelectedViewKey] = useState(() => 1);
|
||||
|
||||
const [
|
||||
confirmClearCallHistoryDialogOpen,
|
||||
setConfirmClearCallHistoryDialogOpen,
|
||||
] = useState(false);
|
||||
|
||||
const updateSelectedView = useCallback(
|
||||
(nextSelected: CallsTabSelectedView | null) => {
|
||||
setSelectedViewInner(nextSelected);
|
||||
setSelectedViewKey(key => key + 1);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const updateSidebarView = useCallback(
|
||||
(newSidebarView: CallsTabSidebarView) => {
|
||||
setSidebarView(newSidebarView);
|
||||
setSelected(null);
|
||||
updateSelectedView(null);
|
||||
},
|
||||
[]
|
||||
[updateSelectedView]
|
||||
);
|
||||
|
||||
const handleSelectCallHistoryGroup = useCallback(
|
||||
(conversationId: string, callHistoryGroup: CallHistoryGroup) => {
|
||||
setSelected({
|
||||
conversationId,
|
||||
callHistoryGroup,
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSelectConversation = useCallback((conversationId: string) => {
|
||||
setSelected({ conversationId, callHistoryGroup: null });
|
||||
}, []);
|
||||
|
||||
useEscapeHandling(
|
||||
sidebarView === CallsTabSidebarView.NewCallView
|
||||
? () => {
|
||||
|
@ -148,12 +185,12 @@ export function CallsTab({
|
|||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (selected?.callHistoryGroup != null) {
|
||||
selected.callHistoryGroup.children.forEach(child => {
|
||||
onMarkCallHistoryRead(selected.conversationId, child.callId);
|
||||
if (selectedView?.type === 'conversation') {
|
||||
selectedView.callHistoryGroup?.children.forEach(child => {
|
||||
onMarkCallHistoryRead(selectedView.conversationId, child.callId);
|
||||
});
|
||||
}
|
||||
}, [selected, onMarkCallHistoryRead]);
|
||||
}, [selectedView, onMarkCallHistoryRead]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -227,20 +264,30 @@ export function CallsTab({
|
|||
{sidebarView === CallsTabSidebarView.CallsListView && (
|
||||
<CallsList
|
||||
key={CallsTabSidebarView.CallsListView}
|
||||
hasActiveCall={activeCall != null}
|
||||
activeCall={activeCall}
|
||||
canCreateCallLinks={canCreateCallLinks}
|
||||
getCallHistoryGroupsCount={getCallHistoryGroupsCount}
|
||||
getCallHistoryGroups={getCallHistoryGroups}
|
||||
callHistoryEdition={callHistoryEdition}
|
||||
getAdhocCall={getAdhocCall}
|
||||
getCall={getCall}
|
||||
getCallLink={getCallLink}
|
||||
getConversation={getConversation}
|
||||
hangUpActiveCall={hangUpActiveCall}
|
||||
i18n={i18n}
|
||||
selectedCallHistoryGroup={selected?.callHistoryGroup ?? null}
|
||||
onSelectCallHistoryGroup={handleSelectCallHistoryGroup}
|
||||
selectedCallHistoryGroup={selectedView?.callHistoryGroup ?? null}
|
||||
onChangeCallsTabSelectedView={updateSelectedView}
|
||||
onCreateCallLink={onCreateCallLink}
|
||||
onOutgoingAudioCallInConversation={
|
||||
handleOutgoingAudioCallInConversation
|
||||
}
|
||||
onOutgoingVideoCallInConversation={
|
||||
handleOutgoingVideoCallInConversation
|
||||
}
|
||||
peekNotConnectedGroupCall={peekNotConnectedGroupCall}
|
||||
startCallLinkLobbyByRoomId={startCallLinkLobbyByRoomId}
|
||||
toggleConfirmLeaveCallModal={toggleConfirmLeaveCallModal}
|
||||
togglePip={togglePip}
|
||||
/>
|
||||
)}
|
||||
{sidebarView === CallsTabSidebarView.NewCallView && (
|
||||
|
@ -250,7 +297,7 @@ export function CallsTab({
|
|||
allConversations={allConversations}
|
||||
i18n={i18n}
|
||||
regionCode={regionCode}
|
||||
onSelectConversation={handleSelectConversation}
|
||||
onChangeCallsTabSelectedView={updateSelectedView}
|
||||
onOutgoingAudioCallInConversation={
|
||||
handleOutgoingAudioCallInConversation
|
||||
}
|
||||
|
@ -260,7 +307,7 @@ export function CallsTab({
|
|||
/>
|
||||
)}
|
||||
</NavSidebar>
|
||||
{selected == null ? (
|
||||
{selectedView == null ? (
|
||||
<div className="CallsTab__EmptyState">
|
||||
<div className="CallsTab__EmptyStateIcon" />
|
||||
<p className="CallsTab__EmptyStateLabel">
|
||||
|
@ -270,13 +317,19 @@ export function CallsTab({
|
|||
) : (
|
||||
<div
|
||||
className="CallsTab__ConversationCallDetails"
|
||||
// Force scrolling to top when a new conversation is selected.
|
||||
key={selected.conversationId}
|
||||
// Force scrolling to top when selection changes
|
||||
key={selectedViewKey}
|
||||
>
|
||||
{renderConversationDetails(
|
||||
selected.conversationId,
|
||||
selected.callHistoryGroup
|
||||
)}
|
||||
{selectedView.type === 'conversation' &&
|
||||
renderConversationDetails(
|
||||
selectedView.conversationId,
|
||||
selectedView.callHistoryGroup
|
||||
)}
|
||||
{selectedView.type === 'callLink' &&
|
||||
renderCallLinkDetails(
|
||||
selectedView.roomId,
|
||||
selectedView.callHistoryGroup
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
@ -8,17 +8,20 @@ import { Button, ButtonVariant } from './Button';
|
|||
import { Modal } from './Modal';
|
||||
import { Spinner } from './Spinner';
|
||||
|
||||
export type PropsType = {
|
||||
export type PropsType = Readonly<{
|
||||
i18n: LocalizerType;
|
||||
isPending: boolean;
|
||||
|
||||
onContinue: () => void;
|
||||
onSkip: () => void;
|
||||
};
|
||||
|
||||
export function CaptchaDialog(props: Readonly<PropsType>): JSX.Element {
|
||||
const { i18n, isPending, onSkip, onContinue } = props;
|
||||
}>;
|
||||
|
||||
export function CaptchaDialog({
|
||||
i18n,
|
||||
isPending,
|
||||
onSkip,
|
||||
onContinue,
|
||||
}: PropsType): JSX.Element {
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
|
||||
const buttonRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
|
|
@ -397,9 +397,8 @@ function CustomColorBubble({
|
|||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
const conversations = await getConversationsWithCustomColor(
|
||||
colorId
|
||||
);
|
||||
const conversations =
|
||||
await getConversationsWithCustomColor(colorId);
|
||||
if (!conversations.length) {
|
||||
onDelete();
|
||||
} else {
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import React, { forwardRef, useMemo } from 'react';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { getClassNamesFor } from '../util/getClassNamesFor';
|
||||
import { Emojify } from './conversation/Emojify';
|
||||
|
||||
export type PropsType = {
|
||||
checked?: boolean;
|
||||
|
@ -15,7 +15,7 @@ export type PropsType = {
|
|||
labelNode: JSX.Element;
|
||||
checked?: boolean;
|
||||
}) => JSX.Element;
|
||||
description?: string;
|
||||
description?: ReactNode;
|
||||
disabled?: boolean;
|
||||
isRadio?: boolean;
|
||||
label: string;
|
||||
|
@ -62,9 +62,7 @@ export const Checkbox = forwardRef(function CheckboxInner(
|
|||
<div>
|
||||
<label htmlFor={id}>
|
||||
<div>{label}</div>
|
||||
<div className={getClassName('__description')}>
|
||||
<Emojify text={description ?? ''} />
|
||||
</div>
|
||||
<div className={getClassName('__description')}>{description}</div>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -108,7 +108,7 @@ export default {
|
|||
blockConversation: action('blockConversation'),
|
||||
blockAndReportSpam: action('blockAndReportSpam'),
|
||||
deleteConversation: action('deleteConversation'),
|
||||
title: '',
|
||||
conversationName: getDefaultConversation(),
|
||||
// GroupV1 Disabled Actions
|
||||
showGV2MigrationDialog: action('showGV2MigrationDialog'),
|
||||
// GroupV2
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
// Copyright 2019 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import React, { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import type { ReadonlyDeep } from 'type-fest';
|
||||
|
||||
|
@ -13,6 +13,7 @@ import type { LocalizerType, ThemeType } from '../types/Util';
|
|||
import type { ErrorDialogAudioRecorderType } from '../types/AudioRecorder';
|
||||
import { RecordingState } from '../types/AudioRecorder';
|
||||
import type { imageToBlurHash } from '../util/imageToBlurHash';
|
||||
import { dropNull } from '../util/dropNull';
|
||||
import { Spinner } from './Spinner';
|
||||
import type {
|
||||
Props as EmojiButtonProps,
|
||||
|
@ -43,12 +44,14 @@ import type { AciString } from '../types/ServiceId';
|
|||
import { AudioCapture } from './conversation/AudioCapture';
|
||||
import { CompositionUpload } from './CompositionUpload';
|
||||
import type {
|
||||
ConversationRemovalStage,
|
||||
ConversationType,
|
||||
PushPanelForConversationActionType,
|
||||
ShowConversationType,
|
||||
} from '../state/ducks/conversations';
|
||||
import type { EmojiPickDataType } from './emoji/EmojiPicker';
|
||||
import type { LinkPreviewType } from '../types/message/LinkPreviews';
|
||||
import { isSameLinkPreview } from '../types/message/LinkPreviews';
|
||||
|
||||
import { MandatoryProfileSharingActions } from './conversation/MandatoryProfileSharingActions';
|
||||
import { MediaQualitySelector } from './MediaQualitySelector';
|
||||
|
@ -71,18 +74,20 @@ import type { SmartCompositionRecordingProps } from '../state/smart/CompositionR
|
|||
import SelectModeActions from './conversation/SelectModeActions';
|
||||
import type { ShowToastAction } from '../state/ducks/toast';
|
||||
import type { DraftEditMessageType } from '../model-types.d';
|
||||
import type { ForwardMessagesPayload } from '../state/ducks/globalModals';
|
||||
import { ForwardMessagesModalType } from './ForwardMessagesModal';
|
||||
|
||||
export type OwnProps = Readonly<{
|
||||
acceptedMessageRequest?: boolean;
|
||||
removalStage?: 'justNotification' | 'messageRequest';
|
||||
acceptedMessageRequest: boolean | null;
|
||||
removalStage: ConversationRemovalStage | null;
|
||||
addAttachment: (
|
||||
conversationId: string,
|
||||
attachment: InMemoryAttachmentDraftType
|
||||
) => unknown;
|
||||
announcementsOnly?: boolean;
|
||||
areWeAdmin?: boolean;
|
||||
areWePending?: boolean;
|
||||
areWePendingApproval?: boolean;
|
||||
announcementsOnly: boolean | null;
|
||||
areWeAdmin: boolean | null;
|
||||
areWePending: boolean | null;
|
||||
areWePendingApproval: boolean | null;
|
||||
cancelRecording: () => unknown;
|
||||
completeRecording: (
|
||||
conversationId: string,
|
||||
|
@ -93,29 +98,30 @@ export type OwnProps = Readonly<{
|
|||
) => HydratedBodyRangesType | undefined;
|
||||
conversationId: string;
|
||||
discardEditMessage: (id: string) => unknown;
|
||||
draftEditMessage?: DraftEditMessageType;
|
||||
draftEditMessage: DraftEditMessageType | null;
|
||||
draftAttachments: ReadonlyArray<AttachmentDraftType>;
|
||||
errorDialogAudioRecorderType?: ErrorDialogAudioRecorderType;
|
||||
errorDialogAudioRecorderType: ErrorDialogAudioRecorderType | null;
|
||||
errorRecording: (e: ErrorDialogAudioRecorderType) => unknown;
|
||||
focusCounter: number;
|
||||
groupAdmins: Array<ConversationType>;
|
||||
groupVersion?: 1 | 2;
|
||||
groupVersion: 1 | 2 | null;
|
||||
i18n: LocalizerType;
|
||||
imageToBlurHash: typeof imageToBlurHash;
|
||||
isDisabled: boolean;
|
||||
isFetchingUUID?: boolean;
|
||||
isFetchingUUID: boolean | null;
|
||||
isFormattingEnabled: boolean;
|
||||
isGroupV1AndDisabled?: boolean;
|
||||
isMissingMandatoryProfileSharing?: boolean;
|
||||
isSignalConversation?: boolean;
|
||||
lastEditableMessageId?: string;
|
||||
isGroupV1AndDisabled: boolean | null;
|
||||
isMissingMandatoryProfileSharing: boolean | null;
|
||||
isSignalConversation: boolean | null;
|
||||
isActive: boolean;
|
||||
lastEditableMessageId: string | null;
|
||||
recordingState: RecordingState;
|
||||
messageCompositionId: string;
|
||||
shouldHidePopovers?: boolean;
|
||||
isSMSOnly?: boolean;
|
||||
left?: boolean;
|
||||
shouldHidePopovers: boolean | null;
|
||||
isSMSOnly: boolean | null;
|
||||
left: boolean | null;
|
||||
linkPreviewLoading: boolean;
|
||||
linkPreviewResult?: LinkPreviewType;
|
||||
linkPreviewResult: LinkPreviewType | null;
|
||||
onClearAttachments(conversationId: string): unknown;
|
||||
onCloseLinkPreview(conversationId: string): unknown;
|
||||
platform: string;
|
||||
|
@ -149,15 +155,15 @@ export type OwnProps = Readonly<{
|
|||
voiceNoteAttachment?: InMemoryAttachmentDraftType;
|
||||
}
|
||||
): unknown;
|
||||
quotedMessageId?: string;
|
||||
quotedMessageProps?: ReadonlyDeep<
|
||||
quotedMessageId: string | null;
|
||||
quotedMessageProps: null | ReadonlyDeep<
|
||||
Omit<
|
||||
QuoteProps,
|
||||
'i18n' | 'onClick' | 'onClose' | 'withContentAbove' | 'isCompose'
|
||||
>
|
||||
>;
|
||||
quotedMessageAuthorAci?: AciString;
|
||||
quotedMessageSentAt?: number;
|
||||
quotedMessageAuthorAci: AciString | null;
|
||||
quotedMessageSentAt: number | null;
|
||||
|
||||
removeAttachment: (conversationId: string, filePath: string) => unknown;
|
||||
scrollToMessage: (conversationId: string, messageId: string) => unknown;
|
||||
|
@ -180,7 +186,7 @@ export type OwnProps = Readonly<{
|
|||
selectedMessageIds: ReadonlyArray<string> | undefined;
|
||||
toggleSelectMode: (on: boolean) => void;
|
||||
toggleForwardMessagesModal: (
|
||||
messageIds: ReadonlyArray<string>,
|
||||
payload: ForwardMessagesPayload,
|
||||
onForward: () => void
|
||||
) => void;
|
||||
}>;
|
||||
|
@ -210,6 +216,7 @@ export type Props = Pick<
|
|||
| 'blessedPacks'
|
||||
| 'recentStickers'
|
||||
| 'clearInstalledStickerPack'
|
||||
| 'showIntroduction'
|
||||
| 'clearShowIntroduction'
|
||||
| 'showPickerHint'
|
||||
| 'clearShowPickerHint'
|
||||
|
@ -220,7 +227,7 @@ export type Props = Pick<
|
|||
pushPanelForConversation: PushPanelForConversationActionType;
|
||||
} & OwnProps;
|
||||
|
||||
export function CompositionArea({
|
||||
export const CompositionArea = memo(function CompositionArea({
|
||||
// Base props
|
||||
addAttachment,
|
||||
conversationId,
|
||||
|
@ -232,6 +239,7 @@ export function CompositionArea({
|
|||
imageToBlurHash,
|
||||
isDisabled,
|
||||
isSignalConversation,
|
||||
isActive,
|
||||
lastEditableMessageId,
|
||||
messageCompositionId,
|
||||
pushPanelForConversation,
|
||||
|
@ -291,6 +299,7 @@ export function CompositionArea({
|
|||
recentStickers,
|
||||
clearInstalledStickerPack,
|
||||
sendStickerMessage,
|
||||
showIntroduction,
|
||||
clearShowIntroduction,
|
||||
showPickerHint,
|
||||
clearShowPickerHint,
|
||||
|
@ -301,14 +310,18 @@ export function CompositionArea({
|
|||
conversationType,
|
||||
groupVersion,
|
||||
isBlocked,
|
||||
isHidden,
|
||||
isReported,
|
||||
isMissingMandatoryProfileSharing,
|
||||
left,
|
||||
removalStage,
|
||||
acceptConversation,
|
||||
blockConversation,
|
||||
reportSpam,
|
||||
blockAndReportSpam,
|
||||
deleteConversation,
|
||||
title,
|
||||
conversationName,
|
||||
addedByName,
|
||||
// GroupV1 Disabled Actions
|
||||
isGroupV1AndDisabled,
|
||||
showGV2MigrationDialog,
|
||||
|
@ -347,8 +360,29 @@ export function CompositionArea({
|
|||
const draftEditMessageBody = draftEditMessage?.body;
|
||||
const editedMessageId = draftEditMessage?.targetMessageId;
|
||||
|
||||
const canSend =
|
||||
// Text or link preview edited
|
||||
dirty ||
|
||||
// Quote of edited message changed
|
||||
(draftEditMessage != null &&
|
||||
dropNull(draftEditMessage.quote?.messageId) !==
|
||||
dropNull(quotedMessageId)) ||
|
||||
// Link preview of edited message changed
|
||||
(draftEditMessage != null &&
|
||||
!isSameLinkPreview(linkPreviewResult, draftEditMessage?.preview)) ||
|
||||
// Not edit message, but has attachments
|
||||
(draftEditMessage == null && draftAttachments.length !== 0);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(message: string, bodyRanges: DraftBodyRanges, timestamp: number) => {
|
||||
(
|
||||
message: string,
|
||||
bodyRanges: DraftBodyRanges,
|
||||
timestamp: number
|
||||
): boolean => {
|
||||
if (!canSend) {
|
||||
return false;
|
||||
}
|
||||
|
||||
emojiButtonRef.current?.close();
|
||||
|
||||
if (editedMessageId) {
|
||||
|
@ -356,8 +390,8 @@ export function CompositionArea({
|
|||
bodyRanges,
|
||||
message,
|
||||
// sent timestamp for the quote
|
||||
quoteSentAt: quotedMessageSentAt,
|
||||
quoteAuthorAci: quotedMessageAuthorAci,
|
||||
quoteSentAt: quotedMessageSentAt ?? undefined,
|
||||
quoteAuthorAci: quotedMessageAuthorAci ?? undefined,
|
||||
targetMessageId: editedMessageId,
|
||||
});
|
||||
} else {
|
||||
|
@ -369,9 +403,12 @@ export function CompositionArea({
|
|||
});
|
||||
}
|
||||
setLarge(false);
|
||||
|
||||
return true;
|
||||
},
|
||||
[
|
||||
conversationId,
|
||||
canSend,
|
||||
draftAttachments,
|
||||
editedMessageId,
|
||||
quotedMessageSentAt,
|
||||
|
@ -504,7 +541,7 @@ export function CompositionArea({
|
|||
|
||||
inputApiRef.current?.setContents(
|
||||
draftEditMessageBody ?? '',
|
||||
draftBodyRanges,
|
||||
draftBodyRanges ?? undefined,
|
||||
true
|
||||
);
|
||||
}, [draftBodyRanges, draftEditMessageBody, hasEditDraftChanged]);
|
||||
|
@ -520,7 +557,11 @@ export function CompositionArea({
|
|||
return;
|
||||
}
|
||||
|
||||
inputApiRef.current?.setContents(draftText, draftBodyRanges, true);
|
||||
inputApiRef.current?.setContents(
|
||||
draftText,
|
||||
draftBodyRanges ?? undefined,
|
||||
true
|
||||
);
|
||||
}, [conversationId, draftBodyRanges, draftText, previousConversationId]);
|
||||
|
||||
const handleToggleLarge = useCallback(() => {
|
||||
|
@ -582,6 +623,7 @@ export function CompositionArea({
|
|||
<button
|
||||
aria-label={i18n('icu:CompositionArea__edit-action--send')}
|
||||
className="CompositionArea__edit-button CompositionArea__edit-button--accept"
|
||||
disabled={!canSend}
|
||||
onClick={() => inputApiRef.current?.submit()}
|
||||
type="button"
|
||||
/>
|
||||
|
@ -637,6 +679,7 @@ export function CompositionArea({
|
|||
onPickSticker={(packId, stickerId) =>
|
||||
sendStickerMessage(conversationId, { packId, stickerId })
|
||||
}
|
||||
showIntroduction={showIntroduction}
|
||||
clearShowIntroduction={clearShowIntroduction}
|
||||
showPickerHint={showPickerHint}
|
||||
clearShowPickerHint={clearShowPickerHint}
|
||||
|
@ -718,9 +761,15 @@ export function CompositionArea({
|
|||
}}
|
||||
onForwardMessages={() => {
|
||||
if (selectedMessageIds.length > 0) {
|
||||
toggleForwardMessagesModal(selectedMessageIds, () => {
|
||||
toggleSelectMode(false);
|
||||
});
|
||||
toggleForwardMessagesModal(
|
||||
{
|
||||
type: ForwardMessagesModalType.Forward,
|
||||
messageIds: selectedMessageIds,
|
||||
},
|
||||
() => {
|
||||
toggleSelectMode(false);
|
||||
}
|
||||
);
|
||||
}
|
||||
}}
|
||||
showToast={showToast}
|
||||
|
@ -735,16 +784,19 @@ export function CompositionArea({
|
|||
) {
|
||||
return (
|
||||
<MessageRequestActions
|
||||
acceptConversation={acceptConversation}
|
||||
blockAndReportSpam={blockAndReportSpam}
|
||||
blockConversation={blockConversation}
|
||||
conversationId={conversationId}
|
||||
addedByName={addedByName}
|
||||
conversationType={conversationType}
|
||||
deleteConversation={deleteConversation}
|
||||
conversationId={conversationId}
|
||||
conversationName={conversationName}
|
||||
i18n={i18n}
|
||||
isBlocked={isBlocked}
|
||||
isHidden={removalStage !== undefined}
|
||||
title={title}
|
||||
isHidden={isHidden}
|
||||
isReported={isReported}
|
||||
acceptConversation={acceptConversation}
|
||||
reportSpam={reportSpam}
|
||||
blockAndReportSpam={blockAndReportSpam}
|
||||
blockConversation={blockConversation}
|
||||
deleteConversation={deleteConversation}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -788,14 +840,18 @@ export function CompositionArea({
|
|||
) {
|
||||
return (
|
||||
<MandatoryProfileSharingActions
|
||||
acceptConversation={acceptConversation}
|
||||
blockAndReportSpam={blockAndReportSpam}
|
||||
blockConversation={blockConversation}
|
||||
addedByName={addedByName}
|
||||
conversationId={conversationId}
|
||||
conversationType={conversationType}
|
||||
deleteConversation={deleteConversation}
|
||||
conversationName={conversationName}
|
||||
i18n={i18n}
|
||||
title={title}
|
||||
isBlocked={isBlocked}
|
||||
isReported={isReported}
|
||||
acceptConversation={acceptConversation}
|
||||
reportSpam={reportSpam}
|
||||
blockAndReportSpam={blockAndReportSpam}
|
||||
blockConversation={blockConversation}
|
||||
deleteConversation={deleteConversation}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -979,6 +1035,7 @@ export function CompositionArea({
|
|||
i18n={i18n}
|
||||
inputApi={inputApiRef}
|
||||
isFormattingEnabled={isFormattingEnabled}
|
||||
isActive={isActive}
|
||||
large={large}
|
||||
linkPreviewLoading={linkPreviewLoading}
|
||||
linkPreviewResult={linkPreviewResult}
|
||||
|
@ -993,7 +1050,7 @@ export function CompositionArea({
|
|||
platform={platform}
|
||||
sendCounter={sendCounter}
|
||||
shouldHidePopovers={shouldHidePopovers}
|
||||
skinTone={skinTone}
|
||||
skinTone={skinTone ?? null}
|
||||
sortedGroupMembers={sortedGroupMembers}
|
||||
theme={theme}
|
||||
/>
|
||||
|
@ -1031,4 +1088,4 @@ export function CompositionArea({
|
|||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
|
@ -21,30 +21,39 @@ export default {
|
|||
args: {},
|
||||
} satisfies Meta<Props>;
|
||||
|
||||
const useProps = (overrideProps: Partial<Props> = {}): Props => ({
|
||||
i18n,
|
||||
disabled: overrideProps.disabled ?? false,
|
||||
draftText: overrideProps.draftText || undefined,
|
||||
draftBodyRanges: overrideProps.draftBodyRanges || [],
|
||||
clearQuotedMessage: action('clearQuotedMessage'),
|
||||
getPreferredBadge: () => undefined,
|
||||
getQuotedMessage: action('getQuotedMessage'),
|
||||
isFormattingEnabled:
|
||||
overrideProps.isFormattingEnabled === false
|
||||
? overrideProps.isFormattingEnabled
|
||||
: true,
|
||||
large: overrideProps.large ?? false,
|
||||
onCloseLinkPreview: action('onCloseLinkPreview'),
|
||||
onEditorStateChange: action('onEditorStateChange'),
|
||||
onPickEmoji: action('onPickEmoji'),
|
||||
onSubmit: action('onSubmit'),
|
||||
onTextTooLong: action('onTextTooLong'),
|
||||
platform: 'darwin',
|
||||
sendCounter: 0,
|
||||
sortedGroupMembers: overrideProps.sortedGroupMembers ?? [],
|
||||
skinTone: overrideProps.skinTone ?? undefined,
|
||||
theme: React.useContext(StorybookThemeContext),
|
||||
});
|
||||
const useProps = (overrideProps: Partial<Props> = {}): Props => {
|
||||
const conversation = getDefaultConversation();
|
||||
return {
|
||||
i18n,
|
||||
conversationId: conversation.id,
|
||||
disabled: overrideProps.disabled ?? false,
|
||||
draftText: overrideProps.draftText ?? null,
|
||||
draftEditMessage: overrideProps.draftEditMessage ?? null,
|
||||
draftBodyRanges: overrideProps.draftBodyRanges || [],
|
||||
clearQuotedMessage: action('clearQuotedMessage'),
|
||||
getPreferredBadge: () => undefined,
|
||||
getQuotedMessage: action('getQuotedMessage'),
|
||||
isActive: true,
|
||||
isFormattingEnabled:
|
||||
overrideProps.isFormattingEnabled === false
|
||||
? overrideProps.isFormattingEnabled
|
||||
: true,
|
||||
large: overrideProps.large ?? false,
|
||||
onCloseLinkPreview: action('onCloseLinkPreview'),
|
||||
onEditorStateChange: action('onEditorStateChange'),
|
||||
onPickEmoji: action('onPickEmoji'),
|
||||
onSubmit: action('onSubmit'),
|
||||
onTextTooLong: action('onTextTooLong'),
|
||||
platform: 'darwin',
|
||||
sendCounter: 0,
|
||||
sortedGroupMembers: overrideProps.sortedGroupMembers ?? [],
|
||||
skinTone: overrideProps.skinTone ?? null,
|
||||
theme: React.useContext(StorybookThemeContext),
|
||||
inputApi: null,
|
||||
shouldHidePopovers: null,
|
||||
linkPreviewResult: null,
|
||||
};
|
||||
};
|
||||
|
||||
export function Default(): JSX.Element {
|
||||
const props = useProps();
|
||||
|
|
|
@ -22,7 +22,12 @@ import type {
|
|||
HydratedBodyRangesType,
|
||||
RangeNode,
|
||||
} from '../types/BodyRange';
|
||||
import { BodyRange, collapseRangeTree, insertRange } from '../types/BodyRange';
|
||||
import {
|
||||
BodyRange,
|
||||
areBodyRangesEqual,
|
||||
collapseRangeTree,
|
||||
insertRange,
|
||||
} from '../types/BodyRange';
|
||||
import type { LocalizerType, ThemeType } from '../types/Util';
|
||||
import type { ConversationType } from '../state/ducks/conversations';
|
||||
import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
|
||||
|
@ -52,6 +57,7 @@ import { isNotNil } from '../util/isNotNil';
|
|||
import * as log from '../logging/log';
|
||||
import * as Errors from '../types/errors';
|
||||
import { useRefMerger } from '../hooks/useRefMerger';
|
||||
import { useEmojiSearch } from '../hooks/useEmojiSearch';
|
||||
import type { LinkPreviewType } from '../types/message/LinkPreviews';
|
||||
import { StagedLinkPreview } from './conversation/StagedLinkPreview';
|
||||
import type { DraftEditMessageType } from '../model-types.d';
|
||||
|
@ -96,25 +102,26 @@ export type InputApi = {
|
|||
|
||||
export type Props = Readonly<{
|
||||
children?: React.ReactNode;
|
||||
conversationId?: string;
|
||||
conversationId: string | null;
|
||||
i18n: LocalizerType;
|
||||
disabled?: boolean;
|
||||
draftEditMessage?: DraftEditMessageType;
|
||||
draftEditMessage: DraftEditMessageType | null;
|
||||
getPreferredBadge: PreferredBadgeSelectorType;
|
||||
large?: boolean;
|
||||
inputApi?: React.MutableRefObject<InputApi | undefined>;
|
||||
large: boolean | null;
|
||||
inputApi: React.MutableRefObject<InputApi | undefined> | null;
|
||||
isFormattingEnabled: boolean;
|
||||
isActive: boolean;
|
||||
sendCounter: number;
|
||||
skinTone?: EmojiPickDataType['skinTone'];
|
||||
draftText?: string;
|
||||
draftBodyRanges?: HydratedBodyRangesType;
|
||||
skinTone: NonNullable<EmojiPickDataType['skinTone']> | null;
|
||||
draftText: string | null;
|
||||
draftBodyRanges: HydratedBodyRangesType | null;
|
||||
moduleClassName?: string;
|
||||
theme: ThemeType;
|
||||
placeholder?: string;
|
||||
sortedGroupMembers?: ReadonlyArray<ConversationType>;
|
||||
sortedGroupMembers: ReadonlyArray<ConversationType> | null;
|
||||
scrollerRef?: React.RefObject<HTMLDivElement>;
|
||||
onDirtyChange?(dirty: boolean): unknown;
|
||||
onEditorStateChange?(options: {
|
||||
onEditorStateChange(options: {
|
||||
bodyRanges: DraftBodyRanges;
|
||||
caretLocation?: number;
|
||||
conversationId: string | undefined;
|
||||
|
@ -132,11 +139,11 @@ export type Props = Readonly<{
|
|||
): unknown;
|
||||
onScroll?: (ev: React.UIEvent<HTMLElement>) => void;
|
||||
platform: string;
|
||||
shouldHidePopovers?: boolean;
|
||||
shouldHidePopovers: boolean | null;
|
||||
getQuotedMessage?(): unknown;
|
||||
clearQuotedMessage?(): unknown;
|
||||
linkPreviewLoading?: boolean;
|
||||
linkPreviewResult?: LinkPreviewType;
|
||||
linkPreviewResult: LinkPreviewType | null;
|
||||
onCloseLinkPreview?(conversationId: string): unknown;
|
||||
}>;
|
||||
|
||||
|
@ -157,6 +164,7 @@ export function CompositionInput(props: Props): React.ReactElement {
|
|||
i18n,
|
||||
inputApi,
|
||||
isFormattingEnabled,
|
||||
isActive,
|
||||
large,
|
||||
linkPreviewLoading,
|
||||
linkPreviewResult,
|
||||
|
@ -356,7 +364,11 @@ export function CompositionInput(props: Props): React.ReactElement {
|
|||
`CompositionInput: Submitting message ${timestamp} with ${bodyRanges.length} ranges`
|
||||
);
|
||||
canSendRef.current = false;
|
||||
onSubmit(text, bodyRanges, timestamp);
|
||||
const didSend = onSubmit(text, bodyRanges, timestamp);
|
||||
|
||||
if (!didSend) {
|
||||
canSendRef.current = true;
|
||||
}
|
||||
};
|
||||
|
||||
if (inputApi) {
|
||||
|
@ -408,9 +420,14 @@ export function CompositionInput(props: Props): React.ReactElement {
|
|||
isMouseDown,
|
||||
previousFormattingEnabled,
|
||||
previousIsMouseDown,
|
||||
quillRef,
|
||||
]);
|
||||
|
||||
React.useEffect(() => {
|
||||
quillRef.current?.getModule('signalClipboard').updateOptions({
|
||||
isDisabled: !isActive,
|
||||
});
|
||||
}, [isActive]);
|
||||
|
||||
const onEnter = (): boolean => {
|
||||
const quill = quillRef.current;
|
||||
const emojiCompletion = emojiCompletionRef.current;
|
||||
|
@ -562,7 +579,7 @@ export function CompositionInput(props: Props): React.ReactElement {
|
|||
onEditorStateChange({
|
||||
bodyRanges,
|
||||
caretLocation: selection ? selection.index : undefined,
|
||||
conversationId,
|
||||
conversationId: conversationId ?? undefined,
|
||||
messageText: text,
|
||||
sendCounter,
|
||||
});
|
||||
|
@ -571,7 +588,19 @@ export function CompositionInput(props: Props): React.ReactElement {
|
|||
}
|
||||
|
||||
if (propsRef.current.onDirtyChange) {
|
||||
propsRef.current.onDirtyChange(text.length > 0);
|
||||
let isDirty: boolean = false;
|
||||
|
||||
if (!draftEditMessage) {
|
||||
isDirty = text.length > 0;
|
||||
} else if (text.trimEnd() !== draftEditMessage.body.trimEnd()) {
|
||||
isDirty = true;
|
||||
} else if (bodyRanges.length !== draftEditMessage.bodyRanges?.length) {
|
||||
isDirty = true;
|
||||
} else if (!areBodyRangesEqual(bodyRanges, draftEditMessage.bodyRanges)) {
|
||||
isDirty = true;
|
||||
}
|
||||
|
||||
propsRef.current.onDirtyChange(isDirty);
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -612,7 +641,7 @@ export function CompositionInput(props: Props): React.ReactElement {
|
|||
React.useEffect(() => {
|
||||
const emojiCompletion = emojiCompletionRef.current;
|
||||
|
||||
if (emojiCompletion === undefined || skinTone === undefined) {
|
||||
if (emojiCompletion == null || skinTone == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -688,6 +717,8 @@ export function CompositionInput(props: Props): React.ReactElement {
|
|||
const callbacksRef = React.useRef(unstaleCallbacks);
|
||||
callbacksRef.current = unstaleCallbacks;
|
||||
|
||||
const search = useEmojiSearch(i18n.getLocale());
|
||||
|
||||
const reactQuill = React.useMemo(
|
||||
() => {
|
||||
const delta = generateDelta(draftText || '', draftBodyRanges || []);
|
||||
|
@ -699,7 +730,9 @@ export function CompositionInput(props: Props): React.ReactElement {
|
|||
defaultValue={delta}
|
||||
modules={{
|
||||
toolbar: false,
|
||||
signalClipboard: true,
|
||||
signalClipboard: {
|
||||
isDisabled: !isActive,
|
||||
},
|
||||
clipboard: {
|
||||
matchers: [
|
||||
['IMG', matchEmojiImage],
|
||||
|
@ -739,6 +772,7 @@ export function CompositionInput(props: Props): React.ReactElement {
|
|||
onPickEmoji: (emoji: EmojiPickDataType) =>
|
||||
callbacksRef.current.onPickEmoji(emoji),
|
||||
skinTone,
|
||||
search,
|
||||
},
|
||||
autoSubstituteAsciiEmojis: {
|
||||
skinTone,
|
||||
|
|
|
@ -45,12 +45,12 @@ export function Default(): JSX.Element {
|
|||
{active && (
|
||||
<CompositionRecording
|
||||
i18n={i18n}
|
||||
conversationId="convo-id"
|
||||
onCancel={handleCancel}
|
||||
onSend={handleSend}
|
||||
errorRecording={_ => action('error')()}
|
||||
addAttachment={action('addAttachment')}
|
||||
completeRecording={action('completeRecording')}
|
||||
saveDraftRecordingIfNeeded={action('saveDraftRecordingIfNeeded')}
|
||||
showToast={action('showToast')}
|
||||
hideToast={action('hideToast')}
|
||||
/>
|
||||
|
|
|
@ -2,9 +2,8 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { noop } from 'lodash';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useEscapeHandling } from '../hooks/useEscapeHandling';
|
||||
import { usePrevious } from '../hooks/usePrevious';
|
||||
import type { HideToastAction, ShowToastAction } from '../state/ducks/toast';
|
||||
import type { InMemoryAttachmentDraftType } from '../types/Attachment';
|
||||
import { ErrorDialogAudioRecorderType } from '../types/AudioRecorder';
|
||||
|
@ -18,7 +17,6 @@ import { RecordingComposer } from './RecordingComposer';
|
|||
|
||||
export type Props = {
|
||||
i18n: LocalizerType;
|
||||
conversationId: string;
|
||||
onCancel: () => void;
|
||||
onSend: () => void;
|
||||
errorRecording: (e: ErrorDialogAudioRecorderType) => unknown;
|
||||
|
@ -31,47 +29,30 @@ export type Props = {
|
|||
conversationId: string,
|
||||
onRecordingComplete: (rec: InMemoryAttachmentDraftType) => unknown
|
||||
) => unknown;
|
||||
saveDraftRecordingIfNeeded: () => void;
|
||||
showToast: ShowToastAction;
|
||||
hideToast: HideToastAction;
|
||||
};
|
||||
|
||||
export function CompositionRecording({
|
||||
i18n,
|
||||
conversationId,
|
||||
onCancel,
|
||||
onSend,
|
||||
errorRecording,
|
||||
errorDialogAudioRecorderType,
|
||||
addAttachment,
|
||||
completeRecording,
|
||||
saveDraftRecordingIfNeeded,
|
||||
showToast,
|
||||
hideToast,
|
||||
}: Props): JSX.Element {
|
||||
useEscapeHandling(onCancel);
|
||||
|
||||
// when interrupted (blur, switching convos)
|
||||
// stop recording and save draft
|
||||
const handleRecordingInterruption = useCallback(() => {
|
||||
completeRecording(conversationId, attachment => {
|
||||
addAttachment(conversationId, attachment);
|
||||
});
|
||||
}, [conversationId, completeRecording, addAttachment]);
|
||||
|
||||
// switched to another app
|
||||
useEffect(() => {
|
||||
window.addEventListener('blur', handleRecordingInterruption);
|
||||
window.addEventListener('blur', saveDraftRecordingIfNeeded);
|
||||
return () => {
|
||||
window.removeEventListener('blur', handleRecordingInterruption);
|
||||
window.removeEventListener('blur', saveDraftRecordingIfNeeded);
|
||||
};
|
||||
}, [handleRecordingInterruption]);
|
||||
|
||||
// switched conversations
|
||||
const previousConversationId = usePrevious(conversationId, conversationId);
|
||||
useEffect(() => {
|
||||
if (previousConversationId !== conversationId) {
|
||||
handleRecordingInterruption();
|
||||
}
|
||||
});
|
||||
}, [saveDraftRecordingIfNeeded]);
|
||||
|
||||
useEffect(() => {
|
||||
const toast: AnyToast = { toastType: ToastType.VoiceNoteLimit };
|
||||
|
|
|
@ -19,8 +19,9 @@ import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
|
|||
import * as grapheme from '../util/grapheme';
|
||||
|
||||
export type CompositionTextAreaProps = {
|
||||
bodyRanges?: HydratedBodyRangesType;
|
||||
bodyRanges: HydratedBodyRangesType | null;
|
||||
i18n: LocalizerType;
|
||||
isActive: boolean;
|
||||
isFormattingEnabled: boolean;
|
||||
maxLength?: number;
|
||||
placeholder?: string;
|
||||
|
@ -58,6 +59,7 @@ export function CompositionTextArea({
|
|||
draftText,
|
||||
getPreferredBadge,
|
||||
i18n,
|
||||
isActive,
|
||||
isFormattingEnabled,
|
||||
maxLength,
|
||||
onChange,
|
||||
|
@ -139,6 +141,7 @@ export function CompositionTextArea({
|
|||
getPreferredBadge={getPreferredBadge}
|
||||
getQuotedMessage={noop}
|
||||
i18n={i18n}
|
||||
isActive={isActive}
|
||||
isFormattingEnabled={isFormattingEnabled}
|
||||
inputApi={inputApiRef}
|
||||
large
|
||||
|
@ -153,6 +156,17 @@ export function CompositionTextArea({
|
|||
scrollerRef={scrollerRef}
|
||||
sendCounter={0}
|
||||
theme={theme}
|
||||
skinTone={skinTone ?? null}
|
||||
// These do not apply in the forward modal because there isn't
|
||||
// strictly one conversation
|
||||
conversationId={null}
|
||||
sortedGroupMembers={null}
|
||||
// we don't edit in this context
|
||||
draftEditMessage={null}
|
||||
// rendered in the forward modal
|
||||
linkPreviewResult={null}
|
||||
// Panels appear behind this modal
|
||||
shouldHidePopovers={null}
|
||||
/>
|
||||
<div className="CompositionTextArea__emoji">
|
||||
<EmojiButton
|
||||
|
|
59
ts/components/ConfirmLeaveCallModal.tsx
Normal file
59
ts/components/ConfirmLeaveCallModal.tsx
Normal file
|
@ -0,0 +1,59 @@
|
|||
// Copyright 2024 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { ConfirmationDialog } from './ConfirmationDialog';
|
||||
|
||||
import type { LocalizerType } from '../types/Util';
|
||||
import type {
|
||||
StartCallingLobbyType,
|
||||
StartCallLinkLobbyByRoomIdType,
|
||||
StartCallLinkLobbyType,
|
||||
} from '../state/ducks/calling';
|
||||
|
||||
export type StartCallData =
|
||||
| ({
|
||||
type: 'conversation';
|
||||
} & StartCallingLobbyType)
|
||||
| ({ type: 'adhoc-roomId' } & StartCallLinkLobbyByRoomIdType)
|
||||
| ({ type: 'adhoc-rootKey' } & StartCallLinkLobbyType);
|
||||
type HousekeepingProps = {
|
||||
i18n: LocalizerType;
|
||||
};
|
||||
type DispatchProps = {
|
||||
toggleConfirmLeaveCallModal: (options: StartCallData | null) => void;
|
||||
leaveCurrentCallAndStartCallingLobby: (options: StartCallData) => void;
|
||||
};
|
||||
|
||||
export type Props = { data: StartCallData } & HousekeepingProps & DispatchProps;
|
||||
|
||||
export function ConfirmLeaveCallModal({
|
||||
i18n,
|
||||
data,
|
||||
leaveCurrentCallAndStartCallingLobby,
|
||||
toggleConfirmLeaveCallModal,
|
||||
}: Props): JSX.Element | null {
|
||||
return (
|
||||
<ConfirmationDialog
|
||||
dialogName="GroupCallRemoteParticipant.blockInfo"
|
||||
cancelText={i18n('icu:cancel')}
|
||||
i18n={i18n}
|
||||
onClose={() => {
|
||||
toggleConfirmLeaveCallModal(null);
|
||||
}}
|
||||
title={i18n('icu:CallsList__LeaveCallDialogTitle')}
|
||||
actions={[
|
||||
{
|
||||
text: i18n('icu:CallsList__LeaveCallDialogButton--leave'),
|
||||
style: 'affirmative',
|
||||
action: () => {
|
||||
leaveCurrentCallAndStartCallingLobby(data);
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
{i18n('icu:CallsList__LeaveCallDialogBody')}
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
}
|
|
@ -129,7 +129,7 @@ export const ConfirmationDialog = React.memo(function ConfirmationDialogInner({
|
|||
<Button
|
||||
key={
|
||||
typeof action.text === 'string'
|
||||
? action.id ?? action.text
|
||||
? (action.id ?? action.text)
|
||||
: action.id
|
||||
}
|
||||
disabled={action.disabled || isSpinning}
|
||||
|
|
|
@ -15,7 +15,7 @@ export type PropsType = {
|
|||
ConversationType,
|
||||
| 'about'
|
||||
| 'acceptedMessageRequest'
|
||||
| 'avatarPath'
|
||||
| 'avatarUrl'
|
||||
| 'color'
|
||||
| 'firstName'
|
||||
| 'id'
|
||||
|
@ -24,12 +24,12 @@ export type PropsType = {
|
|||
| 'profileName'
|
||||
| 'sharedGroupNames'
|
||||
| 'title'
|
||||
| 'unblurredAvatarPath'
|
||||
| 'unblurredAvatarUrl'
|
||||
>;
|
||||
|
||||
export function ContactPill({
|
||||
acceptedMessageRequest,
|
||||
avatarPath,
|
||||
avatarUrl,
|
||||
color,
|
||||
firstName,
|
||||
i18n,
|
||||
|
@ -39,7 +39,7 @@ export function ContactPill({
|
|||
profileName,
|
||||
sharedGroupNames,
|
||||
title,
|
||||
unblurredAvatarPath,
|
||||
unblurredAvatarUrl,
|
||||
onClickRemove,
|
||||
}: PropsType): JSX.Element {
|
||||
const removeLabel = i18n('icu:ContactPill--remove');
|
||||
|
@ -48,7 +48,7 @@ export function ContactPill({
|
|||
<div className="module-ContactPill">
|
||||
<Avatar
|
||||
acceptedMessageRequest={acceptedMessageRequest}
|
||||
avatarPath={avatarPath}
|
||||
avatarUrl={avatarUrl}
|
||||
badge={undefined}
|
||||
color={color}
|
||||
noteToSelf={false}
|
||||
|
@ -60,7 +60,7 @@ export function ContactPill({
|
|||
title={title}
|
||||
sharedGroupNames={sharedGroupNames}
|
||||
size={AvatarSize.TWENTY}
|
||||
unblurredAvatarPath={unblurredAvatarPath}
|
||||
unblurredAvatarUrl={unblurredAvatarUrl}
|
||||
/>
|
||||
<ContactName
|
||||
firstName={firstName}
|
||||
|
|
|
@ -38,7 +38,7 @@ const contactPillProps = (
|
|||
): ContactPillPropsType => ({
|
||||
...(overrideProps ??
|
||||
getDefaultConversation({
|
||||
avatarPath: gifUrl,
|
||||
avatarUrl: gifUrl,
|
||||
firstName: 'John',
|
||||
id: 'abc123',
|
||||
isMe: false,
|
||||
|
|
|
@ -140,6 +140,20 @@ export function ContactDirect(): JSX.Element {
|
|||
);
|
||||
}
|
||||
|
||||
export function ContactInSystemContacts(): JSX.Element {
|
||||
const contact = defaultConversations[0];
|
||||
return (
|
||||
<Wrapper
|
||||
rows={[
|
||||
{
|
||||
type: RowType.Contact,
|
||||
contact: { ...contact, systemGivenName: contact.title },
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ContactDirectWithContextMenu(): JSX.Element {
|
||||
return (
|
||||
<Wrapper
|
||||
|
@ -261,7 +275,7 @@ const createConversation = (
|
|||
: true,
|
||||
badges: [],
|
||||
isMe: overrideProps.isMe ?? false,
|
||||
avatarPath: overrideProps.avatarPath ?? '',
|
||||
avatarUrl: overrideProps.avatarUrl ?? '',
|
||||
id: overrideProps.id || '',
|
||||
isSelected: overrideProps.isSelected ?? false,
|
||||
title: overrideProps.title ?? 'Some Person',
|
||||
|
@ -294,7 +308,7 @@ export const ConversationName = (): JSX.Element => renderConversation();
|
|||
|
||||
export const ConversationNameAndAvatar = (): JSX.Element =>
|
||||
renderConversation({
|
||||
avatarPath: '/fixtures/kitten-1-64-64.jpg',
|
||||
avatarUrl: '/fixtures/kitten-1-64-64.jpg',
|
||||
});
|
||||
|
||||
export const ConversationWithYourself = (): JSX.Element =>
|
||||
|
|
|
@ -371,12 +371,13 @@ export function ConversationList({
|
|||
case RowType.Conversation: {
|
||||
const itemProps = pick(row.conversation, [
|
||||
'acceptedMessageRequest',
|
||||
'avatarPath',
|
||||
'avatarUrl',
|
||||
'badges',
|
||||
'color',
|
||||
'draftPreview',
|
||||
'groupId',
|
||||
'id',
|
||||
'isBlocked',
|
||||
'isMe',
|
||||
'isSelected',
|
||||
'isPinned',
|
||||
|
@ -392,7 +393,7 @@ export function ConversationList({
|
|||
'title',
|
||||
'type',
|
||||
'typingContactIdTimestamps',
|
||||
'unblurredAvatarPath',
|
||||
'unblurredAvatarUrl',
|
||||
'unreadCount',
|
||||
'unreadMentionsCount',
|
||||
'serviceId',
|
||||
|
|
|
@ -18,9 +18,12 @@ export type PropsType = {
|
|||
isPending: boolean;
|
||||
} & PropsActionsType;
|
||||
|
||||
export function CrashReportDialog(props: Readonly<PropsType>): JSX.Element {
|
||||
const { i18n, isPending, writeCrashReportsToLog, eraseCrashReports } = props;
|
||||
|
||||
export function CrashReportDialog({
|
||||
i18n,
|
||||
isPending,
|
||||
writeCrashReportsToLog,
|
||||
eraseCrashReports,
|
||||
}: Readonly<PropsType>): JSX.Element {
|
||||
const onEraseClick = (event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
|
|
|
@ -193,6 +193,7 @@ export function CustomizingPreferredReactionsModal({
|
|||
onClose={() => {
|
||||
deselectDraftEmoji();
|
||||
}}
|
||||
wasInvokedFromKeyboard={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
@ -12,7 +12,6 @@ import * as log from '../logging/log';
|
|||
import { Button, ButtonVariant } from './Button';
|
||||
import { Spinner } from './Spinner';
|
||||
import { ToastManager } from './ToastManager';
|
||||
import { WidthBreakpoint } from './_util';
|
||||
import { createSupportUrl } from '../util/createSupportUrl';
|
||||
import { shouldNeverBeCalled } from '../util/shouldNeverBeCalled';
|
||||
import { openLinkInWebBrowser } from '../util/openLinkInWebBrowser';
|
||||
|
@ -157,7 +156,8 @@ export function DebugLogWindow({
|
|||
onUndoArchive={shouldNeverBeCalled}
|
||||
openFileInFolder={shouldNeverBeCalled}
|
||||
toast={toast}
|
||||
containerWidthBreakpoint={WidthBreakpoint.Narrow}
|
||||
containerWidthBreakpoint={null}
|
||||
isInFullScreenCall={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
@ -213,7 +213,8 @@ export function DebugLogWindow({
|
|||
onUndoArchive={shouldNeverBeCalled}
|
||||
openFileInFolder={shouldNeverBeCalled}
|
||||
toast={toast}
|
||||
containerWidthBreakpoint={WidthBreakpoint.Narrow}
|
||||
containerWidthBreakpoint={null}
|
||||
isInFullScreenCall={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
78
ts/components/DeleteMessagesModal.stories.tsx
Normal file
78
ts/components/DeleteMessagesModal.stories.tsx
Normal file
|
@ -0,0 +1,78 @@
|
|||
// Copyright 2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
import type { Meta, StoryFn } from '@storybook/react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
|
||||
import enMessages from '../../_locales/en/messages.json';
|
||||
import { setupI18n } from '../util/setupI18n';
|
||||
import DeleteMessagesModal from './DeleteMessagesModal';
|
||||
import type { DeleteMessagesModalProps } from './DeleteMessagesModal';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
export default {
|
||||
title: 'Components/DeleteMessagesModal',
|
||||
component: DeleteMessagesModal,
|
||||
args: {
|
||||
i18n,
|
||||
isMe: false,
|
||||
isDeleteSyncSendEnabled: false,
|
||||
canDeleteForEveryone: true,
|
||||
messageCount: 1,
|
||||
onClose: action('onClose'),
|
||||
onDeleteForMe: action('onDeleteForMe'),
|
||||
onDeleteForEveryone: action('onDeleteForEveryone'),
|
||||
showToast: action('showToast'),
|
||||
},
|
||||
} satisfies Meta<DeleteMessagesModalProps>;
|
||||
|
||||
function createProps(args: Partial<DeleteMessagesModalProps>) {
|
||||
return {
|
||||
i18n,
|
||||
isMe: false,
|
||||
isDeleteSyncSendEnabled: false,
|
||||
canDeleteForEveryone: true,
|
||||
messageCount: 1,
|
||||
onClose: action('onClose'),
|
||||
onDeleteForMe: action('onDeleteForMe'),
|
||||
onDeleteForEveryone: action('onDeleteForEveryone'),
|
||||
showToast: action('showToast'),
|
||||
...args,
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react/function-component-definition
|
||||
const Template: StoryFn<DeleteMessagesModalProps> = args => {
|
||||
return <DeleteMessagesModal {...args} />;
|
||||
};
|
||||
|
||||
export const OneMessage = Template.bind({});
|
||||
|
||||
export const ThreeMessages = Template.bind({});
|
||||
ThreeMessages.args = createProps({
|
||||
messageCount: 3,
|
||||
});
|
||||
|
||||
export const IsMe = Template.bind({});
|
||||
IsMe.args = createProps({
|
||||
isMe: true,
|
||||
});
|
||||
|
||||
export const IsMeThreeMessages = Template.bind({});
|
||||
IsMeThreeMessages.args = createProps({
|
||||
isMe: true,
|
||||
messageCount: 3,
|
||||
});
|
||||
|
||||
export const DeleteSyncEnabled = Template.bind({});
|
||||
DeleteSyncEnabled.args = createProps({
|
||||
isDeleteSyncSendEnabled: true,
|
||||
});
|
||||
|
||||
export const IsMeDeleteSyncEnabled = Template.bind({});
|
||||
IsMeDeleteSyncEnabled.args = createProps({
|
||||
isDeleteSyncSendEnabled: true,
|
||||
isMe: true,
|
||||
});
|
|
@ -8,8 +8,9 @@ import type { LocalizerType } from '../types/Util';
|
|||
import type { ShowToastAction } from '../state/ducks/toast';
|
||||
import { ToastType } from '../types/Toast';
|
||||
|
||||
type DeleteMessagesModalProps = Readonly<{
|
||||
export type DeleteMessagesModalProps = Readonly<{
|
||||
isMe: boolean;
|
||||
isDeleteSyncSendEnabled: boolean;
|
||||
canDeleteForEveryone: boolean;
|
||||
i18n: LocalizerType;
|
||||
messageCount: number;
|
||||
|
@ -23,6 +24,7 @@ const MAX_DELETE_FOR_EVERYONE = 30;
|
|||
|
||||
export default function DeleteMessagesModal({
|
||||
isMe,
|
||||
isDeleteSyncSendEnabled,
|
||||
canDeleteForEveryone,
|
||||
i18n,
|
||||
messageCount,
|
||||
|
@ -33,15 +35,22 @@ export default function DeleteMessagesModal({
|
|||
}: DeleteMessagesModalProps): JSX.Element {
|
||||
const actions: Array<ActionSpec> = [];
|
||||
|
||||
const syncNoteToSelfDelete = isMe && isDeleteSyncSendEnabled;
|
||||
|
||||
let deleteForMeText = i18n('icu:DeleteMessagesModal--deleteForMe');
|
||||
if (syncNoteToSelfDelete) {
|
||||
deleteForMeText = i18n('icu:DeleteMessagesModal--noteToSelf--deleteSync');
|
||||
} else if (isMe) {
|
||||
deleteForMeText = i18n('icu:DeleteMessagesModal--deleteFromThisDevice');
|
||||
}
|
||||
|
||||
actions.push({
|
||||
action: onDeleteForMe,
|
||||
style: 'negative',
|
||||
text: isMe
|
||||
? i18n('icu:DeleteMessagesModal--deleteFromThisDevice')
|
||||
: i18n('icu:DeleteMessagesModal--deleteForMe'),
|
||||
text: deleteForMeText,
|
||||
});
|
||||
|
||||
if (canDeleteForEveryone) {
|
||||
if (canDeleteForEveryone && !syncNoteToSelfDelete) {
|
||||
const tooManyMessages = messageCount > MAX_DELETE_FOR_EVERYONE;
|
||||
actions.push({
|
||||
'aria-disabled': tooManyMessages,
|
||||
|
@ -63,6 +72,20 @@ export default function DeleteMessagesModal({
|
|||
});
|
||||
}
|
||||
|
||||
let descriptionText = i18n('icu:DeleteMessagesModal--description', {
|
||||
count: messageCount,
|
||||
});
|
||||
if (syncNoteToSelfDelete) {
|
||||
descriptionText = i18n(
|
||||
'icu:DeleteMessagesModal--description--noteToSelf--deleteSync',
|
||||
{ count: messageCount }
|
||||
);
|
||||
} else if (isMe) {
|
||||
descriptionText = i18n('icu:DeleteMessagesModal--description--noteToSelf', {
|
||||
count: messageCount,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfirmationDialog
|
||||
actions={actions}
|
||||
|
@ -74,13 +97,7 @@ export default function DeleteMessagesModal({
|
|||
})}
|
||||
moduleClassName="DeleteMessagesModal"
|
||||
>
|
||||
{isMe
|
||||
? i18n('icu:DeleteMessagesModal--description--noteToSelf', {
|
||||
count: messageCount,
|
||||
})
|
||||
: i18n('icu:DeleteMessagesModal--description', {
|
||||
count: messageCount,
|
||||
})}
|
||||
{descriptionText}
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -20,6 +20,7 @@ const defaultProps = {
|
|||
hasNetworkDialog: true,
|
||||
i18n,
|
||||
isOnline: true,
|
||||
isOutage: false,
|
||||
socketStatus: SocketStatus.CONNECTING,
|
||||
manualReconnect: action('manual-reconnect'),
|
||||
withinConnectingGracePeriod: false,
|
||||
|
@ -54,6 +55,7 @@ KnobsPlayground.args = {
|
|||
containerWidthBreakpoint: WidthBreakpoint.Wide,
|
||||
hasNetworkDialog: true,
|
||||
isOnline: true,
|
||||
isOutage: false,
|
||||
socketStatus: SocketStatus.CONNECTING,
|
||||
};
|
||||
|
||||
|
@ -105,6 +107,19 @@ export function OfflineWide(): JSX.Element {
|
|||
);
|
||||
}
|
||||
|
||||
export function OutageWide(): JSX.Element {
|
||||
return (
|
||||
<FakeLeftPaneContainer containerWidthBreakpoint={WidthBreakpoint.Wide}>
|
||||
<DialogNetworkStatus
|
||||
{...defaultProps}
|
||||
containerWidthBreakpoint={WidthBreakpoint.Wide}
|
||||
isOnline={false}
|
||||
isOutage
|
||||
/>
|
||||
</FakeLeftPaneContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConnectingNarrow(): JSX.Element {
|
||||
return (
|
||||
<FakeLeftPaneContainer containerWidthBreakpoint={WidthBreakpoint.Narrow}>
|
||||
|
@ -152,3 +167,16 @@ export function OfflineNarrow(): JSX.Element {
|
|||
</FakeLeftPaneContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export function OutageNarrow(): JSX.Element {
|
||||
return (
|
||||
<FakeLeftPaneContainer containerWidthBreakpoint={WidthBreakpoint.Narrow}>
|
||||
<DialogNetworkStatus
|
||||
{...defaultProps}
|
||||
containerWidthBreakpoint={WidthBreakpoint.Narrow}
|
||||
isOnline={false}
|
||||
isOutage
|
||||
/>
|
||||
</FakeLeftPaneContainer>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -13,7 +13,10 @@ import { clearTimeoutIfNecessary } from '../util/clearTimeoutIfNecessary';
|
|||
|
||||
const FIVE_SECONDS = 5 * 1000;
|
||||
|
||||
export type PropsType = Pick<NetworkStateType, 'isOnline' | 'socketStatus'> & {
|
||||
export type PropsType = Pick<
|
||||
NetworkStateType,
|
||||
'isOnline' | 'isOutage' | 'socketStatus'
|
||||
> & {
|
||||
containerWidthBreakpoint: WidthBreakpoint;
|
||||
i18n: LocalizerType;
|
||||
manualReconnect: () => void;
|
||||
|
@ -23,6 +26,7 @@ export function DialogNetworkStatus({
|
|||
containerWidthBreakpoint,
|
||||
i18n,
|
||||
isOnline,
|
||||
isOutage,
|
||||
socketStatus,
|
||||
manualReconnect,
|
||||
}: PropsType): JSX.Element | null {
|
||||
|
@ -48,6 +52,17 @@ export function DialogNetworkStatus({
|
|||
manualReconnect();
|
||||
};
|
||||
|
||||
if (isOutage) {
|
||||
return (
|
||||
<LeftPaneDialog
|
||||
containerWidthBreakpoint={containerWidthBreakpoint}
|
||||
type="warning"
|
||||
icon="error"
|
||||
subtitle={i18n('icu:DialogNetworkStatus__outage')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isConnecting) {
|
||||
const spinner = (
|
||||
<div className="LeftPaneDialog__spinner-container">
|
||||
|
|
|
@ -1,16 +1,29 @@
|
|||
// Copyright 2020 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
import formatFileSize from 'filesize';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import React, { useCallback } from 'react';
|
||||
import { isBeta } from '../util/version';
|
||||
import { DialogType } from '../types/Dialogs';
|
||||
import type { LocalizerType } from '../types/Util';
|
||||
import { PRODUCTION_DOWNLOAD_URL, BETA_DOWNLOAD_URL } from '../types/support';
|
||||
import { Intl } from './Intl';
|
||||
import { I18n } from './I18n';
|
||||
import { LeftPaneDialog } from './LeftPaneDialog';
|
||||
import type { WidthBreakpoint } from './_util';
|
||||
import { formatFileSize } from '../util/formatFileSize';
|
||||
|
||||
function contactSupportLink(parts: ReactNode): JSX.Element {
|
||||
return (
|
||||
<a
|
||||
key="signal-support"
|
||||
href="https://support.signal.org/hc/en-us/requests/new?desktop"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{parts}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export type PropsType = {
|
||||
containerWidthBreakpoint: WidthBreakpoint;
|
||||
|
@ -37,6 +50,22 @@ export function DialogUpdate({
|
|||
version,
|
||||
currentVersion,
|
||||
}: PropsType): JSX.Element | null {
|
||||
const retryUpdateButton = useCallback(
|
||||
(parts: ReactNode): JSX.Element => {
|
||||
return (
|
||||
<button
|
||||
className="LeftPaneDialog__retry"
|
||||
key="signal-retry"
|
||||
onClick={startUpdate}
|
||||
type="button"
|
||||
>
|
||||
{parts}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
[startUpdate]
|
||||
);
|
||||
|
||||
if (dialogType === DialogType.Cannot_Update) {
|
||||
const url = isBeta(currentVersion)
|
||||
? BETA_DOWNLOAD_URL
|
||||
|
@ -48,18 +77,9 @@ export function DialogUpdate({
|
|||
title={i18n('icu:cannotUpdate')}
|
||||
>
|
||||
<span>
|
||||
<Intl
|
||||
<I18n
|
||||
components={{
|
||||
retry: (
|
||||
<button
|
||||
className="LeftPaneDialog__retry"
|
||||
key="signal-retry"
|
||||
onClick={startUpdate}
|
||||
type="button"
|
||||
>
|
||||
{i18n('icu:autoUpdateRetry')}
|
||||
</button>
|
||||
),
|
||||
retryUpdateButton,
|
||||
url: (
|
||||
<a
|
||||
key="signal-download"
|
||||
|
@ -70,19 +90,10 @@ export function DialogUpdate({
|
|||
{url}
|
||||
</a>
|
||||
),
|
||||
support: (
|
||||
<a
|
||||
key="signal-support"
|
||||
href="https://support.signal.org/hc/en-us/requests/new?desktop"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{i18n('icu:autoUpdateContactSupport')}
|
||||
</a>
|
||||
),
|
||||
contactSupportLink,
|
||||
}}
|
||||
i18n={i18n}
|
||||
id="icu:cannotUpdateDetail"
|
||||
id="icu:cannotUpdateDetail-v2"
|
||||
/>
|
||||
</span>
|
||||
</LeftPaneDialog>
|
||||
|
@ -100,7 +111,7 @@ export function DialogUpdate({
|
|||
title={i18n('icu:cannotUpdate')}
|
||||
>
|
||||
<span>
|
||||
<Intl
|
||||
<I18n
|
||||
components={{
|
||||
url: (
|
||||
<a
|
||||
|
@ -112,19 +123,10 @@ export function DialogUpdate({
|
|||
{url}
|
||||
</a>
|
||||
),
|
||||
support: (
|
||||
<a
|
||||
key="signal-support"
|
||||
href="https://support.signal.org/hc/en-us/requests/new?desktop"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{i18n('icu:autoUpdateContactSupport')}
|
||||
</a>
|
||||
),
|
||||
contactSupportLink,
|
||||
}}
|
||||
i18n={i18n}
|
||||
id="icu:cannotUpdateRequireManualDetail"
|
||||
id="icu:cannotUpdateRequireManualDetail-v2"
|
||||
/>
|
||||
</span>
|
||||
</LeftPaneDialog>
|
||||
|
@ -142,7 +144,7 @@ export function DialogUpdate({
|
|||
type="warning"
|
||||
>
|
||||
<span>
|
||||
<Intl
|
||||
<I18n
|
||||
components={{
|
||||
app: <strong key="app">Signal.app</strong>,
|
||||
folder: <strong key="folder">/Applications</strong>,
|
||||
|
@ -195,7 +197,7 @@ export function DialogUpdate({
|
|||
(dialogType === DialogType.DownloadReady ||
|
||||
dialogType === DialogType.FullDownloadReady)
|
||||
) {
|
||||
title += ` (${formatFileSize(downloadSize, { round: 0 })})`;
|
||||
title += ` (${formatFileSize(downloadSize)})`;
|
||||
}
|
||||
|
||||
let clickLabel = i18n('icu:autoUpdateNewVersionMessage');
|
||||
|
|
|
@ -52,7 +52,7 @@ function renderAvatar(
|
|||
i18n: LocalizerType,
|
||||
{
|
||||
acceptedMessageRequest,
|
||||
avatarPath,
|
||||
avatarUrl,
|
||||
color,
|
||||
isMe,
|
||||
phoneNumber,
|
||||
|
@ -62,7 +62,7 @@ function renderAvatar(
|
|||
}: Pick<
|
||||
ConversationType,
|
||||
| 'acceptedMessageRequest'
|
||||
| 'avatarPath'
|
||||
| 'avatarUrl'
|
||||
| 'color'
|
||||
| 'isMe'
|
||||
| 'phoneNumber'
|
||||
|
@ -73,10 +73,10 @@ function renderAvatar(
|
|||
): JSX.Element {
|
||||
return (
|
||||
<div className="module-ongoing-call__remote-video-disabled">
|
||||
<CallBackgroundBlur avatarPath={avatarPath}>
|
||||
<CallBackgroundBlur avatarUrl={avatarUrl}>
|
||||
<Avatar
|
||||
acceptedMessageRequest={acceptedMessageRequest}
|
||||
avatarPath={avatarPath}
|
||||
avatarUrl={avatarUrl}
|
||||
badge={undefined}
|
||||
color={color || AvatarColors[0]}
|
||||
noteToSelf={false}
|
||||
|
|
|
@ -23,7 +23,7 @@ export type PropsType = Readonly<{
|
|||
|
||||
const UNITS = ['seconds', 'minutes', 'hours', 'days', 'weeks'] as const;
|
||||
|
||||
export type Unit = typeof UNITS[number];
|
||||
export type Unit = (typeof UNITS)[number];
|
||||
|
||||
const UNIT_TO_SEC = new Map<Unit, number>([
|
||||
['seconds', 1],
|
||||
|
|
32
ts/components/EditNicknameAndNoteModal.stories.tsx
Normal file
32
ts/components/EditNicknameAndNoteModal.stories.tsx
Normal file
|
@ -0,0 +1,32 @@
|
|||
// Copyright 2024 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import * as React from 'react';
|
||||
import enMessages from '../../_locales/en/messages.json';
|
||||
import type { ComponentMeta } from '../storybook/types';
|
||||
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
|
||||
import { setupI18n } from '../util/setupI18n';
|
||||
import type { EditNicknameAndNoteModalProps } from './EditNicknameAndNoteModal';
|
||||
import { EditNicknameAndNoteModal } from './EditNicknameAndNoteModal';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
export default {
|
||||
title: 'Components/EditNicknameAndNoteModal',
|
||||
component: EditNicknameAndNoteModal,
|
||||
argTypes: {},
|
||||
args: {
|
||||
conversation: getDefaultConversation({
|
||||
nicknameGivenName: 'Bestie',
|
||||
nicknameFamilyName: 'McBesterson',
|
||||
note: 'Met at UC Berkeley, mutual friends with Katie Hall.\n\nWebsite: https://example.com/',
|
||||
}),
|
||||
i18n,
|
||||
onClose: action('onClose'),
|
||||
onSave: action('onSave'),
|
||||
},
|
||||
} satisfies ComponentMeta<EditNicknameAndNoteModalProps>;
|
||||
|
||||
export function Normal(args: EditNicknameAndNoteModalProps): JSX.Element {
|
||||
return <EditNicknameAndNoteModal {...args} />;
|
||||
}
|
190
ts/components/EditNicknameAndNoteModal.tsx
Normal file
190
ts/components/EditNicknameAndNoteModal.tsx
Normal file
|
@ -0,0 +1,190 @@
|
|||
// Copyright 2024 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
import type { FormEvent } from 'react';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import uuid from 'uuid';
|
||||
import { z } from 'zod';
|
||||
import { Modal } from './Modal';
|
||||
import type { LocalizerType } from '../types/I18N';
|
||||
import { Avatar, AvatarSize } from './Avatar';
|
||||
import type {
|
||||
ConversationType,
|
||||
NicknameAndNote,
|
||||
} from '../state/ducks/conversations';
|
||||
import { Input } from './Input';
|
||||
import { AutoSizeTextArea } from './AutoSizeTextArea';
|
||||
import { Button, ButtonVariant } from './Button';
|
||||
import { strictAssert } from '../util/assert';
|
||||
|
||||
const formSchema = z.object({
|
||||
nickname: z
|
||||
.object({
|
||||
givenName: z.string().nullable(),
|
||||
familyName: z.string().nullable(),
|
||||
})
|
||||
.nullable(),
|
||||
note: z.string().nullable(),
|
||||
});
|
||||
|
||||
function toOptionalStringValue(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
return trimmed === '' ? null : trimmed;
|
||||
}
|
||||
|
||||
export type EditNicknameAndNoteModalProps = Readonly<{
|
||||
conversation: ConversationType;
|
||||
i18n: LocalizerType;
|
||||
onSave: (result: NicknameAndNote) => void;
|
||||
onClose: () => void;
|
||||
}>;
|
||||
|
||||
export function EditNicknameAndNoteModal({
|
||||
conversation,
|
||||
i18n,
|
||||
onSave,
|
||||
onClose,
|
||||
}: EditNicknameAndNoteModalProps): JSX.Element {
|
||||
strictAssert(
|
||||
conversation.type === 'direct',
|
||||
'Expected a direct conversation'
|
||||
);
|
||||
|
||||
const [givenName, setGivenName] = useState(
|
||||
conversation.nicknameGivenName ?? ''
|
||||
);
|
||||
const [familyName, setFamilyName] = useState(
|
||||
conversation.nicknameFamilyName ?? ''
|
||||
);
|
||||
const [note, setNote] = useState(conversation.note ?? '');
|
||||
|
||||
const [formId] = useState(() => uuid());
|
||||
const [givenNameId] = useState(() => uuid());
|
||||
const [familyNameId] = useState(() => uuid());
|
||||
const [noteId] = useState(() => uuid());
|
||||
|
||||
const formResult = useMemo(() => {
|
||||
const givenNameValue = toOptionalStringValue(givenName);
|
||||
const familyNameValue = toOptionalStringValue(familyName);
|
||||
const noteValue = toOptionalStringValue(note);
|
||||
const hasEitherName = givenNameValue != null || familyNameValue != null;
|
||||
return formSchema.safeParse({
|
||||
nickname: hasEitherName
|
||||
? { givenName: givenNameValue, familyName: familyNameValue }
|
||||
: null,
|
||||
note: noteValue,
|
||||
});
|
||||
}, [givenName, familyName, note]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (formResult.success) {
|
||||
onSave(formResult.data);
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[formResult, onSave, onClose]
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
modalName="EditNicknameAndNoteModal"
|
||||
moduleClassName="EditNicknameAndNoteModal"
|
||||
i18n={i18n}
|
||||
onClose={onClose}
|
||||
title={i18n('icu:EditNicknameAndNoteModal__Title')}
|
||||
hasXButton
|
||||
modalFooter={
|
||||
<>
|
||||
<Button variant={ButtonVariant.Secondary} onClick={onClose}>
|
||||
{i18n('icu:cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={ButtonVariant.Primary}
|
||||
type="submit"
|
||||
form={formId}
|
||||
aria-disabled={!formResult.success}
|
||||
>
|
||||
{i18n('icu:save')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="EditNicknameAndNoteModal__Description">
|
||||
{i18n('icu:EditNicknameAndNoteModal__Description')}
|
||||
</p>
|
||||
<div className="EditNicknameAndNoteModal__Avatar">
|
||||
<Avatar
|
||||
{...conversation}
|
||||
conversationType={conversation.type}
|
||||
i18n={i18n}
|
||||
size={AvatarSize.EIGHTY}
|
||||
badge={undefined}
|
||||
theme={undefined}
|
||||
/>
|
||||
</div>
|
||||
<form id={formId} onSubmit={handleSubmit}>
|
||||
<label
|
||||
htmlFor={givenNameId}
|
||||
className="EditNicknameAndNoteModal__Label"
|
||||
>
|
||||
{i18n('icu:EditNicknameAndNoteModal__FirstName__Label')}
|
||||
</label>
|
||||
<Input
|
||||
id={givenNameId}
|
||||
i18n={i18n}
|
||||
placeholder={i18n(
|
||||
'icu:EditNicknameAndNoteModal__FirstName__Placeholder'
|
||||
)}
|
||||
value={givenName}
|
||||
hasClearButton
|
||||
maxLengthCount={26}
|
||||
maxByteCount={128}
|
||||
onChange={value => {
|
||||
setGivenName(value);
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
htmlFor={familyNameId}
|
||||
className="EditNicknameAndNoteModal__Label"
|
||||
>
|
||||
{i18n('icu:EditNicknameAndNoteModal__LastName__Label')}
|
||||
</label>
|
||||
<Input
|
||||
id={familyNameId}
|
||||
i18n={i18n}
|
||||
placeholder={i18n(
|
||||
'icu:EditNicknameAndNoteModal__LastName__Placeholder'
|
||||
)}
|
||||
value={familyName}
|
||||
hasClearButton
|
||||
maxLengthCount={26}
|
||||
maxByteCount={128}
|
||||
onChange={value => {
|
||||
setFamilyName(value);
|
||||
}}
|
||||
/>
|
||||
|
||||
<label htmlFor={noteId} className="EditNicknameAndNoteModal__Label">
|
||||
{i18n('icu:EditNicknameAndNoteModal__Note__Label')}
|
||||
</label>
|
||||
<AutoSizeTextArea
|
||||
i18n={i18n}
|
||||
id={noteId}
|
||||
placeholder={i18n('icu:EditNicknameAndNoteModal__Note__Placeholder')}
|
||||
value={note}
|
||||
maxByteCount={240}
|
||||
maxLengthCount={240}
|
||||
whenToShowRemainingCount={140}
|
||||
whenToWarnRemainingCount={235}
|
||||
onChange={value => {
|
||||
setNote(value);
|
||||
}}
|
||||
/>
|
||||
<button type="submit" hidden>
|
||||
{i18n('icu:submit')}
|
||||
</button>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
|
@ -36,25 +36,20 @@ export default {
|
|||
},
|
||||
state: {
|
||||
control: { type: 'radio' },
|
||||
options: {
|
||||
Open: State.Open,
|
||||
Closed: State.Closed,
|
||||
Reserving: State.Reserving,
|
||||
Confirming: State.Confirming,
|
||||
},
|
||||
options: [State.Open, State.Closed, State.Reserving, State.Confirming],
|
||||
},
|
||||
error: {
|
||||
control: { type: 'radio' },
|
||||
options: {
|
||||
None: undefined,
|
||||
NotEnoughCharacters: UsernameReservationError.NotEnoughCharacters,
|
||||
TooManyCharacters: UsernameReservationError.TooManyCharacters,
|
||||
CheckStartingCharacter: UsernameReservationError.CheckStartingCharacter,
|
||||
CheckCharacters: UsernameReservationError.CheckCharacters,
|
||||
UsernameNotAvailable: UsernameReservationError.UsernameNotAvailable,
|
||||
General: UsernameReservationError.General,
|
||||
TooManyAttempts: UsernameReservationError.TooManyAttempts,
|
||||
},
|
||||
options: [
|
||||
undefined,
|
||||
UsernameReservationError.NotEnoughCharacters,
|
||||
UsernameReservationError.TooManyCharacters,
|
||||
UsernameReservationError.CheckStartingCharacter,
|
||||
UsernameReservationError.CheckCharacters,
|
||||
UsernameReservationError.UsernameNotAvailable,
|
||||
UsernameReservationError.General,
|
||||
UsernameReservationError.TooManyAttempts,
|
||||
],
|
||||
},
|
||||
reservation: {
|
||||
type: { name: 'string', required: false },
|
||||
|
|
|
@ -179,12 +179,12 @@ export function EditUsernameModalBody({
|
|||
return undefined;
|
||||
}
|
||||
if (error === UsernameReservationError.NotEnoughCharacters) {
|
||||
return i18n('icu:ProfileEditor--username--check-character-min', {
|
||||
return i18n('icu:ProfileEditor--username--check-character-min-plural', {
|
||||
min: minNickname,
|
||||
});
|
||||
}
|
||||
if (error === UsernameReservationError.TooManyCharacters) {
|
||||
return i18n('icu:ProfileEditor--username--check-character-max', {
|
||||
return i18n('icu:ProfileEditor--username--check-character-max-plural', {
|
||||
max: maxNickname,
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,38 +0,0 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
import type { LocalizerType } from '../types/Util';
|
||||
import { ConfirmationDialog } from './ConfirmationDialog';
|
||||
|
||||
type PropsType = {
|
||||
i18n: LocalizerType;
|
||||
onSendAnyway: () => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
export function FormattingWarningModal({
|
||||
i18n,
|
||||
onSendAnyway,
|
||||
onCancel,
|
||||
}: PropsType): JSX.Element | null {
|
||||
return (
|
||||
<ConfirmationDialog
|
||||
actions={[
|
||||
{
|
||||
action: onSendAnyway,
|
||||
autoClose: true,
|
||||
style: 'affirmative',
|
||||
text: i18n('icu:sendAnyway'),
|
||||
},
|
||||
]}
|
||||
dialogName="FormattingWarningModal"
|
||||
i18n={i18n}
|
||||
onCancel={onCancel}
|
||||
onClose={onCancel}
|
||||
title={i18n('icu:SendFormatting--dialog--title')}
|
||||
>
|
||||
{i18n('icu:SendFormatting--dialog--body')}
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
}
|
|
@ -7,7 +7,10 @@ import type { Meta } from '@storybook/react';
|
|||
import enMessages from '../../_locales/en/messages.json';
|
||||
import type { AttachmentType } from '../types/Attachment';
|
||||
import type { PropsType } from './ForwardMessagesModal';
|
||||
import { ForwardMessagesModal } from './ForwardMessagesModal';
|
||||
import {
|
||||
ForwardMessagesModal,
|
||||
ForwardMessagesModalType,
|
||||
} from './ForwardMessagesModal';
|
||||
import { IMAGE_JPEG, VIDEO_MP4, stringToMIMEType } from '../types/MIME';
|
||||
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
|
||||
import { setupI18n } from '../util/setupI18n';
|
||||
|
@ -23,7 +26,7 @@ const createAttachment = (
|
|||
contentType: stringToMIMEType(props.contentType ?? ''),
|
||||
fileName: props.fileName ?? '',
|
||||
screenshotPath: props.pending === false ? props.screenshotPath : undefined,
|
||||
url: props.pending === false ? props.url ?? '' : '',
|
||||
url: props.pending === false ? (props.url ?? '') : '',
|
||||
size: 3433,
|
||||
});
|
||||
|
||||
|
@ -49,6 +52,7 @@ const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
|||
doForwardMessages: action('doForwardMessages'),
|
||||
getPreferredBadge: () => undefined,
|
||||
i18n,
|
||||
isInFullScreenCall: false,
|
||||
linkPreviewForSource: () => undefined,
|
||||
onClose: action('onClose'),
|
||||
onChange: action('onChange'),
|
||||
|
@ -58,6 +62,7 @@ const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
|||
{...props}
|
||||
getPreferredBadge={() => undefined}
|
||||
i18n={i18n}
|
||||
isActive
|
||||
isFormattingEnabled
|
||||
onPickEmoji={action('onPickEmoji')}
|
||||
onSetSkinTone={action('onSetSkinTone')}
|
||||
|
@ -67,6 +72,7 @@ const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
|||
/>
|
||||
),
|
||||
showToast: action('showToast'),
|
||||
type: ForwardMessagesModalType.Forward,
|
||||
theme: React.useContext(StorybookThemeContext),
|
||||
regionCode: 'US',
|
||||
});
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { ComponentType } from 'react';
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
|
@ -22,7 +23,7 @@ import type { LocalizerType, ThemeType } from '../types/Util';
|
|||
import type { SmartCompositionTextAreaProps } from '../state/smart/CompositionTextArea';
|
||||
import { SearchInput } from './SearchInput';
|
||||
import { StagedLinkPreview } from './conversation/StagedLinkPreview';
|
||||
import { filterAndSortConversationsByRecent } from '../util/filterAndSortConversations';
|
||||
import { filterAndSortConversations } from '../util/filterAndSortConversations';
|
||||
import {
|
||||
shouldNeverBeCalled,
|
||||
asyncShouldNeverBeCalled,
|
||||
|
@ -41,6 +42,13 @@ import {
|
|||
isDraftForwardable,
|
||||
type MessageForwardDraft,
|
||||
} from '../types/ForwardDraft';
|
||||
import { missingCaseError } from '../util/missingCaseError';
|
||||
import { Theme } from '../util/theme';
|
||||
|
||||
export enum ForwardMessagesModalType {
|
||||
Forward,
|
||||
ShareCallLink,
|
||||
}
|
||||
|
||||
export type DataPropsType = {
|
||||
candidateConversations: ReadonlyArray<ConversationType>;
|
||||
|
@ -51,6 +59,7 @@ export type DataPropsType = {
|
|||
drafts: ReadonlyArray<MessageForwardDraft>;
|
||||
getPreferredBadge: PreferredBadgeSelectorType;
|
||||
i18n: LocalizerType;
|
||||
isInFullScreenCall: boolean;
|
||||
|
||||
linkPreviewForSource: (
|
||||
source: LinkPreviewSourceType
|
||||
|
@ -61,9 +70,8 @@ export type DataPropsType = {
|
|||
caretLocation?: number
|
||||
) => unknown;
|
||||
regionCode: string | undefined;
|
||||
RenderCompositionTextArea: (
|
||||
props: SmartCompositionTextAreaProps
|
||||
) => JSX.Element;
|
||||
RenderCompositionTextArea: ComponentType<SmartCompositionTextAreaProps>;
|
||||
type: ForwardMessagesModalType;
|
||||
showToast: ShowToastAction;
|
||||
theme: ThemeType;
|
||||
};
|
||||
|
@ -77,12 +85,14 @@ export type PropsType = DataPropsType & ActionPropsType;
|
|||
const MAX_FORWARD = 5;
|
||||
|
||||
export function ForwardMessagesModal({
|
||||
type,
|
||||
drafts,
|
||||
candidateConversations,
|
||||
doForwardMessages,
|
||||
linkPreviewForSource,
|
||||
getPreferredBadge,
|
||||
i18n,
|
||||
isInFullScreenCall,
|
||||
onClose,
|
||||
onChange,
|
||||
removeLinkPreview,
|
||||
|
@ -97,7 +107,7 @@ export function ForwardMessagesModal({
|
|||
>([]);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [filteredConversations, setFilteredConversations] = useState(
|
||||
filterAndSortConversationsByRecent(candidateConversations, '', regionCode)
|
||||
filterAndSortConversations(candidateConversations, '', regionCode)
|
||||
);
|
||||
const [isEditingMessage, setIsEditingMessage] = useState(false);
|
||||
const [cannotMessage, setCannotMessage] = useState(false);
|
||||
|
@ -170,7 +180,7 @@ export function ForwardMessagesModal({
|
|||
useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
setFilteredConversations(
|
||||
filterAndSortConversationsByRecent(
|
||||
filterAndSortConversations(
|
||||
candidateConversations,
|
||||
normalizedSearchTerm,
|
||||
regionCode
|
||||
|
@ -293,6 +303,17 @@ export function ForwardMessagesModal({
|
|||
</div>
|
||||
);
|
||||
|
||||
let title: string;
|
||||
if (type === ForwardMessagesModalType.Forward) {
|
||||
title = i18n('icu:ForwardMessageModal__title');
|
||||
} else if (type === ForwardMessagesModalType.ShareCallLink) {
|
||||
title = i18n('icu:ForwardMessageModal__ShareCallLink');
|
||||
} else {
|
||||
throw missingCaseError(type);
|
||||
}
|
||||
|
||||
const modalTheme = isInFullScreenCall ? Theme.Dark : undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
{cannotMessage && (
|
||||
|
@ -312,8 +333,9 @@ export function ForwardMessagesModal({
|
|||
onClose={onClose}
|
||||
onBackButtonClick={isEditingMessage ? handleBackOrClose : undefined}
|
||||
moduleClassName="module-ForwardMessageModal"
|
||||
title={i18n('icu:ForwardMessageModal__title')}
|
||||
useFocusTrap={false}
|
||||
title={title}
|
||||
theme={modalTheme}
|
||||
useFocusTrap={isInFullScreenCall}
|
||||
padded={false}
|
||||
modalFooter={footer}
|
||||
noMouseClose
|
||||
|
@ -413,9 +435,7 @@ type ForwardMessageEditorProps = Readonly<{
|
|||
draft: MessageForwardDraft;
|
||||
linkPreview: LinkPreviewType | null | void;
|
||||
removeLinkPreview(): void;
|
||||
RenderCompositionTextArea: (
|
||||
props: SmartCompositionTextAreaProps
|
||||
) => JSX.Element;
|
||||
RenderCompositionTextArea: ComponentType<SmartCompositionTextAreaProps>;
|
||||
onChange: (
|
||||
messageText: string,
|
||||
bodyRanges: HydratedBodyRangesType,
|
||||
|
@ -470,8 +490,9 @@ function ForwardMessageEditor({
|
|||
) : null}
|
||||
|
||||
<RenderCompositionTextArea
|
||||
bodyRanges={draft.bodyRanges}
|
||||
bodyRanges={draft.bodyRanges ?? null}
|
||||
draftText={draft.messageBody ?? ''}
|
||||
isActive
|
||||
onChange={onChange}
|
||||
onSubmit={onSubmit}
|
||||
theme={theme}
|
||||
|
|
|
@ -3,27 +3,24 @@
|
|||
|
||||
import React from 'react';
|
||||
import type {
|
||||
AuthorizeArtCreatorDataType,
|
||||
ContactModalStateType,
|
||||
DeleteMessagesPropsType,
|
||||
EditHistoryMessagesType,
|
||||
FormattingWarningDataType,
|
||||
EditNicknameAndNoteModalPropsType,
|
||||
ForwardMessagesPropsType,
|
||||
MessageRequestActionsConfirmationPropsType,
|
||||
SafetyNumberChangedBlockingDataType,
|
||||
SendEditWarningDataType,
|
||||
UserNotFoundModalStateType,
|
||||
} from '../state/ducks/globalModals';
|
||||
import type { LocalizerType, ThemeType } from '../types/Util';
|
||||
import { UsernameOnboardingState } from '../types/globalModals';
|
||||
import type { ExplodePromiseResultType } from '../util/explodePromise';
|
||||
import { missingCaseError } from '../util/missingCaseError';
|
||||
|
||||
import { ButtonVariant } from './Button';
|
||||
import { ConfirmationDialog } from './ConfirmationDialog';
|
||||
import { FormattingWarningModal } from './FormattingWarningModal';
|
||||
import { SendEditWarningModal } from './SendEditWarningModal';
|
||||
import { SignalConnectionsModal } from './SignalConnectionsModal';
|
||||
import { WhatsNewModal } from './WhatsNewModal';
|
||||
import type { StartCallData } from './ConfirmLeaveCallModal';
|
||||
|
||||
// NOTE: All types should be required for this component so that the smart
|
||||
// component gives you type errors when adding/removing props.
|
||||
|
@ -33,12 +30,24 @@ export type PropsType = {
|
|||
// AddUserToAnotherGroupModal
|
||||
addUserToAnotherGroupModalContactId: string | undefined;
|
||||
renderAddUserToAnotherGroup: () => JSX.Element;
|
||||
// CallLinkAddNameModal
|
||||
callLinkAddNameModalRoomId: string | null;
|
||||
renderCallLinkAddNameModal: () => JSX.Element;
|
||||
// CallLinkEditModal
|
||||
callLinkEditModalRoomId: string | null;
|
||||
renderCallLinkEditModal: () => JSX.Element;
|
||||
// ConfirmLeaveCallModal
|
||||
confirmLeaveCallModalState: StartCallData | null;
|
||||
renderConfirmLeaveCallModal: () => JSX.Element;
|
||||
// ContactModal
|
||||
contactModalState: ContactModalStateType | undefined;
|
||||
renderContactModal: () => JSX.Element;
|
||||
// EditHistoryMessagesModal
|
||||
editHistoryMessages: EditHistoryMessagesType | undefined;
|
||||
renderEditHistoryMessagesModal: () => JSX.Element;
|
||||
// EditNicknameAndNoteModal
|
||||
editNicknameAndNoteModalProps: EditNicknameAndNoteModalPropsType | null;
|
||||
renderEditNicknameAndNoteModal: () => JSX.Element;
|
||||
// ErrorModal
|
||||
errorModalProps:
|
||||
| { buttonVariant?: ButtonVariant; description?: string; title?: string }
|
||||
|
@ -51,25 +60,21 @@ export type PropsType = {
|
|||
// DeleteMessageModal
|
||||
deleteMessagesProps: DeleteMessagesPropsType | undefined;
|
||||
renderDeleteMessagesModal: () => JSX.Element;
|
||||
// FormattingWarningModal
|
||||
showFormattingWarningModal: (
|
||||
explodedPromise: ExplodePromiseResultType<boolean> | undefined
|
||||
) => void;
|
||||
formattingWarningData: FormattingWarningDataType | undefined;
|
||||
// ForwardMessageModal
|
||||
forwardMessagesProps: ForwardMessagesPropsType | undefined;
|
||||
renderForwardMessagesModal: () => JSX.Element;
|
||||
// MessageRequestActionsConfirmation
|
||||
messageRequestActionsConfirmationProps: MessageRequestActionsConfirmationPropsType | null;
|
||||
renderMessageRequestActionsConfirmation: () => JSX.Element;
|
||||
// NotePreviewModal
|
||||
notePreviewModalProps: { conversationId: string } | null;
|
||||
renderNotePreviewModal: () => JSX.Element;
|
||||
// ProfileEditor
|
||||
isProfileEditorVisible: boolean;
|
||||
renderProfileEditor: () => JSX.Element;
|
||||
// SafetyNumberModal
|
||||
safetyNumberModalContactId: string | undefined;
|
||||
renderSafetyNumber: () => JSX.Element;
|
||||
// SendEditWarningModal
|
||||
showSendEditWarningModal: (
|
||||
explodedPromise: ExplodePromiseResultType<boolean> | undefined
|
||||
) => void;
|
||||
sendEditWarningData: SendEditWarningDataType | undefined;
|
||||
// ShortcutGuideModal
|
||||
isShortcutGuideModalVisible: boolean;
|
||||
renderShortcutGuideModal: () => JSX.Element;
|
||||
|
@ -100,11 +105,6 @@ export type PropsType = {
|
|||
// UsernameOnboarding
|
||||
usernameOnboardingState: UsernameOnboardingState;
|
||||
renderUsernameOnboarding: () => JSX.Element;
|
||||
// AuthArtCreatorModal
|
||||
authArtCreatorData?: AuthorizeArtCreatorDataType;
|
||||
isAuthorizingArtCreator?: boolean;
|
||||
cancelAuthorizeArtCreator: () => unknown;
|
||||
confirmAuthorizeArtCreator: () => unknown;
|
||||
};
|
||||
|
||||
export function GlobalModalContainer({
|
||||
|
@ -112,33 +112,45 @@ export function GlobalModalContainer({
|
|||
// AddUserToAnotherGroupModal
|
||||
addUserToAnotherGroupModalContactId,
|
||||
renderAddUserToAnotherGroup,
|
||||
// CallLinkAddNameModal
|
||||
callLinkAddNameModalRoomId,
|
||||
renderCallLinkAddNameModal,
|
||||
// CallLinkEditModal
|
||||
callLinkEditModalRoomId,
|
||||
renderCallLinkEditModal,
|
||||
// ConfirmLeaveCallModal
|
||||
confirmLeaveCallModalState,
|
||||
renderConfirmLeaveCallModal,
|
||||
// ContactModal
|
||||
contactModalState,
|
||||
renderContactModal,
|
||||
// EditHistoryMessages
|
||||
editHistoryMessages,
|
||||
renderEditHistoryMessagesModal,
|
||||
// EditNicknameAndNoteModal
|
||||
editNicknameAndNoteModalProps,
|
||||
renderEditNicknameAndNoteModal,
|
||||
// ErrorModal
|
||||
errorModalProps,
|
||||
renderErrorModal,
|
||||
// DeleteMessageModal
|
||||
deleteMessagesProps,
|
||||
renderDeleteMessagesModal,
|
||||
// FormattingWarningModal
|
||||
showFormattingWarningModal,
|
||||
formattingWarningData,
|
||||
// ForwardMessageModal
|
||||
forwardMessagesProps,
|
||||
renderForwardMessagesModal,
|
||||
// MessageRequestActionsConfirmation
|
||||
messageRequestActionsConfirmationProps,
|
||||
renderMessageRequestActionsConfirmation,
|
||||
// NotePreviewModal
|
||||
notePreviewModalProps,
|
||||
renderNotePreviewModal,
|
||||
// ProfileEditor
|
||||
isProfileEditorVisible,
|
||||
renderProfileEditor,
|
||||
// SafetyNumberModal
|
||||
safetyNumberModalContactId,
|
||||
renderSafetyNumber,
|
||||
// SendEditWarningDataType
|
||||
showSendEditWarningModal,
|
||||
sendEditWarningData,
|
||||
// ShortcutGuideModal
|
||||
isShortcutGuideModalVisible,
|
||||
renderShortcutGuideModal,
|
||||
|
@ -167,16 +179,12 @@ export function GlobalModalContainer({
|
|||
// UsernameOnboarding
|
||||
usernameOnboardingState,
|
||||
renderUsernameOnboarding,
|
||||
// AuthArtCreatorModal
|
||||
authArtCreatorData,
|
||||
isAuthorizingArtCreator,
|
||||
cancelAuthorizeArtCreator,
|
||||
confirmAuthorizeArtCreator,
|
||||
}: PropsType): JSX.Element | null {
|
||||
// We want the following dialogs to show in this order:
|
||||
// 1. Errors
|
||||
// 2. Safety Number Changes
|
||||
// 3. The Rest (in no particular order, but they're ordered alphabetically)
|
||||
// 3. Forward Modal, so other modals can open it
|
||||
// 4. The Rest (in no particular order, but they're ordered alphabetically)
|
||||
|
||||
// Errors
|
||||
if (errorModalProps) {
|
||||
|
@ -188,62 +196,53 @@ export function GlobalModalContainer({
|
|||
return renderSendAnywayDialog();
|
||||
}
|
||||
|
||||
// Forward Modal
|
||||
if (forwardMessagesProps) {
|
||||
return renderForwardMessagesModal();
|
||||
}
|
||||
|
||||
// The Rest
|
||||
|
||||
if (confirmLeaveCallModalState) {
|
||||
return renderConfirmLeaveCallModal();
|
||||
}
|
||||
|
||||
if (addUserToAnotherGroupModalContactId) {
|
||||
return renderAddUserToAnotherGroup();
|
||||
}
|
||||
|
||||
if (callLinkAddNameModalRoomId) {
|
||||
return renderCallLinkAddNameModal();
|
||||
}
|
||||
|
||||
if (callLinkEditModalRoomId) {
|
||||
return renderCallLinkEditModal();
|
||||
}
|
||||
|
||||
if (editHistoryMessages) {
|
||||
return renderEditHistoryMessagesModal();
|
||||
}
|
||||
|
||||
if (editNicknameAndNoteModalProps) {
|
||||
return renderEditNicknameAndNoteModal();
|
||||
}
|
||||
|
||||
if (deleteMessagesProps) {
|
||||
return renderDeleteMessagesModal();
|
||||
}
|
||||
|
||||
if (formattingWarningData) {
|
||||
const { resolve } = formattingWarningData.explodedPromise;
|
||||
return (
|
||||
<FormattingWarningModal
|
||||
i18n={i18n}
|
||||
onSendAnyway={() => {
|
||||
showFormattingWarningModal(undefined);
|
||||
resolve(true);
|
||||
}}
|
||||
onCancel={() => {
|
||||
showFormattingWarningModal(undefined);
|
||||
resolve(false);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
if (messageRequestActionsConfirmationProps) {
|
||||
return renderMessageRequestActionsConfirmation();
|
||||
}
|
||||
|
||||
if (forwardMessagesProps) {
|
||||
return renderForwardMessagesModal();
|
||||
if (notePreviewModalProps) {
|
||||
return renderNotePreviewModal();
|
||||
}
|
||||
|
||||
if (isProfileEditorVisible) {
|
||||
return renderProfileEditor();
|
||||
}
|
||||
|
||||
if (sendEditWarningData) {
|
||||
const { resolve } = sendEditWarningData.explodedPromise;
|
||||
return (
|
||||
<SendEditWarningModal
|
||||
i18n={i18n}
|
||||
onSendAnyway={() => {
|
||||
showSendEditWarningModal(undefined);
|
||||
resolve(true);
|
||||
}}
|
||||
onCancel={() => {
|
||||
showSendEditWarningModal(undefined);
|
||||
resolve(false);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isShortcutGuideModalVisible) {
|
||||
return renderShortcutGuideModal();
|
||||
}
|
||||
|
@ -312,28 +311,5 @@ export function GlobalModalContainer({
|
|||
);
|
||||
}
|
||||
|
||||
if (authArtCreatorData) {
|
||||
return (
|
||||
<ConfirmationDialog
|
||||
dialogName="GlobalModalContainer.authArtCreator"
|
||||
cancelText={i18n('icu:AuthArtCreator--dialog--dismiss')}
|
||||
cancelButtonVariant={ButtonVariant.Secondary}
|
||||
i18n={i18n}
|
||||
isSpinning={isAuthorizingArtCreator}
|
||||
onClose={cancelAuthorizeArtCreator}
|
||||
actions={[
|
||||
{
|
||||
text: i18n('icu:AuthArtCreator--dialog--confirm'),
|
||||
style: 'affirmative',
|
||||
action: confirmAuthorizeArtCreator,
|
||||
autoClose: false,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{i18n('icu:AuthArtCreator--dialog--message')}
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
|
@ -13,6 +13,7 @@ import { fakeGetGroupCallVideoFrameSource } from '../test-both/helpers/fakeGetGr
|
|||
import { FRAME_BUFFER_SIZE } from '../calling/constants';
|
||||
import enMessages from '../../_locales/en/messages.json';
|
||||
import { generateAci } from '../types/ServiceId';
|
||||
import type { CallingImageDataCache } from './CallManager';
|
||||
|
||||
const MAX_PARTICIPANTS = 32;
|
||||
|
||||
|
@ -42,7 +43,9 @@ export default {
|
|||
|
||||
const defaultProps = {
|
||||
getFrameBuffer: memoize(() => Buffer.alloc(FRAME_BUFFER_SIZE)),
|
||||
getCallingImageDataCache: memoize(() => new Map()),
|
||||
getGroupCallVideoFrameSource: fakeGetGroupCallVideoFrameSource,
|
||||
imageDataCache: React.createRef<CallingImageDataCache>(),
|
||||
i18n,
|
||||
isCallReconnecting: false,
|
||||
onParticipantVisibilityChanged: action('onParticipantVisibilityChanged'),
|
||||
|
|
|
@ -8,6 +8,7 @@ import type { VideoFrameSource } from '@signalapp/ringrtc';
|
|||
import type { LocalizerType } from '../types/Util';
|
||||
import type { GroupCallRemoteParticipantType } from '../types/Calling';
|
||||
import { GroupCallRemoteParticipant } from './GroupCallRemoteParticipant';
|
||||
import type { CallingImageDataCache } from './CallManager';
|
||||
|
||||
const OVERFLOW_SCROLLED_TO_EDGE_THRESHOLD = 20;
|
||||
const OVERFLOW_SCROLL_BUTTON_RATIO = 0.75;
|
||||
|
@ -19,6 +20,7 @@ export type PropsType = {
|
|||
getFrameBuffer: () => Buffer;
|
||||
getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource;
|
||||
i18n: LocalizerType;
|
||||
imageDataCache: React.RefObject<CallingImageDataCache>;
|
||||
isCallReconnecting: boolean;
|
||||
onClickRaisedHand?: () => void;
|
||||
onParticipantVisibilityChanged: (
|
||||
|
@ -33,6 +35,7 @@ export type PropsType = {
|
|||
export function GroupCallOverflowArea({
|
||||
getFrameBuffer,
|
||||
getGroupCallVideoFrameSource,
|
||||
imageDataCache,
|
||||
i18n,
|
||||
isCallReconnecting,
|
||||
onClickRaisedHand,
|
||||
|
@ -121,6 +124,7 @@ export function GroupCallOverflowArea({
|
|||
key={remoteParticipant.demuxId}
|
||||
getFrameBuffer={getFrameBuffer}
|
||||
getGroupCallVideoFrameSource={getGroupCallVideoFrameSource}
|
||||
imageDataCache={imageDataCache}
|
||||
i18n={i18n}
|
||||
audioLevel={remoteAudioLevels.get(remoteParticipant.demuxId) ?? 0}
|
||||
onClickRaisedHand={onClickRaisedHand}
|
||||
|
|
|
@ -11,6 +11,7 @@ import { FRAME_BUFFER_SIZE } from '../calling/constants';
|
|||
import { setupI18n } from '../util/setupI18n';
|
||||
import { generateAci } from '../types/ServiceId';
|
||||
import enMessages from '../../_locales/en/messages.json';
|
||||
import type { CallingImageDataCache } from './CallManager';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
|
@ -54,6 +55,7 @@ const createProps = (
|
|||
getGroupCallVideoFrameSource: () => {
|
||||
return { receiveVideoFrame: () => undefined };
|
||||
},
|
||||
imageDataCache: React.createRef<CallingImageDataCache>(),
|
||||
i18n,
|
||||
audioLevel: 0,
|
||||
remoteParticipant: {
|
||||
|
@ -192,3 +194,43 @@ export function NoMediaKeys(): JSX.Element {
|
|||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function NoMediaKeysBlockedIntermittent(): JSX.Element {
|
||||
const [isBlocked, setIsBlocked] = React.useState(false);
|
||||
React.useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setIsBlocked(value => !value);
|
||||
}, 6000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isBlocked]);
|
||||
|
||||
const [mediaKeysReceived, setMediaKeysReceived] = React.useState(false);
|
||||
React.useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setMediaKeysReceived(value => !value);
|
||||
}, 3000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [mediaKeysReceived]);
|
||||
|
||||
return (
|
||||
<GroupCallRemoteParticipant
|
||||
{...createProps(
|
||||
{
|
||||
isInPip: false,
|
||||
height: 120,
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
addedTime: Date.now() - 60 * 1000,
|
||||
hasRemoteAudio: true,
|
||||
mediaKeysReceived,
|
||||
isBlocked,
|
||||
}
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -22,13 +22,15 @@ import {
|
|||
} from './CallingAudioIndicator';
|
||||
import { Avatar, AvatarSize } from './Avatar';
|
||||
import { ConfirmationDialog } from './ConfirmationDialog';
|
||||
import { Intl } from './Intl';
|
||||
import { I18n } from './I18n';
|
||||
import { ContactName } from './conversation/ContactName';
|
||||
import { useIntersectionObserver } from '../hooks/useIntersectionObserver';
|
||||
import { MAX_FRAME_HEIGHT, MAX_FRAME_WIDTH } from '../calling/constants';
|
||||
import { useValueAtFixedRate } from '../hooks/useValueAtFixedRate';
|
||||
import { Theme } from '../util/theme';
|
||||
import { isOlderThan } from '../util/timestamp';
|
||||
import type { CallingImageDataCache } from './CallManager';
|
||||
import { usePrevious } from '../hooks/usePrevious';
|
||||
|
||||
const MAX_TIME_TO_SHOW_STALE_VIDEO_FRAMES = 10000;
|
||||
const MAX_TIME_TO_SHOW_STALE_SCREENSHARE_FRAMES = 60000;
|
||||
|
@ -38,6 +40,7 @@ type BasePropsType = {
|
|||
getFrameBuffer: () => Buffer;
|
||||
getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource;
|
||||
i18n: LocalizerType;
|
||||
imageDataCache: React.RefObject<CallingImageDataCache>;
|
||||
isActiveSpeakerInSpeakerView: boolean;
|
||||
isCallReconnecting: boolean;
|
||||
onClickRaisedHand?: () => void;
|
||||
|
@ -70,6 +73,7 @@ export const GroupCallRemoteParticipant: React.FC<PropsType> = React.memo(
|
|||
const {
|
||||
getFrameBuffer,
|
||||
getGroupCallVideoFrameSource,
|
||||
imageDataCache,
|
||||
i18n,
|
||||
onClickRaisedHand,
|
||||
onVisibilityChanged,
|
||||
|
@ -81,7 +85,7 @@ export const GroupCallRemoteParticipant: React.FC<PropsType> = React.memo(
|
|||
const {
|
||||
acceptedMessageRequest,
|
||||
addedTime,
|
||||
avatarPath,
|
||||
avatarUrl,
|
||||
color,
|
||||
demuxId,
|
||||
hasRemoteAudio,
|
||||
|
@ -101,9 +105,12 @@ export const GroupCallRemoteParticipant: React.FC<PropsType> = React.memo(
|
|||
!props.isInPip ? props.audioLevel > 0 : false,
|
||||
SPEAKING_LINGER_MS
|
||||
);
|
||||
const previousSharingScreen = usePrevious(sharingScreen, sharingScreen);
|
||||
|
||||
const isImageDataCached =
|
||||
sharingScreen && imageDataCache.current?.has(demuxId);
|
||||
const [hasReceivedVideoRecently, setHasReceivedVideoRecently] =
|
||||
useState(false);
|
||||
useState(isImageDataCached);
|
||||
const [isWide, setIsWide] = useState<boolean>(
|
||||
videoAspectRatio ? videoAspectRatio >= 1 : true
|
||||
);
|
||||
|
@ -132,6 +139,12 @@ export const GroupCallRemoteParticipant: React.FC<PropsType> = React.memo(
|
|||
onVisibilityChanged?.(demuxId, isVisible);
|
||||
}, [demuxId, isVisible, onVisibilityChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sharingScreen !== previousSharingScreen) {
|
||||
imageDataCache.current?.delete(demuxId);
|
||||
}
|
||||
}, [demuxId, imageDataCache, previousSharingScreen, sharingScreen]);
|
||||
|
||||
const wantsToShowVideo = hasRemoteVideo && !isBlocked && isVisible;
|
||||
const hasVideoToShow = wantsToShowVideo && hasReceivedVideoRecently;
|
||||
const showMissingMediaKeys = Boolean(
|
||||
|
@ -173,46 +186,74 @@ export const GroupCallRemoteParticipant: React.FC<PropsType> = React.memo(
|
|||
// This frame buffer is shared by all participants, so it may contain pixel data
|
||||
// for other participants, or pixel data from a previous frame. That's why we
|
||||
// return early and use the `frameWidth` and `frameHeight`.
|
||||
let frameWidth: number | undefined;
|
||||
let frameHeight: number | undefined;
|
||||
let imageData = imageDataRef.current;
|
||||
|
||||
const frameBuffer = getFrameBuffer();
|
||||
const frameDimensions = videoFrameSource.receiveVideoFrame(
|
||||
frameBuffer,
|
||||
MAX_FRAME_WIDTH,
|
||||
MAX_FRAME_HEIGHT
|
||||
);
|
||||
if (!frameDimensions) {
|
||||
return;
|
||||
if (frameDimensions) {
|
||||
[frameWidth, frameHeight] = frameDimensions;
|
||||
|
||||
if (
|
||||
frameWidth < 2 ||
|
||||
frameHeight < 2 ||
|
||||
frameWidth > MAX_FRAME_WIDTH ||
|
||||
frameHeight > MAX_FRAME_HEIGHT
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
imageData?.width !== frameWidth ||
|
||||
imageData?.height !== frameHeight
|
||||
) {
|
||||
imageData = new ImageData(frameWidth, frameHeight);
|
||||
imageDataRef.current = imageData;
|
||||
}
|
||||
imageData.data.set(
|
||||
frameBuffer.subarray(0, frameWidth * frameHeight * 4)
|
||||
);
|
||||
|
||||
// Screen share is at a slow FPS so updates slowly if we PiP then restore.
|
||||
// Cache the image data so we can quickly show the most recent frame.
|
||||
if (sharingScreen) {
|
||||
imageDataCache.current?.set(demuxId, imageData);
|
||||
}
|
||||
} else if (sharingScreen && !imageData) {
|
||||
// Try to use the screenshare cache the first time we show
|
||||
const cachedImageData = imageDataCache.current?.get(demuxId);
|
||||
if (cachedImageData) {
|
||||
frameWidth = cachedImageData.width;
|
||||
frameHeight = cachedImageData.height;
|
||||
imageDataRef.current = cachedImageData;
|
||||
imageData = cachedImageData;
|
||||
}
|
||||
}
|
||||
|
||||
const [frameWidth, frameHeight] = frameDimensions;
|
||||
|
||||
if (
|
||||
frameWidth < 2 ||
|
||||
frameHeight < 2 ||
|
||||
frameWidth > MAX_FRAME_WIDTH ||
|
||||
frameHeight > MAX_FRAME_HEIGHT
|
||||
) {
|
||||
if (!frameWidth || !frameHeight || !imageData) {
|
||||
return;
|
||||
}
|
||||
|
||||
canvasEl.width = frameWidth;
|
||||
canvasEl.height = frameHeight;
|
||||
|
||||
let imageData = imageDataRef.current;
|
||||
if (
|
||||
imageData?.width !== frameWidth ||
|
||||
imageData?.height !== frameHeight
|
||||
) {
|
||||
imageData = new ImageData(frameWidth, frameHeight);
|
||||
imageDataRef.current = imageData;
|
||||
}
|
||||
imageData.data.set(frameBuffer.subarray(0, frameWidth * frameHeight * 4));
|
||||
canvasContext.putImageData(imageData, 0, 0);
|
||||
|
||||
lastReceivedVideoAt.current = Date.now();
|
||||
|
||||
setHasReceivedVideoRecently(true);
|
||||
setIsWide(frameWidth > frameHeight);
|
||||
}, [getFrameBuffer, videoFrameSource, sharingScreen, isCallReconnecting]);
|
||||
}, [
|
||||
demuxId,
|
||||
imageDataCache,
|
||||
isCallReconnecting,
|
||||
sharingScreen,
|
||||
videoFrameSource,
|
||||
getFrameBuffer,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasRemoteVideo) {
|
||||
|
@ -304,8 +345,6 @@ export const GroupCallRemoteParticipant: React.FC<PropsType> = React.memo(
|
|||
}
|
||||
|
||||
let noVideoNode: ReactNode;
|
||||
let errorDialogTitle: ReactNode;
|
||||
let errorDialogBody = '';
|
||||
if (!hasVideoToShow) {
|
||||
const showDialogButton = (
|
||||
<button
|
||||
|
@ -322,21 +361,12 @@ export const GroupCallRemoteParticipant: React.FC<PropsType> = React.memo(
|
|||
noVideoNode = (
|
||||
<>
|
||||
<i className="module-ongoing-call__group-call-remote-participant__error-icon module-ongoing-call__group-call-remote-participant__error-icon--blocked" />
|
||||
<div className="module-ongoing-call__group-call-remote-participant__error">
|
||||
{i18n('icu:calling__blocked-participant', { name: title })}
|
||||
</div>
|
||||
{showDialogButton}
|
||||
</>
|
||||
);
|
||||
errorDialogTitle = (
|
||||
<div className="module-ongoing-call__group-call-remote-participant__more-info-modal-title">
|
||||
<Intl
|
||||
i18n={i18n}
|
||||
id="icu:calling__you-have-blocked"
|
||||
components={{
|
||||
name: <ContactName key="name" title={title} />,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
errorDialogBody = i18n('icu:calling__block-info');
|
||||
} else if (showMissingMediaKeys) {
|
||||
noVideoNode = (
|
||||
<>
|
||||
|
@ -347,23 +377,11 @@ export const GroupCallRemoteParticipant: React.FC<PropsType> = React.memo(
|
|||
{showDialogButton}
|
||||
</>
|
||||
);
|
||||
errorDialogTitle = (
|
||||
<div className="module-ongoing-call__group-call-remote-participant__more-info-modal-title">
|
||||
<Intl
|
||||
i18n={i18n}
|
||||
id="icu:calling__missing-media-keys"
|
||||
components={{
|
||||
name: <ContactName key="name" title={title} />,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
errorDialogBody = i18n('icu:calling__missing-media-keys-info');
|
||||
} else {
|
||||
noVideoNode = (
|
||||
<Avatar
|
||||
acceptedMessageRequest={acceptedMessageRequest}
|
||||
avatarPath={avatarPath}
|
||||
avatarUrl={avatarUrl}
|
||||
badge={undefined}
|
||||
color={color || AvatarColors[0]}
|
||||
noteToSelf={false}
|
||||
|
@ -379,6 +397,56 @@ export const GroupCallRemoteParticipant: React.FC<PropsType> = React.memo(
|
|||
}
|
||||
}
|
||||
|
||||
// Error dialog maintains state, so if you have it open and the underlying
|
||||
// error changes or resolves, you can keep reading the same dialog info.
|
||||
const [errorDialogTitle, setErrorDialogTitle] = useState<ReactNode | null>(
|
||||
null
|
||||
);
|
||||
const [errorDialogBody, setErrorDialogBody] = useState<string>('');
|
||||
useEffect(() => {
|
||||
if (hasVideoToShow || showErrorDialog) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isBlocked) {
|
||||
setErrorDialogTitle(
|
||||
<div className="module-ongoing-call__group-call-remote-participant__more-info-modal-title">
|
||||
<I18n
|
||||
i18n={i18n}
|
||||
id="icu:calling__block-info-title"
|
||||
components={{
|
||||
name: <ContactName key="name" title={title} />,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
setErrorDialogBody(i18n('icu:calling__block-info'));
|
||||
} else if (showMissingMediaKeys) {
|
||||
setErrorDialogTitle(
|
||||
<div className="module-ongoing-call__group-call-remote-participant__more-info-modal-title">
|
||||
<I18n
|
||||
i18n={i18n}
|
||||
id="icu:calling__missing-media-keys"
|
||||
components={{
|
||||
name: <ContactName key="name" title={title} />,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
setErrorDialogBody(i18n('icu:calling__missing-media-keys-info'));
|
||||
} else {
|
||||
setErrorDialogTitle(null);
|
||||
setErrorDialogBody('');
|
||||
}
|
||||
}, [
|
||||
hasVideoToShow,
|
||||
i18n,
|
||||
isBlocked,
|
||||
showErrorDialog,
|
||||
showMissingMediaKeys,
|
||||
title,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{showErrorDialog && (
|
||||
|
@ -436,11 +504,7 @@ export const GroupCallRemoteParticipant: React.FC<PropsType> = React.memo(
|
|||
ref={canvasEl => {
|
||||
remoteVideoRef.current = canvasEl;
|
||||
if (canvasEl) {
|
||||
canvasContextRef.current = canvasEl.getContext('2d', {
|
||||
alpha: false,
|
||||
desynchronized: true,
|
||||
storage: 'discardable',
|
||||
} as CanvasRenderingContext2DSettings);
|
||||
canvasContextRef.current = canvasEl.getContext('2d');
|
||||
} else {
|
||||
canvasContextRef.current = null;
|
||||
}
|
||||
|
@ -449,7 +513,7 @@ export const GroupCallRemoteParticipant: React.FC<PropsType> = React.memo(
|
|||
)}
|
||||
{noVideoNode && (
|
||||
<CallBackgroundBlur
|
||||
avatarPath={avatarPath}
|
||||
avatarUrl={isBlocked ? undefined : avatarUrl}
|
||||
className="module-ongoing-call__group-call-remote-participant-background"
|
||||
>
|
||||
{noVideoNode}
|
||||
|
|
|
@ -27,6 +27,7 @@ import * as log from '../logging/log';
|
|||
import { MAX_FRAME_HEIGHT, MAX_FRAME_WIDTH } from '../calling/constants';
|
||||
import { SizeObserver } from '../hooks/useSizeObserver';
|
||||
import { strictAssert } from '../util/assert';
|
||||
import type { CallingImageDataCache } from './CallManager';
|
||||
|
||||
const SMALL_TILES_MIN_HEIGHT = 80;
|
||||
const LARGE_TILES_MIN_HEIGHT = 200;
|
||||
|
@ -60,6 +61,7 @@ type PropsType = {
|
|||
callViewMode: CallViewMode;
|
||||
getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource;
|
||||
i18n: LocalizerType;
|
||||
imageDataCache: React.RefObject<CallingImageDataCache>;
|
||||
isCallReconnecting: boolean;
|
||||
remoteParticipants: ReadonlyArray<GroupCallRemoteParticipantType>;
|
||||
setGroupCallVideoRequest: (
|
||||
|
@ -110,6 +112,7 @@ enum VideoRequestMode {
|
|||
export function GroupCallRemoteParticipants({
|
||||
callViewMode,
|
||||
getGroupCallVideoFrameSource,
|
||||
imageDataCache,
|
||||
i18n,
|
||||
isCallReconnecting,
|
||||
remoteParticipants,
|
||||
|
@ -343,6 +346,7 @@ export function GroupCallRemoteParticipants({
|
|||
<GroupCallRemoteParticipant
|
||||
key={tile.demuxId}
|
||||
getFrameBuffer={getFrameBuffer}
|
||||
imageDataCache={imageDataCache}
|
||||
getGroupCallVideoFrameSource={getGroupCallVideoFrameSource}
|
||||
onClickRaisedHand={onClickRaisedHand}
|
||||
height={gridParticipantHeight}
|
||||
|
@ -510,6 +514,7 @@ export function GroupCallRemoteParticipants({
|
|||
<GroupCallOverflowArea
|
||||
getFrameBuffer={getFrameBuffer}
|
||||
getGroupCallVideoFrameSource={getGroupCallVideoFrameSource}
|
||||
imageDataCache={imageDataCache}
|
||||
i18n={i18n}
|
||||
isCallReconnecting={isCallReconnecting}
|
||||
onClickRaisedHand={onClickRaisedHand}
|
||||
|
@ -632,7 +637,7 @@ function stableParticipantComparator(
|
|||
}
|
||||
|
||||
type ParticipantsInPageType<
|
||||
T extends { videoAspectRatio: number } = ParticipantTileType
|
||||
T extends { videoAspectRatio: number } = ParticipantTileType,
|
||||
> = {
|
||||
rows: Array<Array<T>>;
|
||||
numParticipants: number;
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue