Merge branch 'main' into HEAD

This commit is contained in:
Scott Nonnenberg 2024-07-30 15:53:28 -07:00
commit fed6bbfc8b
1127 changed files with 263697 additions and 302446 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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}')`,
}}
/>
)}

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

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

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

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

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

View file

@ -0,0 +1,44 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import {
CallLinkRestrictions,
toCallLinkRestrictions,
} from '../types/CallLink';
import type { LocalizerType } from '../types/I18N';
import { Select } from './Select';
export type CallLinkRestrictionsSelectProps = Readonly<{
i18n: LocalizerType;
id?: string;
value: CallLinkRestrictions;
onChange: (value: CallLinkRestrictions) => void;
}>;
export function CallLinkRestrictionsSelect({
i18n,
id,
value,
onChange,
}: CallLinkRestrictionsSelectProps): JSX.Element {
return (
<Select
id={id}
value={String(value)}
moduleClassName="CallLinkRestrictionsSelect"
options={[
{
value: String(CallLinkRestrictions.None),
text: i18n('icu:CallLinkRestrictionsSelect__Option--Off'),
},
{
value: String(CallLinkRestrictions.AdminApproval),
text: i18n('icu:CallLinkRestrictionsSelect__Option--On'),
},
]}
onChange={nextValue => {
onChange(toCallLinkRestrictions(nextValue));
}}
/>
);
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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
@ -337,6 +762,8 @@ export function CallsList({
let statusText;
if (wasMissed) {
statusText = i18n('icu:CallsList__ItemCallInfo--Missed');
} else if (isAdhoc) {
statusText = i18n('icu:CallsList__ItemCallInfo--CallLink');
} else if (item.type === CallType.Group) {
statusText = i18n('icu:CallsList__ItemCallInfo--GroupCall');
} else if (item.direction === CallDirection.Outgoing) {
@ -347,10 +774,48 @@ 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,
@ -362,8 +827,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 +839,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 +853,53 @@ export function CallsList({
<span className="CallsList__ItemCallInfo">
{item.children.length > 1 ? `(${item.children.length}) ` : ''}
{statusText} &middot;{' '}
<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 +916,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 +941,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 +958,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 +967,7 @@ export function CallsList({
ref={infiniteLoaderRef}
isRowLoaded={isRowLoaded}
loadMoreRows={loadMoreRows}
rowCount={searchState.results?.count}
rowCount={rowCount}
minimumBatchSize={100}
threshold={30}
>
@ -512,13 +975,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}

View file

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

View file

@ -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 (
<>
@ -226,20 +263,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 && (
@ -249,7 +296,7 @@ export function CallsTab({
allConversations={allConversations}
i18n={i18n}
regionCode={regionCode}
onSelectConversation={handleSelectConversation}
onChangeCallsTabSelectedView={updateSelectedView}
onOutgoingAudioCallInConversation={
handleOutgoingAudioCallInConversation
}
@ -259,7 +306,7 @@ export function CallsTab({
/>
)}
</NavSidebar>
{selected == null ? (
{selectedView == null ? (
<div className="CallsTab__EmptyState">
<div className="CallsTab__EmptyStateIcon" />
<p className="CallsTab__EmptyStateLabel">
@ -269,13 +316,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>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

@ -38,7 +38,7 @@ const contactPillProps = (
): ContactPillPropsType => ({
...(overrideProps ??
getDefaultConversation({
avatarPath: gifUrl,
avatarUrl: gifUrl,
firstName: 'John',
id: 'abc123',
isMe: false,

View file

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

View file

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

View file

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

View file

@ -193,6 +193,7 @@ export function CustomizingPreferredReactionsModal({
onClose={() => {
deselectDraftEmoji();
}}
wasInvokedFromKeyboard={false}
/>
</div>
)}

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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