Migrate to private class properties/methods

This commit is contained in:
Jamie Kyle 2025-01-14 11:11:52 -08:00 committed by GitHub
parent 7dbe57084b
commit aa9f53df57
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
100 changed files with 3795 additions and 3944 deletions

View file

@ -34,9 +34,9 @@ type EmojiEntryType = Readonly<{
type SheetCacheEntry = Map<string, Uint8Array>; type SheetCacheEntry = Map<string, Uint8Array>;
export class EmojiService { export class EmojiService {
private readonly emojiMap = new Map<string, EmojiEntryType>(); readonly #emojiMap = new Map<string, EmojiEntryType>();
private readonly sheetCache = new LRUCache<string, SheetCacheEntry>({ readonly #sheetCache = new LRUCache<string, SheetCacheEntry>({
// Each sheet is roughly 500kb // Each sheet is roughly 500kb
max: 10, max: 10,
}); });
@ -52,12 +52,12 @@ export class EmojiService {
return new Response('invalid', { status: 400 }); return new Response('invalid', { status: 400 });
} }
return this.fetch(emoji); return this.#fetch(emoji);
}); });
for (const [sheet, emojiList] of Object.entries(manifest)) { for (const [sheet, emojiList] of Object.entries(manifest)) {
for (const utf16 of emojiList) { for (const utf16 of emojiList) {
this.emojiMap.set(utf16ToEmoji(utf16), { sheet, utf16 }); this.#emojiMap.set(utf16ToEmoji(utf16), { sheet, utf16 });
} }
} }
} }
@ -71,15 +71,15 @@ export class EmojiService {
return new EmojiService(resourceService, manifest); return new EmojiService(resourceService, manifest);
} }
private async fetch(emoji: string): Promise<Response> { async #fetch(emoji: string): Promise<Response> {
const entry = this.emojiMap.get(emoji); const entry = this.#emojiMap.get(emoji);
if (!entry) { if (!entry) {
return new Response('entry not found', { status: 404 }); return new Response('entry not found', { status: 404 });
} }
const { sheet, utf16 } = entry; const { sheet, utf16 } = entry;
let imageMap = this.sheetCache.get(sheet); let imageMap = this.#sheetCache.get(sheet);
if (!imageMap) { if (!imageMap) {
const proto = await this.resourceService.getData( const proto = await this.resourceService.getData(
`emoji-sheet-${sheet}.proto` `emoji-sheet-${sheet}.proto`
@ -96,7 +96,7 @@ export class EmojiService {
image || new Uint8Array(0), image || new Uint8Array(0),
]) ])
); );
this.sheetCache.set(sheet, imageMap); this.#sheetCache.set(sheet, imageMap);
} }
const image = imageMap.get(utf16); const image = imageMap.get(utf16);

View file

@ -29,22 +29,22 @@ const RESOURCES_DICT_PATH = join(
const MAX_CACHE_SIZE = 50 * 1024 * 1024; const MAX_CACHE_SIZE = 50 * 1024 * 1024;
export class OptionalResourceService { export class OptionalResourceService {
private maybeDeclaration: OptionalResourcesDictType | undefined; #maybeDeclaration: OptionalResourcesDictType | undefined;
private readonly cache = new LRUCache<string, Buffer>({ readonly #cache = new LRUCache<string, Buffer>({
maxSize: MAX_CACHE_SIZE, maxSize: MAX_CACHE_SIZE,
sizeCalculation: buf => buf.length, sizeCalculation: buf => buf.length,
}); });
private readonly fileQueues = new Map<string, PQueue>(); readonly #fileQueues = new Map<string, PQueue>();
private constructor(private readonly resourcesDir: string) { private constructor(private readonly resourcesDir: string) {
ipcMain.handle('OptionalResourceService:getData', (_event, name) => ipcMain.handle('OptionalResourceService:getData', (_event, name) =>
this.getData(name) this.getData(name)
); );
drop(this.lazyInit()); drop(this.#lazyInit());
} }
public static create(resourcesDir: string): OptionalResourceService { public static create(resourcesDir: string): OptionalResourceService {
@ -52,20 +52,20 @@ export class OptionalResourceService {
} }
public async getData(name: string): Promise<Buffer | undefined> { public async getData(name: string): Promise<Buffer | undefined> {
await this.lazyInit(); await this.#lazyInit();
const decl = this.declaration[name]; const decl = this.#declaration[name];
if (!decl) { if (!decl) {
return undefined; return undefined;
} }
const inMemory = this.cache.get(name); const inMemory = this.#cache.get(name);
if (inMemory) { if (inMemory) {
return inMemory; return inMemory;
} }
const filePath = join(this.resourcesDir, name); const filePath = join(this.resourcesDir, name);
return this.queueFileWork(filePath, async () => { return this.#queueFileWork(filePath, async () => {
try { try {
const onDisk = await readFile(filePath); const onDisk = await readFile(filePath);
const digest = createHash('sha512').update(onDisk).digest(); const digest = createHash('sha512').update(onDisk).digest();
@ -76,7 +76,7 @@ export class OptionalResourceService {
onDisk.length === decl.size onDisk.length === decl.size
) { ) {
log.warn(`OptionalResourceService: loaded ${name} from disk`); log.warn(`OptionalResourceService: loaded ${name} from disk`);
this.cache.set(name, onDisk); this.#cache.set(name, onDisk);
return onDisk; return onDisk;
} }
@ -94,7 +94,7 @@ export class OptionalResourceService {
// Just do our best effort and move forward // Just do our best effort and move forward
} }
return this.fetch(name, decl, filePath); return this.#fetch(name, decl, filePath);
}); });
} }
@ -102,15 +102,15 @@ export class OptionalResourceService {
// Private // Private
// //
private async lazyInit(): Promise<void> { async #lazyInit(): Promise<void> {
if (this.maybeDeclaration !== undefined) { if (this.#maybeDeclaration !== undefined) {
return; return;
} }
const json: unknown = JSON.parse( const json: unknown = JSON.parse(
await readFile(RESOURCES_DICT_PATH, 'utf8') await readFile(RESOURCES_DICT_PATH, 'utf8')
); );
this.maybeDeclaration = parseUnknown(OptionalResourcesDictSchema, json); this.#maybeDeclaration = parseUnknown(OptionalResourcesDictSchema, json);
// Clean unknown resources // Clean unknown resources
let subPaths: Array<string>; let subPaths: Array<string>;
@ -126,7 +126,7 @@ export class OptionalResourceService {
await Promise.all( await Promise.all(
subPaths.map(async subPath => { subPaths.map(async subPath => {
if (this.declaration[subPath]) { if (this.#declaration[subPath]) {
return; return;
} }
@ -144,39 +144,39 @@ export class OptionalResourceService {
); );
} }
private get declaration(): OptionalResourcesDictType { get #declaration(): OptionalResourcesDictType {
if (this.maybeDeclaration === undefined) { if (this.#maybeDeclaration === undefined) {
throw new Error('optional-resources.json not loaded yet'); throw new Error('optional-resources.json not loaded yet');
} }
return this.maybeDeclaration; return this.#maybeDeclaration;
} }
private async queueFileWork<R>( async #queueFileWork<R>(
filePath: string, filePath: string,
body: () => Promise<R> body: () => Promise<R>
): Promise<R> { ): Promise<R> {
let queue = this.fileQueues.get(filePath); let queue = this.#fileQueues.get(filePath);
if (!queue) { if (!queue) {
queue = new PQueue({ concurrency: 1 }); queue = new PQueue({ concurrency: 1 });
this.fileQueues.set(filePath, queue); this.#fileQueues.set(filePath, queue);
} }
try { try {
return await queue.add(body); return await queue.add(body);
} finally { } finally {
if (queue.size === 0) { if (queue.size === 0) {
this.fileQueues.delete(filePath); this.#fileQueues.delete(filePath);
} }
} }
} }
private async fetch( async #fetch(
name: string, name: string,
decl: OptionalResourceType, decl: OptionalResourceType,
destPath: string destPath: string
): Promise<Buffer> { ): Promise<Buffer> {
const result = await got(decl.url, await getGotOptions()).buffer(); const result = await got(decl.url, await getGotOptions()).buffer();
this.cache.set(name, result); this.#cache.set(name, result);
try { try {
await mkdir(dirname(destPath), { recursive: true }); await mkdir(dirname(destPath), { recursive: true });

View file

@ -17,20 +17,20 @@ export class PreventDisplaySleepService {
); );
if (isEnabled) { if (isEnabled) {
this.enable(); this.#enable();
} else { } else {
this.disable(); this.#disable();
} }
} }
private enable(): void { #enable(): void {
if (this.blockerId !== undefined) { if (this.blockerId !== undefined) {
return; return;
} }
this.blockerId = this.powerSaveBlocker.start('prevent-display-sleep'); this.blockerId = this.powerSaveBlocker.start('prevent-display-sleep');
} }
private disable(): void { #disable(): void {
if (this.blockerId === undefined) { if (this.blockerId === undefined) {
return; return;
} }

View file

@ -24,29 +24,20 @@ export type SystemTrayServiceOptionsType = Readonly<{
* [0]: https://www.electronjs.org/docs/api/tray * [0]: https://www.electronjs.org/docs/api/tray
*/ */
export class SystemTrayService { export class SystemTrayService {
private browserWindow?: BrowserWindow; #browserWindow?: BrowserWindow;
readonly #i18n: LocalizerType;
private readonly i18n: LocalizerType; #tray?: Tray;
#isEnabled = false;
private tray?: Tray; #isQuitting = false;
#unreadCount = 0;
private isEnabled = false; #createTrayInstance: (icon: NativeImage) => Tray;
private isQuitting = false;
private unreadCount = 0;
private boundRender: typeof SystemTrayService.prototype.render;
private createTrayInstance: (icon: NativeImage) => Tray;
constructor({ i18n, createTrayInstance }: SystemTrayServiceOptionsType) { constructor({ i18n, createTrayInstance }: SystemTrayServiceOptionsType) {
log.info('System tray service: created'); log.info('System tray service: created');
this.i18n = i18n; this.#i18n = i18n;
this.boundRender = this.render.bind(this); this.#createTrayInstance = createTrayInstance || (icon => new Tray(icon));
this.createTrayInstance = createTrayInstance || (icon => new Tray(icon));
nativeTheme.on('updated', this.boundRender); nativeTheme.on('updated', this.#render);
} }
/** /**
@ -55,7 +46,7 @@ export class SystemTrayService {
* toggle in the tray's context menu. * toggle in the tray's context menu.
*/ */
setMainWindow(newBrowserWindow: undefined | BrowserWindow): void { setMainWindow(newBrowserWindow: undefined | BrowserWindow): void {
const oldBrowserWindow = this.browserWindow; const oldBrowserWindow = this.#browserWindow;
if (oldBrowserWindow === newBrowserWindow) { if (oldBrowserWindow === newBrowserWindow) {
return; return;
} }
@ -67,18 +58,18 @@ export class SystemTrayService {
); );
if (oldBrowserWindow) { if (oldBrowserWindow) {
oldBrowserWindow.off('show', this.boundRender); oldBrowserWindow.off('show', this.#render);
oldBrowserWindow.off('hide', this.boundRender); oldBrowserWindow.off('hide', this.#render);
} }
if (newBrowserWindow) { if (newBrowserWindow) {
newBrowserWindow.on('show', this.boundRender); newBrowserWindow.on('show', this.#render);
newBrowserWindow.on('hide', this.boundRender); newBrowserWindow.on('hide', this.#render);
} }
this.browserWindow = newBrowserWindow; this.#browserWindow = newBrowserWindow;
this.render(); this.#render();
} }
/** /**
@ -86,27 +77,27 @@ export class SystemTrayService {
* `setMainWindow`), the tray icon will not be shown, even if enabled. * `setMainWindow`), the tray icon will not be shown, even if enabled.
*/ */
setEnabled(isEnabled: boolean): void { setEnabled(isEnabled: boolean): void {
if (this.isEnabled === isEnabled) { if (this.#isEnabled === isEnabled) {
return; return;
} }
log.info(`System tray service: ${isEnabled ? 'enabling' : 'disabling'}`); log.info(`System tray service: ${isEnabled ? 'enabling' : 'disabling'}`);
this.isEnabled = isEnabled; this.#isEnabled = isEnabled;
this.render(); this.#render();
} }
/** /**
* Update the unread count, which updates the tray icon if it's visible. * Update the unread count, which updates the tray icon if it's visible.
*/ */
setUnreadCount(unreadCount: number): void { setUnreadCount(unreadCount: number): void {
if (this.unreadCount === unreadCount) { if (this.#unreadCount === unreadCount) {
return; return;
} }
log.info(`System tray service: setting unread count to ${unreadCount}`); log.info(`System tray service: setting unread count to ${unreadCount}`);
this.unreadCount = unreadCount; this.#unreadCount = unreadCount;
this.render(); this.#render();
} }
/** /**
@ -118,35 +109,36 @@ export class SystemTrayService {
markShouldQuit(): void { markShouldQuit(): void {
log.info('System tray service: markShouldQuit'); log.info('System tray service: markShouldQuit');
this.tray = undefined; this.#tray = undefined;
this.isQuitting = true; this.#isQuitting = true;
} }
isVisible(): boolean { isVisible(): boolean {
return this.tray !== undefined; return this.#tray !== undefined;
} }
private render(): void { #render = (): void => {
if (this.isEnabled && this.browserWindow) { if (this.#isEnabled && this.#browserWindow) {
this.renderEnabled(); this.#renderEnabled();
return; return;
} }
this.renderDisabled(); this.#renderDisabled();
} };
private renderEnabled() { #renderEnabled() {
if (this.isQuitting) { if (this.#isQuitting) {
log.info('System tray service: not rendering the tray, quitting'); log.info('System tray service: not rendering the tray, quitting');
return; return;
} }
log.info('System tray service: rendering the tray'); log.info('System tray service: rendering the tray');
this.tray ??= this.createTray(); this.#tray ??= this.#createTray();
const { browserWindow, tray } = this; const tray = this.#tray;
const browserWindow = this.#browserWindow;
try { try {
tray.setImage(getIcon(this.unreadCount)); tray.setImage(getIcon(this.#unreadCount));
} catch (err: unknown) { } catch (err: unknown) {
log.warn( log.warn(
'System tray service: failed to set preferred image. Falling back...' 'System tray service: failed to set preferred image. Falling back...'
@ -164,7 +156,7 @@ export class SystemTrayService {
id: 'toggleWindowVisibility', id: 'toggleWindowVisibility',
...(browserWindow?.isVisible() ...(browserWindow?.isVisible()
? { ? {
label: this.i18n('icu:hide'), label: this.#i18n('icu:hide'),
click: () => { click: () => {
log.info( log.info(
'System tray service: hiding the window from the context menu' 'System tray service: hiding the window from the context menu'
@ -172,25 +164,25 @@ export class SystemTrayService {
// We re-fetch `this.browserWindow` here just in case the browser window // We re-fetch `this.browserWindow` here just in case the browser window
// has changed while the context menu was open. Same applies in the // has changed while the context menu was open. Same applies in the
// "show" case below. // "show" case below.
this.browserWindow?.hide(); this.#browserWindow?.hide();
}, },
} }
: { : {
label: this.i18n('icu:show'), label: this.#i18n('icu:show'),
click: () => { click: () => {
log.info( log.info(
'System tray service: showing the window from the context menu' 'System tray service: showing the window from the context menu'
); );
if (this.browserWindow) { if (this.#browserWindow) {
this.browserWindow.show(); this.#browserWindow.show();
focusAndForceToTop(this.browserWindow); focusAndForceToTop(this.#browserWindow);
} }
}, },
}), }),
}, },
{ {
id: 'quit', id: 'quit',
label: this.i18n('icu:quit'), label: this.#i18n('icu:quit'),
click: () => { click: () => {
log.info( log.info(
'System tray service: quitting the app from the context menu' 'System tray service: quitting the app from the context menu'
@ -202,21 +194,21 @@ export class SystemTrayService {
); );
} }
private renderDisabled() { #renderDisabled() {
log.info('System tray service: rendering no tray'); log.info('System tray service: rendering no tray');
if (!this.tray) { if (!this.#tray) {
return; return;
} }
this.tray.destroy(); this.#tray.destroy();
this.tray = undefined; this.#tray = undefined;
} }
private createTray(): Tray { #createTray(): Tray {
log.info('System tray service: creating the tray'); log.info('System tray service: creating the tray');
// This icon may be swiftly overwritten. // This icon may be swiftly overwritten.
const result = this.createTrayInstance(getDefaultIcon()); const result = this.#createTrayInstance(getDefaultIcon());
// Note: "When app indicator is used on Linux, the click event is ignored." This // Note: "When app indicator is used on Linux, the click event is ignored." This
// doesn't mean that the click event is always ignored on Linux; it depends on how // doesn't mean that the click event is always ignored on Linux; it depends on how
@ -224,7 +216,7 @@ export class SystemTrayService {
// //
// See <https://github.com/electron/electron/blob/v13.1.3/docs/api/tray.md#class-tray>. // See <https://github.com/electron/electron/blob/v13.1.3/docs/api/tray.md#class-tray>.
result.on('click', () => { result.on('click', () => {
const { browserWindow } = this; const browserWindow = this.#browserWindow;
if (!browserWindow) { if (!browserWindow) {
return; return;
} }
@ -236,7 +228,7 @@ export class SystemTrayService {
} }
}); });
result.setToolTip(this.i18n('icu:signalDesktop')); result.setToolTip(this.#i18n('icu:signalDesktop'));
return result; return result;
} }
@ -246,7 +238,7 @@ export class SystemTrayService {
* into the existing tray instances. It should not be used by "real" code. * into the existing tray instances. It should not be used by "real" code.
*/ */
_getTray(): undefined | Tray { _getTray(): undefined | Tray {
return this.tray; return this.#tray;
} }
} }

View file

@ -15,9 +15,8 @@ import type { ConfigType } from './base_config';
* process. * process.
*/ */
export class SystemTraySettingCache { export class SystemTraySettingCache {
private cachedValue: undefined | SystemTraySetting; #cachedValue: undefined | SystemTraySetting;
#getPromise: undefined | Promise<SystemTraySetting>;
private getPromise: undefined | Promise<SystemTraySetting>;
constructor( constructor(
private readonly ephemeralConfig: Pick<ConfigType, 'get' | 'set'>, private readonly ephemeralConfig: Pick<ConfigType, 'get' | 'set'>,
@ -25,19 +24,19 @@ export class SystemTraySettingCache {
) {} ) {}
async get(): Promise<SystemTraySetting> { async get(): Promise<SystemTraySetting> {
if (this.cachedValue !== undefined) { if (this.#cachedValue !== undefined) {
return this.cachedValue; return this.#cachedValue;
} }
this.getPromise = this.getPromise || this.doFirstGet(); this.#getPromise = this.#getPromise || this.#doFirstGet();
return this.getPromise; return this.#getPromise;
} }
set(value: SystemTraySetting): void { set(value: SystemTraySetting): void {
this.cachedValue = value; this.#cachedValue = value;
} }
private async doFirstGet(): Promise<SystemTraySetting> { async #doFirstGet(): Promise<SystemTraySetting> {
let result: SystemTraySetting; let result: SystemTraySetting;
// These command line flags are not officially supported, but many users rely on them. // These command line flags are not officially supported, but many users rely on them.
@ -76,15 +75,15 @@ export class SystemTraySettingCache {
); );
} }
return this.updateCachedValue(result); return this.#updateCachedValue(result);
} }
private updateCachedValue(value: SystemTraySetting): SystemTraySetting { #updateCachedValue(value: SystemTraySetting): SystemTraySetting {
// If there's a value in the cache, someone has updated the value "out from under us", // If there's a value in the cache, someone has updated the value "out from under us",
// so we should return that because it's newer. // so we should return that because it's newer.
this.cachedValue = this.#cachedValue =
this.cachedValue === undefined ? value : this.cachedValue; this.#cachedValue === undefined ? value : this.#cachedValue;
return this.cachedValue; return this.#cachedValue;
} }
} }

View file

@ -149,17 +149,14 @@ export function start(): void {
} }
export class ConversationController { export class ConversationController {
private _initialFetchComplete = false; #_initialFetchComplete = false;
private _initialPromise: undefined | Promise<void>; private _initialPromise: undefined | Promise<void>;
private _conversationOpenStart = new Map<string, number>(); #_conversationOpenStart = new Map<string, number>();
#_hasQueueEmptied = false;
private _hasQueueEmptied = false; #_combineConversationsQueue = new PQueue({ concurrency: 1 });
#_signalConversationId: undefined | string;
private _combineConversationsQueue = new PQueue({ concurrency: 1 });
private _signalConversationId: undefined | string;
constructor(private _conversations: ConversationModelCollectionType) { constructor(private _conversations: ConversationModelCollectionType) {
const debouncedUpdateUnreadCount = debounce( const debouncedUpdateUnreadCount = debounce(
@ -192,7 +189,7 @@ export class ConversationController {
} }
updateUnreadCount(): void { updateUnreadCount(): void {
if (!this._hasQueueEmptied) { if (!this.#_hasQueueEmptied) {
return; return;
} }
@ -238,12 +235,12 @@ export class ConversationController {
} }
onEmpty(): void { onEmpty(): void {
this._hasQueueEmptied = true; this.#_hasQueueEmptied = true;
this.updateUnreadCount(); this.updateUnreadCount();
} }
get(id?: string | null): ConversationModel | undefined { get(id?: string | null): ConversationModel | undefined {
if (!this._initialFetchComplete) { if (!this.#_initialFetchComplete) {
throw new Error( throw new Error(
'ConversationController.get() needs complete initial fetch' 'ConversationController.get() needs complete initial fetch'
); );
@ -283,7 +280,7 @@ export class ConversationController {
); );
} }
if (!this._initialFetchComplete) { if (!this.#_initialFetchComplete) {
throw new Error( throw new Error(
'ConversationController.get() needs complete initial fetch' 'ConversationController.get() needs complete initial fetch'
); );
@ -460,13 +457,13 @@ export class ConversationController {
await updateConversation(conversation.attributes); await updateConversation(conversation.attributes);
} }
this._signalConversationId = conversation.id; this.#_signalConversationId = conversation.id;
return conversation; return conversation;
} }
isSignalConversationId(conversationId: string): boolean { isSignalConversationId(conversationId: string): boolean {
return this._signalConversationId === conversationId; return this.#_signalConversationId === conversationId;
} }
areWePrimaryDevice(): boolean { areWePrimaryDevice(): boolean {
@ -841,14 +838,14 @@ export class ConversationController {
} }
checkForConflicts(): Promise<void> { checkForConflicts(): Promise<void> {
return this._combineConversationsQueue.add(() => return this.#_combineConversationsQueue.add(() =>
this.doCheckForConflicts() this.#doCheckForConflicts()
); );
} }
// Note: `doCombineConversations` is directly used within this function since both // Note: `doCombineConversations` is directly used within this function since both
// run on `_combineConversationsQueue` queue and we don't want deadlocks. // run on `_combineConversationsQueue` queue and we don't want deadlocks.
private async doCheckForConflicts(): Promise<void> { async #doCheckForConflicts(): Promise<void> {
log.info('ConversationController.checkForConflicts: starting...'); log.info('ConversationController.checkForConflicts: starting...');
const byServiceId = Object.create(null); const byServiceId = Object.create(null);
const byE164 = Object.create(null); const byE164 = Object.create(null);
@ -884,7 +881,7 @@ export class ConversationController {
if (conversation.get('e164')) { if (conversation.get('e164')) {
// Keep new one // Keep new one
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
await this.doCombineConversations({ await this.#doCombineConversations({
current: conversation, current: conversation,
obsolete: existing, obsolete: existing,
}); });
@ -892,7 +889,7 @@ export class ConversationController {
} else { } else {
// Keep existing - note that this applies if neither had an e164 // Keep existing - note that this applies if neither had an e164
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
await this.doCombineConversations({ await this.#doCombineConversations({
current: existing, current: existing,
obsolete: conversation, obsolete: conversation,
}); });
@ -918,7 +915,7 @@ export class ConversationController {
if (conversation.get('e164') || conversation.getPni()) { if (conversation.get('e164') || conversation.getPni()) {
// Keep new one // Keep new one
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
await this.doCombineConversations({ await this.#doCombineConversations({
current: conversation, current: conversation,
obsolete: existing, obsolete: existing,
}); });
@ -926,7 +923,7 @@ export class ConversationController {
} else { } else {
// Keep existing - note that this applies if neither had an e164 // Keep existing - note that this applies if neither had an e164
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
await this.doCombineConversations({ await this.#doCombineConversations({
current: existing, current: existing,
obsolete: conversation, obsolete: conversation,
}); });
@ -964,7 +961,7 @@ export class ConversationController {
if (conversation.getServiceId()) { if (conversation.getServiceId()) {
// Keep new one // Keep new one
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
await this.doCombineConversations({ await this.#doCombineConversations({
current: conversation, current: conversation,
obsolete: existing, obsolete: existing,
}); });
@ -972,7 +969,7 @@ export class ConversationController {
} else { } else {
// Keep existing - note that this applies if neither had a service id // Keep existing - note that this applies if neither had a service id
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
await this.doCombineConversations({ await this.#doCombineConversations({
current: existing, current: existing,
obsolete: conversation, obsolete: conversation,
}); });
@ -1010,14 +1007,14 @@ export class ConversationController {
!isGroupV2(existing.attributes) !isGroupV2(existing.attributes)
) { ) {
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
await this.doCombineConversations({ await this.#doCombineConversations({
current: conversation, current: conversation,
obsolete: existing, obsolete: existing,
}); });
byGroupV2Id[groupV2Id] = conversation; byGroupV2Id[groupV2Id] = conversation;
} else { } else {
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
await this.doCombineConversations({ await this.#doCombineConversations({
current: existing, current: existing,
obsolete: conversation, obsolete: conversation,
}); });
@ -1032,12 +1029,12 @@ export class ConversationController {
async combineConversations( async combineConversations(
options: CombineConversationsParams options: CombineConversationsParams
): Promise<void> { ): Promise<void> {
return this._combineConversationsQueue.add(() => return this.#_combineConversationsQueue.add(() =>
this.doCombineConversations(options) this.#doCombineConversations(options)
); );
} }
private async doCombineConversations({ async #doCombineConversations({
current, current,
obsolete, obsolete,
obsoleteTitleInfo, obsoleteTitleInfo,
@ -1304,12 +1301,12 @@ export class ConversationController {
reset(): void { reset(): void {
delete this._initialPromise; delete this._initialPromise;
this._initialFetchComplete = false; this.#_initialFetchComplete = false;
this._conversations.reset([]); this._conversations.reset([]);
} }
load(): Promise<void> { load(): Promise<void> {
this._initialPromise ||= this.doLoad(); this._initialPromise ||= this.#doLoad();
return this._initialPromise; return this._initialPromise;
} }
@ -1346,16 +1343,16 @@ export class ConversationController {
} }
onConvoOpenStart(conversationId: string): void { onConvoOpenStart(conversationId: string): void {
this._conversationOpenStart.set(conversationId, Date.now()); this.#_conversationOpenStart.set(conversationId, Date.now());
} }
onConvoMessageMount(conversationId: string): void { onConvoMessageMount(conversationId: string): void {
const loadStart = this._conversationOpenStart.get(conversationId); const loadStart = this.#_conversationOpenStart.get(conversationId);
if (loadStart === undefined) { if (loadStart === undefined) {
return; return;
} }
this._conversationOpenStart.delete(conversationId); this.#_conversationOpenStart.delete(conversationId);
this.get(conversationId)?.onOpenComplete(loadStart); this.get(conversationId)?.onOpenComplete(loadStart);
} }
@ -1424,7 +1421,7 @@ export class ConversationController {
} }
} }
private async doLoad(): Promise<void> { async #doLoad(): Promise<void> {
log.info('ConversationController: starting initial fetch'); log.info('ConversationController: starting initial fetch');
if (this._conversations.length) { if (this._conversations.length) {
@ -1460,7 +1457,7 @@ export class ConversationController {
// It is alright to call it first because the 'add'/'update' events are // It is alright to call it first because the 'add'/'update' events are
// triggered after updating the collection. // triggered after updating the collection.
this._initialFetchComplete = true; this.#_initialFetchComplete = true;
// Hydrate the final set of conversations // Hydrate the final set of conversations
batchDispatch(() => { batchDispatch(() => {

View file

@ -14,7 +14,7 @@ export class IdleDetector extends EventEmitter {
public start(): void { public start(): void {
log.info('Start idle detector'); log.info('Start idle detector');
this.scheduleNextCallback(); this.#scheduleNextCallback();
} }
public stop(): void { public stop(): void {
@ -23,10 +23,10 @@ export class IdleDetector extends EventEmitter {
} }
log.info('Stop idle detector'); log.info('Stop idle detector');
this.clearScheduledCallbacks(); this.#clearScheduledCallbacks();
} }
private clearScheduledCallbacks() { #clearScheduledCallbacks() {
if (this.handle) { if (this.handle) {
cancelIdleCallback(this.handle); cancelIdleCallback(this.handle);
delete this.handle; delete this.handle;
@ -36,14 +36,14 @@ export class IdleDetector extends EventEmitter {
delete this.timeoutId; delete this.timeoutId;
} }
private scheduleNextCallback() { #scheduleNextCallback() {
this.clearScheduledCallbacks(); this.#clearScheduledCallbacks();
this.handle = window.requestIdleCallback(deadline => { this.handle = window.requestIdleCallback(deadline => {
const { didTimeout } = deadline; const { didTimeout } = deadline;
const timeRemaining = deadline.timeRemaining(); const timeRemaining = deadline.timeRemaining();
const isIdle = timeRemaining >= IDLE_THRESHOLD_MS; const isIdle = timeRemaining >= IDLE_THRESHOLD_MS;
this.timeoutId = setTimeout( this.timeoutId = setTimeout(
() => this.scheduleNextCallback(), () => this.#scheduleNextCallback(),
POLL_INTERVAL_MS POLL_INTERVAL_MS
); );
if (isIdle || didTimeout) { if (isIdle || didTimeout) {

View file

@ -51,15 +51,14 @@ export type SessionsOptions = Readonly<{
}>; }>;
export class Sessions extends SessionStore { export class Sessions extends SessionStore {
private readonly ourServiceId: ServiceIdString; readonly #ourServiceId: ServiceIdString;
readonly #zone: Zone | undefined;
private readonly zone: Zone | undefined;
constructor({ ourServiceId, zone }: SessionsOptions) { constructor({ ourServiceId, zone }: SessionsOptions) {
super(); super();
this.ourServiceId = ourServiceId; this.#ourServiceId = ourServiceId;
this.zone = zone; this.#zone = zone;
} }
async saveSession( async saveSession(
@ -67,17 +66,17 @@ export class Sessions extends SessionStore {
record: SessionRecord record: SessionRecord
): Promise<void> { ): Promise<void> {
await window.textsecure.storage.protocol.storeSession( await window.textsecure.storage.protocol.storeSession(
toQualifiedAddress(this.ourServiceId, address), toQualifiedAddress(this.#ourServiceId, address),
record, record,
{ zone: this.zone } { zone: this.#zone }
); );
} }
async getSession(name: ProtocolAddress): Promise<SessionRecord | null> { async getSession(name: ProtocolAddress): Promise<SessionRecord | null> {
const encodedAddress = toQualifiedAddress(this.ourServiceId, name); const encodedAddress = toQualifiedAddress(this.#ourServiceId, name);
const record = await window.textsecure.storage.protocol.loadSession( const record = await window.textsecure.storage.protocol.loadSession(
encodedAddress, encodedAddress,
{ zone: this.zone } { zone: this.#zone }
); );
return record || null; return record || null;
@ -87,10 +86,10 @@ export class Sessions extends SessionStore {
addresses: Array<ProtocolAddress> addresses: Array<ProtocolAddress>
): Promise<Array<SessionRecord>> { ): Promise<Array<SessionRecord>> {
const encodedAddresses = addresses.map(addr => const encodedAddresses = addresses.map(addr =>
toQualifiedAddress(this.ourServiceId, addr) toQualifiedAddress(this.#ourServiceId, addr)
); );
return window.textsecure.storage.protocol.loadSessions(encodedAddresses, { return window.textsecure.storage.protocol.loadSessions(encodedAddresses, {
zone: this.zone, zone: this.#zone,
}); });
} }
} }
@ -101,20 +100,19 @@ export type IdentityKeysOptions = Readonly<{
}>; }>;
export class IdentityKeys extends IdentityKeyStore { export class IdentityKeys extends IdentityKeyStore {
private readonly ourServiceId: ServiceIdString; readonly #ourServiceId: ServiceIdString;
readonly #zone: Zone | undefined;
private readonly zone: Zone | undefined;
constructor({ ourServiceId, zone }: IdentityKeysOptions) { constructor({ ourServiceId, zone }: IdentityKeysOptions) {
super(); super();
this.ourServiceId = ourServiceId; this.#ourServiceId = ourServiceId;
this.zone = zone; this.#zone = zone;
} }
async getIdentityKey(): Promise<PrivateKey> { async getIdentityKey(): Promise<PrivateKey> {
const keyPair = window.textsecure.storage.protocol.getIdentityKeyPair( const keyPair = window.textsecure.storage.protocol.getIdentityKeyPair(
this.ourServiceId this.#ourServiceId
); );
if (!keyPair) { if (!keyPair) {
throw new Error('IdentityKeyStore/getIdentityKey: No identity key!'); throw new Error('IdentityKeyStore/getIdentityKey: No identity key!');
@ -124,7 +122,7 @@ export class IdentityKeys extends IdentityKeyStore {
async getLocalRegistrationId(): Promise<number> { async getLocalRegistrationId(): Promise<number> {
const id = await window.textsecure.storage.protocol.getLocalRegistrationId( const id = await window.textsecure.storage.protocol.getLocalRegistrationId(
this.ourServiceId this.#ourServiceId
); );
if (!isNumber(id)) { if (!isNumber(id)) {
throw new Error( throw new Error(
@ -157,7 +155,7 @@ export class IdentityKeys extends IdentityKeyStore {
encodedAddress, encodedAddress,
publicKey, publicKey,
false, false,
{ zone: this.zone } { zone: this.#zone }
); );
} }
@ -182,11 +180,11 @@ export type PreKeysOptions = Readonly<{
}>; }>;
export class PreKeys extends PreKeyStore { export class PreKeys extends PreKeyStore {
private readonly ourServiceId: ServiceIdString; readonly #ourServiceId: ServiceIdString;
constructor({ ourServiceId }: PreKeysOptions) { constructor({ ourServiceId }: PreKeysOptions) {
super(); super();
this.ourServiceId = ourServiceId; this.#ourServiceId = ourServiceId;
} }
async savePreKey(): Promise<void> { async savePreKey(): Promise<void> {
@ -195,7 +193,7 @@ export class PreKeys extends PreKeyStore {
async getPreKey(id: number): Promise<PreKeyRecord> { async getPreKey(id: number): Promise<PreKeyRecord> {
const preKey = await window.textsecure.storage.protocol.loadPreKey( const preKey = await window.textsecure.storage.protocol.loadPreKey(
this.ourServiceId, this.#ourServiceId,
id id
); );
@ -207,18 +205,18 @@ export class PreKeys extends PreKeyStore {
} }
async removePreKey(id: number): Promise<void> { async removePreKey(id: number): Promise<void> {
await window.textsecure.storage.protocol.removePreKeys(this.ourServiceId, [ await window.textsecure.storage.protocol.removePreKeys(this.#ourServiceId, [
id, id,
]); ]);
} }
} }
export class KyberPreKeys extends KyberPreKeyStore { export class KyberPreKeys extends KyberPreKeyStore {
private readonly ourServiceId: ServiceIdString; readonly #ourServiceId: ServiceIdString;
constructor({ ourServiceId }: PreKeysOptions) { constructor({ ourServiceId }: PreKeysOptions) {
super(); super();
this.ourServiceId = ourServiceId; this.#ourServiceId = ourServiceId;
} }
async saveKyberPreKey(): Promise<void> { async saveKyberPreKey(): Promise<void> {
@ -228,7 +226,7 @@ export class KyberPreKeys extends KyberPreKeyStore {
async getKyberPreKey(id: number): Promise<KyberPreKeyRecord> { async getKyberPreKey(id: number): Promise<KyberPreKeyRecord> {
const kyberPreKey = const kyberPreKey =
await window.textsecure.storage.protocol.loadKyberPreKey( await window.textsecure.storage.protocol.loadKyberPreKey(
this.ourServiceId, this.#ourServiceId,
id id
); );
@ -241,7 +239,7 @@ export class KyberPreKeys extends KyberPreKeyStore {
async markKyberPreKeyUsed(id: number): Promise<void> { async markKyberPreKeyUsed(id: number): Promise<void> {
await window.textsecure.storage.protocol.maybeRemoveKyberPreKey( await window.textsecure.storage.protocol.maybeRemoveKyberPreKey(
this.ourServiceId, this.#ourServiceId,
id id
); );
} }
@ -253,13 +251,13 @@ export type SenderKeysOptions = Readonly<{
}>; }>;
export class SenderKeys extends SenderKeyStore { export class SenderKeys extends SenderKeyStore {
private readonly ourServiceId: ServiceIdString; readonly #ourServiceId: ServiceIdString;
readonly zone: Zone | undefined; readonly zone: Zone | undefined;
constructor({ ourServiceId, zone }: SenderKeysOptions) { constructor({ ourServiceId, zone }: SenderKeysOptions) {
super(); super();
this.ourServiceId = ourServiceId; this.#ourServiceId = ourServiceId;
this.zone = zone; this.zone = zone;
} }
@ -268,7 +266,7 @@ export class SenderKeys extends SenderKeyStore {
distributionId: Uuid, distributionId: Uuid,
record: SenderKeyRecord record: SenderKeyRecord
): Promise<void> { ): Promise<void> {
const encodedAddress = toQualifiedAddress(this.ourServiceId, sender); const encodedAddress = toQualifiedAddress(this.#ourServiceId, sender);
await window.textsecure.storage.protocol.saveSenderKey( await window.textsecure.storage.protocol.saveSenderKey(
encodedAddress, encodedAddress,
@ -282,7 +280,7 @@ export class SenderKeys extends SenderKeyStore {
sender: ProtocolAddress, sender: ProtocolAddress,
distributionId: Uuid distributionId: Uuid
): Promise<SenderKeyRecord | null> { ): Promise<SenderKeyRecord | null> {
const encodedAddress = toQualifiedAddress(this.ourServiceId, sender); const encodedAddress = toQualifiedAddress(this.#ourServiceId, sender);
const senderKey = await window.textsecure.storage.protocol.getSenderKey( const senderKey = await window.textsecure.storage.protocol.getSenderKey(
encodedAddress, encodedAddress,
@ -299,11 +297,11 @@ export type SignedPreKeysOptions = Readonly<{
}>; }>;
export class SignedPreKeys extends SignedPreKeyStore { export class SignedPreKeys extends SignedPreKeyStore {
private readonly ourServiceId: ServiceIdString; readonly #ourServiceId: ServiceIdString;
constructor({ ourServiceId }: SignedPreKeysOptions) { constructor({ ourServiceId }: SignedPreKeysOptions) {
super(); super();
this.ourServiceId = ourServiceId; this.#ourServiceId = ourServiceId;
} }
async saveSignedPreKey(): Promise<void> { async saveSignedPreKey(): Promise<void> {
@ -313,7 +311,7 @@ export class SignedPreKeys extends SignedPreKeyStore {
async getSignedPreKey(id: number): Promise<SignedPreKeyRecord> { async getSignedPreKey(id: number): Promise<SignedPreKeyRecord> {
const signedPreKey = const signedPreKey =
await window.textsecure.storage.protocol.loadSignedPreKey( await window.textsecure.storage.protocol.loadSignedPreKey(
this.ourServiceId, this.#ourServiceId,
id id
); );

View file

@ -240,11 +240,10 @@ export class SignalProtocolStore extends EventEmitter {
// Cached values // Cached values
private ourIdentityKeys = new Map<ServiceIdString, KeyPairType>(); #ourIdentityKeys = new Map<ServiceIdString, KeyPairType>();
private ourRegistrationIds = new Map<ServiceIdString, number>(); #ourRegistrationIds = new Map<ServiceIdString, number>();
#cachedPniSignatureMessage: PniSignatureMessageType | undefined;
private cachedPniSignatureMessage: PniSignatureMessageType | undefined;
identityKeys?: Map< identityKeys?: Map<
IdentityKeyIdType, IdentityKeyIdType,
@ -273,24 +272,18 @@ export class SignalProtocolStore extends EventEmitter {
sessionQueueJobCounter = 0; sessionQueueJobCounter = 0;
private readonly identityQueues = new Map<ServiceIdString, PQueue>(); readonly #identityQueues = new Map<ServiceIdString, PQueue>();
#currentZone?: Zone;
private currentZone?: Zone; #currentZoneDepth = 0;
readonly #zoneQueue: Array<ZoneQueueEntryType> = [];
private currentZoneDepth = 0; #pendingSessions = new Map<SessionIdType, SessionCacheEntry>();
#pendingSenderKeys = new Map<SenderKeyIdType, SenderKeyCacheEntry>();
private readonly zoneQueue: Array<ZoneQueueEntryType> = []; #pendingUnprocessed = new Map<string, UnprocessedType>();
private pendingSessions = new Map<SessionIdType, SessionCacheEntry>();
private pendingSenderKeys = new Map<SenderKeyIdType, SenderKeyCacheEntry>();
private pendingUnprocessed = new Map<string, UnprocessedType>();
async hydrateCaches(): Promise<void> { async hydrateCaches(): Promise<void> {
await Promise.all([ await Promise.all([
(async () => { (async () => {
this.ourIdentityKeys.clear(); this.#ourIdentityKeys.clear();
const map = (await DataReader.getItemById( const map = (await DataReader.getItemById(
'identityKeyMap' 'identityKeyMap'
)) as unknown as ItemType<'identityKeyMap'>; )) as unknown as ItemType<'identityKeyMap'>;
@ -304,14 +297,14 @@ export class SignalProtocolStore extends EventEmitter {
'Invalid identity key serviceId' 'Invalid identity key serviceId'
); );
const { privKey, pubKey } = map.value[serviceId]; const { privKey, pubKey } = map.value[serviceId];
this.ourIdentityKeys.set(serviceId, { this.#ourIdentityKeys.set(serviceId, {
privKey, privKey,
pubKey, pubKey,
}); });
} }
})(), })(),
(async () => { (async () => {
this.ourRegistrationIds.clear(); this.#ourRegistrationIds.clear();
const map = (await DataReader.getItemById( const map = (await DataReader.getItemById(
'registrationIdMap' 'registrationIdMap'
)) as unknown as ItemType<'registrationIdMap'>; )) as unknown as ItemType<'registrationIdMap'>;
@ -324,7 +317,7 @@ export class SignalProtocolStore extends EventEmitter {
isServiceIdString(serviceId), isServiceIdString(serviceId),
'Invalid registration id serviceId' 'Invalid registration id serviceId'
); );
this.ourRegistrationIds.set(serviceId, map.value[serviceId]); this.#ourRegistrationIds.set(serviceId, map.value[serviceId]);
} }
})(), })(),
_fillCaches<string, IdentityKeyType, PublicKey>( _fillCaches<string, IdentityKeyType, PublicKey>(
@ -361,25 +354,22 @@ export class SignalProtocolStore extends EventEmitter {
} }
getIdentityKeyPair(ourServiceId: ServiceIdString): KeyPairType | undefined { getIdentityKeyPair(ourServiceId: ServiceIdString): KeyPairType | undefined {
return this.ourIdentityKeys.get(ourServiceId); return this.#ourIdentityKeys.get(ourServiceId);
} }
async getLocalRegistrationId( async getLocalRegistrationId(
ourServiceId: ServiceIdString ourServiceId: ServiceIdString
): Promise<number | undefined> { ): Promise<number | undefined> {
return this.ourRegistrationIds.get(ourServiceId); return this.#ourRegistrationIds.get(ourServiceId);
} }
private _getKeyId( #_getKeyId(ourServiceId: ServiceIdString, keyId: number): PreKeyIdType {
ourServiceId: ServiceIdString,
keyId: number
): PreKeyIdType {
return `${ourServiceId}:${keyId}`; return `${ourServiceId}:${keyId}`;
} }
// KyberPreKeys // KyberPreKeys
private _getKyberPreKeyEntry( #_getKyberPreKeyEntry(
id: PreKeyIdType, id: PreKeyIdType,
logContext: string logContext: string
): ):
@ -420,8 +410,8 @@ export class SignalProtocolStore extends EventEmitter {
ourServiceId: ServiceIdString, ourServiceId: ServiceIdString,
keyId: number keyId: number
): Promise<KyberPreKeyRecord | undefined> { ): Promise<KyberPreKeyRecord | undefined> {
const id: PreKeyIdType = this._getKeyId(ourServiceId, keyId); const id: PreKeyIdType = this.#_getKeyId(ourServiceId, keyId);
const entry = this._getKyberPreKeyEntry(id, 'loadKyberPreKey'); const entry = this.#_getKyberPreKeyEntry(id, 'loadKyberPreKey');
return entry?.item; return entry?.item;
} }
@ -457,7 +447,7 @@ export class SignalProtocolStore extends EventEmitter {
throw new Error('storeKyberPreKey: this.kyberPreKeys not yet cached!'); throw new Error('storeKyberPreKey: this.kyberPreKeys not yet cached!');
} }
const id: PreKeyIdType = this._getKeyId(ourServiceId, keyId); const id: PreKeyIdType = this.#_getKeyId(ourServiceId, keyId);
const item = kyberPreKeyCache.get(id); const item = kyberPreKeyCache.get(id);
if (!item) { if (!item) {
throw new Error(`confirmKyberPreKey: missing kyber prekey ${id}!`); throw new Error(`confirmKyberPreKey: missing kyber prekey ${id}!`);
@ -487,7 +477,7 @@ export class SignalProtocolStore extends EventEmitter {
const toSave: Array<KyberPreKeyType> = []; const toSave: Array<KyberPreKeyType> = [];
keys.forEach(key => { keys.forEach(key => {
const id: PreKeyIdType = this._getKeyId(ourServiceId, key.keyId); const id: PreKeyIdType = this.#_getKeyId(ourServiceId, key.keyId);
if (kyberPreKeyCache.has(id)) { if (kyberPreKeyCache.has(id)) {
throw new Error(`storeKyberPreKey: kyber prekey ${id} already exists!`); throw new Error(`storeKyberPreKey: kyber prekey ${id} already exists!`);
} }
@ -519,8 +509,8 @@ export class SignalProtocolStore extends EventEmitter {
ourServiceId: ServiceIdString, ourServiceId: ServiceIdString,
keyId: number keyId: number
): Promise<void> { ): Promise<void> {
const id: PreKeyIdType = this._getKeyId(ourServiceId, keyId); const id: PreKeyIdType = this.#_getKeyId(ourServiceId, keyId);
const entry = this._getKyberPreKeyEntry(id, 'maybeRemoveKyberPreKey'); const entry = this.#_getKyberPreKeyEntry(id, 'maybeRemoveKyberPreKey');
if (!entry) { if (!entry) {
return; return;
@ -544,7 +534,7 @@ export class SignalProtocolStore extends EventEmitter {
throw new Error('removeKyberPreKeys: this.kyberPreKeys not yet cached!'); throw new Error('removeKyberPreKeys: this.kyberPreKeys not yet cached!');
} }
const ids = keyIds.map(keyId => this._getKeyId(ourServiceId, keyId)); const ids = keyIds.map(keyId => this.#_getKeyId(ourServiceId, keyId));
log.info('removeKyberPreKeys: Removing kyber prekeys:', formatKeys(keyIds)); log.info('removeKyberPreKeys: Removing kyber prekeys:', formatKeys(keyIds));
const changes = await DataWriter.removeKyberPreKeyById(ids); const changes = await DataWriter.removeKyberPreKeyById(ids);
@ -554,7 +544,7 @@ export class SignalProtocolStore extends EventEmitter {
}); });
if (kyberPreKeyCache.size < LOW_KEYS_THRESHOLD) { if (kyberPreKeyCache.size < LOW_KEYS_THRESHOLD) {
this.emitLowKeys( this.#emitLowKeys(
ourServiceId, ourServiceId,
`removeKyberPreKeys@${kyberPreKeyCache.size}` `removeKyberPreKeys@${kyberPreKeyCache.size}`
); );
@ -579,7 +569,7 @@ export class SignalProtocolStore extends EventEmitter {
throw new Error('loadPreKey: this.preKeys not yet cached!'); throw new Error('loadPreKey: this.preKeys not yet cached!');
} }
const id: PreKeyIdType = this._getKeyId(ourServiceId, keyId); const id: PreKeyIdType = this.#_getKeyId(ourServiceId, keyId);
const entry = this.preKeys.get(id); const entry = this.preKeys.get(id);
if (!entry) { if (!entry) {
log.error('Failed to fetch prekey:', id); log.error('Failed to fetch prekey:', id);
@ -628,7 +618,7 @@ export class SignalProtocolStore extends EventEmitter {
const now = Date.now(); const now = Date.now();
const toSave: Array<PreKeyType> = []; const toSave: Array<PreKeyType> = [];
keys.forEach(key => { keys.forEach(key => {
const id: PreKeyIdType = this._getKeyId(ourServiceId, key.keyId); const id: PreKeyIdType = this.#_getKeyId(ourServiceId, key.keyId);
if (preKeyCache.has(id)) { if (preKeyCache.has(id)) {
throw new Error(`storePreKeys: prekey ${id} already exists!`); throw new Error(`storePreKeys: prekey ${id} already exists!`);
@ -665,7 +655,7 @@ export class SignalProtocolStore extends EventEmitter {
throw new Error('removePreKeys: this.preKeys not yet cached!'); throw new Error('removePreKeys: this.preKeys not yet cached!');
} }
const ids = keyIds.map(keyId => this._getKeyId(ourServiceId, keyId)); const ids = keyIds.map(keyId => this.#_getKeyId(ourServiceId, keyId));
log.info('removePreKeys: Removing prekeys:', formatKeys(keyIds)); log.info('removePreKeys: Removing prekeys:', formatKeys(keyIds));
@ -676,7 +666,7 @@ export class SignalProtocolStore extends EventEmitter {
}); });
if (preKeyCache.size < LOW_KEYS_THRESHOLD) { if (preKeyCache.size < LOW_KEYS_THRESHOLD) {
this.emitLowKeys(ourServiceId, `removePreKeys@${preKeyCache.size}`); this.#emitLowKeys(ourServiceId, `removePreKeys@${preKeyCache.size}`);
} }
} }
@ -756,7 +746,7 @@ export class SignalProtocolStore extends EventEmitter {
throw new Error('storeKyberPreKey: this.signedPreKeys not yet cached!'); throw new Error('storeKyberPreKey: this.signedPreKeys not yet cached!');
} }
const id: PreKeyIdType = this._getKeyId(ourServiceId, keyId); const id: PreKeyIdType = this.#_getKeyId(ourServiceId, keyId);
const item = signedPreKeyCache.get(id); const item = signedPreKeyCache.get(id);
if (!item) { if (!item) {
throw new Error(`confirmSignedPreKey: missing prekey ${id}!`); throw new Error(`confirmSignedPreKey: missing prekey ${id}!`);
@ -785,7 +775,7 @@ export class SignalProtocolStore extends EventEmitter {
throw new Error('storeSignedPreKey: this.signedPreKeys not yet cached!'); throw new Error('storeSignedPreKey: this.signedPreKeys not yet cached!');
} }
const id: SignedPreKeyIdType = this._getKeyId(ourServiceId, keyId); const id: SignedPreKeyIdType = this.#_getKeyId(ourServiceId, keyId);
const fromDB = { const fromDB = {
id, id,
@ -813,7 +803,7 @@ export class SignalProtocolStore extends EventEmitter {
throw new Error('removeSignedPreKey: this.signedPreKeys not yet cached!'); throw new Error('removeSignedPreKey: this.signedPreKeys not yet cached!');
} }
const ids = keyIds.map(keyId => this._getKeyId(ourServiceId, keyId)); const ids = keyIds.map(keyId => this.#_getKeyId(ourServiceId, keyId));
log.info( log.info(
'removeSignedPreKeys: Removing signed prekeys:', 'removeSignedPreKeys: Removing signed prekeys:',
@ -855,13 +845,13 @@ export class SignalProtocolStore extends EventEmitter {
zone = GLOBAL_ZONE zone = GLOBAL_ZONE
): Promise<T> { ): Promise<T> {
return this.withZone(zone, 'enqueueSenderKeyJob', async () => { return this.withZone(zone, 'enqueueSenderKeyJob', async () => {
const queue = this._getSenderKeyQueue(qualifiedAddress); const queue = this.#_getSenderKeyQueue(qualifiedAddress);
return queue.add<T>(task); return queue.add<T>(task);
}); });
} }
private _createSenderKeyQueue(): PQueue { #_createSenderKeyQueue(): PQueue {
return new PQueue({ return new PQueue({
concurrency: 1, concurrency: 1,
timeout: MINUTE * 30, timeout: MINUTE * 30,
@ -869,18 +859,18 @@ export class SignalProtocolStore extends EventEmitter {
}); });
} }
private _getSenderKeyQueue(senderId: QualifiedAddress): PQueue { #_getSenderKeyQueue(senderId: QualifiedAddress): PQueue {
const cachedQueue = this.senderKeyQueues.get(senderId.toString()); const cachedQueue = this.senderKeyQueues.get(senderId.toString());
if (cachedQueue) { if (cachedQueue) {
return cachedQueue; return cachedQueue;
} }
const freshQueue = this._createSenderKeyQueue(); const freshQueue = this.#_createSenderKeyQueue();
this.senderKeyQueues.set(senderId.toString(), freshQueue); this.senderKeyQueues.set(senderId.toString(), freshQueue);
return freshQueue; return freshQueue;
} }
private getSenderKeyId( #getSenderKeyId(
senderKeyId: QualifiedAddress, senderKeyId: QualifiedAddress,
distributionId: string distributionId: string
): SenderKeyIdType { ): SenderKeyIdType {
@ -901,7 +891,7 @@ export class SignalProtocolStore extends EventEmitter {
const senderId = qualifiedAddress.toString(); const senderId = qualifiedAddress.toString();
try { try {
const id = this.getSenderKeyId(qualifiedAddress, distributionId); const id = this.#getSenderKeyId(qualifiedAddress, distributionId);
const fromDB: SenderKeyType = { const fromDB: SenderKeyType = {
id, id,
@ -911,7 +901,7 @@ export class SignalProtocolStore extends EventEmitter {
lastUpdatedDate: Date.now(), lastUpdatedDate: Date.now(),
}; };
this.pendingSenderKeys.set(id, { this.#pendingSenderKeys.set(id, {
hydrated: true, hydrated: true,
fromDB, fromDB,
item: record, item: record,
@ -919,7 +909,7 @@ export class SignalProtocolStore extends EventEmitter {
// Current zone doesn't support pending sessions - commit immediately // Current zone doesn't support pending sessions - commit immediately
if (!zone.supportsPendingSenderKeys()) { if (!zone.supportsPendingSenderKeys()) {
await this.commitZoneChanges('saveSenderKey'); await this.#commitZoneChanges('saveSenderKey');
} }
} catch (error) { } catch (error) {
const errorString = Errors.toLogFormat(error); const errorString = Errors.toLogFormat(error);
@ -943,10 +933,10 @@ export class SignalProtocolStore extends EventEmitter {
const senderId = qualifiedAddress.toString(); const senderId = qualifiedAddress.toString();
try { try {
const id = this.getSenderKeyId(qualifiedAddress, distributionId); const id = this.#getSenderKeyId(qualifiedAddress, distributionId);
const map = this.pendingSenderKeys.has(id) const map = this.#pendingSenderKeys.has(id)
? this.pendingSenderKeys ? this.#pendingSenderKeys
: this.senderKeys; : this.senderKeys;
const entry = map.get(id); const entry = map.get(id);
@ -991,7 +981,7 @@ export class SignalProtocolStore extends EventEmitter {
const senderId = qualifiedAddress.toString(); const senderId = qualifiedAddress.toString();
try { try {
const id = this.getSenderKeyId(qualifiedAddress, distributionId); const id = this.#getSenderKeyId(qualifiedAddress, distributionId);
await DataWriter.removeSenderKeyById(id); await DataWriter.removeSenderKeyById(id);
@ -1009,8 +999,8 @@ export class SignalProtocolStore extends EventEmitter {
if (this.senderKeys) { if (this.senderKeys) {
this.senderKeys.clear(); this.senderKeys.clear();
} }
if (this.pendingSenderKeys) { if (this.#pendingSenderKeys) {
this.pendingSenderKeys.clear(); this.#pendingSenderKeys.clear();
} }
await DataWriter.removeAllSenderKeys(); await DataWriter.removeAllSenderKeys();
}); });
@ -1030,7 +1020,7 @@ export class SignalProtocolStore extends EventEmitter {
const waitStart = Date.now(); const waitStart = Date.now();
return this.withZone(zone, 'enqueueSessionJob', async () => { return this.withZone(zone, 'enqueueSessionJob', async () => {
const queue = this._getSessionQueue(qualifiedAddress); const queue = this.#_getSessionQueue(qualifiedAddress);
const waitTime = Date.now() - waitStart; const waitTime = Date.now() - waitStart;
log.info( log.info(
@ -1048,7 +1038,7 @@ export class SignalProtocolStore extends EventEmitter {
}); });
} }
private _createSessionQueue(): PQueue { #_createSessionQueue(): PQueue {
return new PQueue({ return new PQueue({
concurrency: 1, concurrency: 1,
timeout: MINUTE * 30, timeout: MINUTE * 30,
@ -1056,20 +1046,20 @@ export class SignalProtocolStore extends EventEmitter {
}); });
} }
private _getSessionQueue(id: QualifiedAddress): PQueue { #_getSessionQueue(id: QualifiedAddress): PQueue {
const cachedQueue = this.sessionQueues.get(id.toString()); const cachedQueue = this.sessionQueues.get(id.toString());
if (cachedQueue) { if (cachedQueue) {
return cachedQueue; return cachedQueue;
} }
const freshQueue = this._createSessionQueue(); const freshQueue = this.#_createSessionQueue();
this.sessionQueues.set(id.toString(), freshQueue); this.sessionQueues.set(id.toString(), freshQueue);
return freshQueue; return freshQueue;
} }
// Identity Queue // Identity Queue
private _createIdentityQueue(): PQueue { #_createIdentityQueue(): PQueue {
return new PQueue({ return new PQueue({
concurrency: 1, concurrency: 1,
timeout: MINUTE * 30, timeout: MINUTE * 30,
@ -1077,7 +1067,7 @@ export class SignalProtocolStore extends EventEmitter {
}); });
} }
private _runOnIdentityQueue<T>( #_runOnIdentityQueue<T>(
serviceId: ServiceIdString, serviceId: ServiceIdString,
zone: Zone, zone: Zone,
name: string, name: string,
@ -1085,12 +1075,12 @@ export class SignalProtocolStore extends EventEmitter {
): Promise<T> { ): Promise<T> {
let queue: PQueue; let queue: PQueue;
const cachedQueue = this.identityQueues.get(serviceId); const cachedQueue = this.#identityQueues.get(serviceId);
if (cachedQueue) { if (cachedQueue) {
queue = cachedQueue; queue = cachedQueue;
} else { } else {
queue = this._createIdentityQueue(); queue = this.#_createIdentityQueue();
this.identityQueues.set(serviceId, queue); this.#identityQueues.set(serviceId, queue);
} }
// We run the identity queue task in zone because `saveIdentity` needs to // We run the identity queue task in zone because `saveIdentity` needs to
@ -1124,10 +1114,10 @@ export class SignalProtocolStore extends EventEmitter {
const debugName = `withZone(${zone.name}:${name})`; const debugName = `withZone(${zone.name}:${name})`;
// Allow re-entering from LibSignalStores // Allow re-entering from LibSignalStores
if (this.currentZone && this.currentZone !== zone) { if (this.#currentZone && this.#currentZone !== zone) {
const start = Date.now(); const start = Date.now();
log.info(`${debugName}: locked by ${this.currentZone.name}, waiting`); log.info(`${debugName}: locked by ${this.#currentZone.name}, waiting`);
return new Promise<T>((resolve, reject) => { return new Promise<T>((resolve, reject) => {
const callback = async () => { const callback = async () => {
@ -1143,33 +1133,35 @@ export class SignalProtocolStore extends EventEmitter {
} }
}; };
this.zoneQueue.push({ zone, callback }); this.#zoneQueue.push({ zone, callback });
}); });
} }
this.enterZone(zone, name); this.#enterZone(zone, name);
let result: T; let result: T;
try { try {
result = await body(); result = await body();
} catch (error) { } catch (error) {
if (this.isInTopLevelZone()) { if (this.#isInTopLevelZone()) {
await this.revertZoneChanges(name, error); await this.#revertZoneChanges(name, error);
} }
this.leaveZone(zone); this.#leaveZone(zone);
throw error; throw error;
} }
if (this.isInTopLevelZone()) { if (this.#isInTopLevelZone()) {
await this.commitZoneChanges(name); await this.#commitZoneChanges(name);
} }
this.leaveZone(zone); this.#leaveZone(zone);
return result; return result;
} }
private async commitZoneChanges(name: string): Promise<void> { async #commitZoneChanges(name: string): Promise<void> {
const { pendingSenderKeys, pendingSessions, pendingUnprocessed } = this; const pendingUnprocessed = this.#pendingUnprocessed;
const pendingSenderKeys = this.#pendingSenderKeys;
const pendingSessions = this.#pendingSessions;
if ( if (
pendingSenderKeys.size === 0 && pendingSenderKeys.size === 0 &&
@ -1186,9 +1178,9 @@ export class SignalProtocolStore extends EventEmitter {
`pending unprocessed ${pendingUnprocessed.size}` `pending unprocessed ${pendingUnprocessed.size}`
); );
this.pendingSenderKeys = new Map(); this.#pendingSenderKeys = new Map();
this.pendingSessions = new Map(); this.#pendingSessions = new Map();
this.pendingUnprocessed = new Map(); this.#pendingUnprocessed = new Map();
// Commit both sender keys, sessions and unprocessed in the same database transaction // Commit both sender keys, sessions and unprocessed in the same database transaction
// to unroll both on error. // to unroll both on error.
@ -1223,28 +1215,28 @@ export class SignalProtocolStore extends EventEmitter {
}); });
} }
private async revertZoneChanges(name: string, error: Error): Promise<void> { async #revertZoneChanges(name: string, error: Error): Promise<void> {
log.info( log.info(
`revertZoneChanges(${name}): ` + `revertZoneChanges(${name}): ` +
`pending sender keys size ${this.pendingSenderKeys.size}, ` + `pending sender keys size ${this.#pendingSenderKeys.size}, ` +
`pending sessions size ${this.pendingSessions.size}, ` + `pending sessions size ${this.#pendingSessions.size}, ` +
`pending unprocessed size ${this.pendingUnprocessed.size}`, `pending unprocessed size ${this.#pendingUnprocessed.size}`,
Errors.toLogFormat(error) Errors.toLogFormat(error)
); );
this.pendingSenderKeys.clear(); this.#pendingSenderKeys.clear();
this.pendingSessions.clear(); this.#pendingSessions.clear();
this.pendingUnprocessed.clear(); this.#pendingUnprocessed.clear();
} }
private isInTopLevelZone(): boolean { #isInTopLevelZone(): boolean {
return this.currentZoneDepth === 1; return this.#currentZoneDepth === 1;
} }
private enterZone(zone: Zone, name: string): void { #enterZone(zone: Zone, name: string): void {
this.currentZoneDepth += 1; this.#currentZoneDepth += 1;
if (this.currentZoneDepth === 1) { if (this.#currentZoneDepth === 1) {
assertDev(this.currentZone === undefined, 'Should not be in the zone'); assertDev(this.#currentZone === undefined, 'Should not be in the zone');
this.currentZone = zone; this.#currentZone = zone;
if (zone !== GLOBAL_ZONE) { if (zone !== GLOBAL_ZONE) {
log.info(`SignalProtocolStore.enterZone(${zone.name}:${name})`); log.info(`SignalProtocolStore.enterZone(${zone.name}:${name})`);
@ -1252,19 +1244,19 @@ export class SignalProtocolStore extends EventEmitter {
} }
} }
private leaveZone(zone: Zone): void { #leaveZone(zone: Zone): void {
assertDev(this.currentZone === zone, 'Should be in the correct zone'); assertDev(this.#currentZone === zone, 'Should be in the correct zone');
this.currentZoneDepth -= 1; this.#currentZoneDepth -= 1;
assertDev( assertDev(
this.currentZoneDepth >= 0, this.#currentZoneDepth >= 0,
'Unmatched number of leaveZone calls' 'Unmatched number of leaveZone calls'
); );
// Since we allow re-entering zones we might actually be in two overlapping // Since we allow re-entering zones we might actually be in two overlapping
// async calls. Leave the zone and yield to another one only if there are // async calls. Leave the zone and yield to another one only if there are
// no active zone users anymore. // no active zone users anymore.
if (this.currentZoneDepth !== 0) { if (this.#currentZoneDepth !== 0) {
return; return;
} }
@ -1272,17 +1264,17 @@ export class SignalProtocolStore extends EventEmitter {
log.info(`SignalProtocolStore.leaveZone(${zone.name})`); log.info(`SignalProtocolStore.leaveZone(${zone.name})`);
} }
this.currentZone = undefined; this.#currentZone = undefined;
const next = this.zoneQueue.shift(); const next = this.#zoneQueue.shift();
if (!next) { if (!next) {
return; return;
} }
const toEnter = [next]; const toEnter = [next];
while (this.zoneQueue[0]?.zone === next.zone) { while (this.#zoneQueue[0]?.zone === next.zone) {
const elem = this.zoneQueue.shift(); const elem = this.#zoneQueue.shift();
assertDev(elem, 'Zone element should be present'); assertDev(elem, 'Zone element should be present');
toEnter.push(elem); toEnter.push(elem);
@ -1313,8 +1305,8 @@ export class SignalProtocolStore extends EventEmitter {
const id = qualifiedAddress.toString(); const id = qualifiedAddress.toString();
try { try {
const map = this.pendingSessions.has(id) const map = this.#pendingSessions.has(id)
? this.pendingSessions ? this.#pendingSessions
: this.sessions; : this.sessions;
const entry = map.get(id); const entry = map.get(id);
@ -1399,13 +1391,13 @@ export class SignalProtocolStore extends EventEmitter {
item: record, item: record,
}; };
assertDev(this.currentZone, 'Must run in the zone'); assertDev(this.#currentZone, 'Must run in the zone');
this.pendingSessions.set(id, newSession); this.#pendingSessions.set(id, newSession);
// Current zone doesn't support pending sessions - commit immediately // Current zone doesn't support pending sessions - commit immediately
if (!zone.supportsPendingSessions()) { if (!zone.supportsPendingSessions()) {
await this.commitZoneChanges('storeSession'); await this.#commitZoneChanges('storeSession');
} }
} catch (error) { } catch (error) {
const errorString = Errors.toLogFormat(error); const errorString = Errors.toLogFormat(error);
@ -1421,7 +1413,7 @@ export class SignalProtocolStore extends EventEmitter {
throw new Error('getOpenDevices: this.sessions not yet cached!'); throw new Error('getOpenDevices: this.sessions not yet cached!');
} }
return this._getAllSessions().some( return this.#_getAllSessions().some(
({ fromDB }) => fromDB.serviceId === serviceId ({ fromDB }) => fromDB.serviceId === serviceId
); );
}); });
@ -1446,7 +1438,7 @@ export class SignalProtocolStore extends EventEmitter {
try { try {
const serviceIdSet = new Set(serviceIds); const serviceIdSet = new Set(serviceIds);
const allSessions = this._getAllSessions(); const allSessions = this.#_getAllSessions();
const entries = allSessions.filter( const entries = allSessions.filter(
({ fromDB }) => ({ fromDB }) =>
fromDB.ourServiceId === ourServiceId && fromDB.ourServiceId === ourServiceId &&
@ -1537,7 +1529,7 @@ export class SignalProtocolStore extends EventEmitter {
try { try {
await DataWriter.removeSessionById(id); await DataWriter.removeSessionById(id);
this.sessions.delete(id); this.sessions.delete(id);
this.pendingSessions.delete(id); this.#pendingSessions.delete(id);
} catch (e) { } catch (e) {
log.error(`removeSession: Failed to delete session for ${id}`); log.error(`removeSession: Failed to delete session for ${id}`);
} }
@ -1578,7 +1570,7 @@ export class SignalProtocolStore extends EventEmitter {
const entry = entries[i]; const entry = entries[i];
if (entry.fromDB.conversationId === id) { if (entry.fromDB.conversationId === id) {
this.sessions.delete(entry.fromDB.id); this.sessions.delete(entry.fromDB.id);
this.pendingSessions.delete(entry.fromDB.id); this.#pendingSessions.delete(entry.fromDB.id);
} }
} }
@ -1603,7 +1595,7 @@ export class SignalProtocolStore extends EventEmitter {
const entry = entries[i]; const entry = entries[i];
if (entry.fromDB.serviceId === serviceId) { if (entry.fromDB.serviceId === serviceId) {
this.sessions.delete(entry.fromDB.id); this.sessions.delete(entry.fromDB.id);
this.pendingSessions.delete(entry.fromDB.id); this.#pendingSessions.delete(entry.fromDB.id);
} }
} }
@ -1611,7 +1603,7 @@ export class SignalProtocolStore extends EventEmitter {
}); });
} }
private async _archiveSession(entry?: SessionCacheEntry, zone?: Zone) { async #_archiveSession(entry?: SessionCacheEntry, zone?: Zone) {
if (!entry) { if (!entry) {
return; return;
} }
@ -1646,9 +1638,9 @@ export class SignalProtocolStore extends EventEmitter {
log.info(`archiveSession: session for ${id}`); log.info(`archiveSession: session for ${id}`);
const entry = this.pendingSessions.get(id) || this.sessions.get(id); const entry = this.#pendingSessions.get(id) || this.sessions.get(id);
await this._archiveSession(entry); await this.#_archiveSession(entry);
}); });
} }
@ -1670,7 +1662,7 @@ export class SignalProtocolStore extends EventEmitter {
const { serviceId, deviceId } = encodedAddress; const { serviceId, deviceId } = encodedAddress;
const allEntries = this._getAllSessions(); const allEntries = this.#_getAllSessions();
const entries = allEntries.filter( const entries = allEntries.filter(
entry => entry =>
entry.fromDB.serviceId === serviceId && entry.fromDB.serviceId === serviceId &&
@ -1679,7 +1671,7 @@ export class SignalProtocolStore extends EventEmitter {
await Promise.all( await Promise.all(
entries.map(async entry => { entries.map(async entry => {
await this._archiveSession(entry, zone); await this.#_archiveSession(entry, zone);
}) })
); );
}); });
@ -1693,14 +1685,14 @@ export class SignalProtocolStore extends EventEmitter {
log.info('archiveAllSessions: archiving all sessions for', serviceId); log.info('archiveAllSessions: archiving all sessions for', serviceId);
const allEntries = this._getAllSessions(); const allEntries = this.#_getAllSessions();
const entries = allEntries.filter( const entries = allEntries.filter(
entry => entry.fromDB.serviceId === serviceId entry => entry.fromDB.serviceId === serviceId
); );
await Promise.all( await Promise.all(
entries.map(async entry => { entries.map(async entry => {
await this._archiveSession(entry); await this.#_archiveSession(entry);
}) })
); );
}); });
@ -1711,7 +1703,7 @@ export class SignalProtocolStore extends EventEmitter {
if (this.sessions) { if (this.sessions) {
this.sessions.clear(); this.sessions.clear();
} }
this.pendingSessions.clear(); this.#pendingSessions.clear();
const changes = await DataWriter.removeAllSessions(); const changes = await DataWriter.removeAllSessions();
log.info(`clearSessionStore: Removed ${changes} sessions`); log.info(`clearSessionStore: Removed ${changes} sessions`);
}); });
@ -1830,7 +1822,7 @@ export class SignalProtocolStore extends EventEmitter {
`to ${newRecord.id}` `to ${newRecord.id}`
); );
await this._saveIdentityKey(newRecord); await this.#_saveIdentityKey(newRecord);
this.identityKeys.delete(record.fromDB.id); this.identityKeys.delete(record.fromDB.id);
const changes = await DataWriter.removeIdentityKeyById(record.fromDB.id); const changes = await DataWriter.removeIdentityKeyById(record.fromDB.id);
@ -1931,7 +1923,7 @@ export class SignalProtocolStore extends EventEmitter {
log.error('isTrustedForSending: Needs unverified approval!'); log.error('isTrustedForSending: Needs unverified approval!');
return false; return false;
} }
if (this.isNonBlockingApprovalRequired(identityRecord)) { if (this.#isNonBlockingApprovalRequired(identityRecord)) {
log.error('isTrustedForSending: Needs non-blocking approval!'); log.error('isTrustedForSending: Needs non-blocking approval!');
return false; return false;
} }
@ -1973,7 +1965,7 @@ export class SignalProtocolStore extends EventEmitter {
return Bytes.toBase64(fingerprint); return Bytes.toBase64(fingerprint);
} }
private async _saveIdentityKey(data: IdentityKeyType): Promise<void> { async #_saveIdentityKey(data: IdentityKeyType): Promise<void> {
if (!this.identityKeys) { if (!this.identityKeys) {
throw new Error('_saveIdentityKey: this.identityKeys not yet cached!'); throw new Error('_saveIdentityKey: this.identityKeys not yet cached!');
} }
@ -2010,7 +2002,7 @@ export class SignalProtocolStore extends EventEmitter {
nonblockingApproval = false; nonblockingApproval = false;
} }
return this._runOnIdentityQueue( return this.#_runOnIdentityQueue(
encodedAddress.serviceId, encodedAddress.serviceId,
zone, zone,
'saveIdentity', 'saveIdentity',
@ -2025,7 +2017,7 @@ export class SignalProtocolStore extends EventEmitter {
if (!identityRecord || !identityRecord.publicKey) { if (!identityRecord || !identityRecord.publicKey) {
// Lookup failed, or the current key was removed, so save this one. // Lookup failed, or the current key was removed, so save this one.
log.info(`${logId}: Saving new identity...`); log.info(`${logId}: Saving new identity...`);
await this._saveIdentityKey({ await this.#_saveIdentityKey({
id, id,
publicKey, publicKey,
firstUse: true, firstUse: true,
@ -2074,7 +2066,7 @@ export class SignalProtocolStore extends EventEmitter {
verifiedStatus = VerifiedStatus.DEFAULT; verifiedStatus = VerifiedStatus.DEFAULT;
} }
await this._saveIdentityKey({ await this.#_saveIdentityKey({
id, id,
publicKey, publicKey,
firstUse: false, firstUse: false,
@ -2106,11 +2098,11 @@ export class SignalProtocolStore extends EventEmitter {
return true; return true;
} }
if (this.isNonBlockingApprovalRequired(identityRecord)) { if (this.#isNonBlockingApprovalRequired(identityRecord)) {
log.info(`${logId}: Setting approval status...`); log.info(`${logId}: Setting approval status...`);
identityRecord.nonblockingApproval = nonblockingApproval; identityRecord.nonblockingApproval = nonblockingApproval;
await this._saveIdentityKey(identityRecord); await this.#_saveIdentityKey(identityRecord);
return false; return false;
} }
@ -2121,9 +2113,7 @@ export class SignalProtocolStore extends EventEmitter {
} }
// https://github.com/signalapp/Signal-Android/blob/fc3db538bcaa38dc149712a483d3032c9c1f3998/app/src/main/java/org/thoughtcrime/securesms/crypto/storage/SignalBaseIdentityKeyStore.java#L257 // https://github.com/signalapp/Signal-Android/blob/fc3db538bcaa38dc149712a483d3032c9c1f3998/app/src/main/java/org/thoughtcrime/securesms/crypto/storage/SignalBaseIdentityKeyStore.java#L257
private isNonBlockingApprovalRequired( #isNonBlockingApprovalRequired(identityRecord: IdentityKeyType): boolean {
identityRecord: IdentityKeyType
): boolean {
return ( return (
!identityRecord.firstUse && !identityRecord.firstUse &&
isMoreRecentThan(identityRecord.timestamp, TIMESTAMP_THRESHOLD) && isMoreRecentThan(identityRecord.timestamp, TIMESTAMP_THRESHOLD) &&
@ -2135,17 +2125,17 @@ export class SignalProtocolStore extends EventEmitter {
serviceId: ServiceIdString, serviceId: ServiceIdString,
attributes: Partial<IdentityKeyType> attributes: Partial<IdentityKeyType>
): Promise<void> { ): Promise<void> {
return this._runOnIdentityQueue( return this.#_runOnIdentityQueue(
serviceId, serviceId,
GLOBAL_ZONE, GLOBAL_ZONE,
'saveIdentityWithAttributes', 'saveIdentityWithAttributes',
async () => { async () => {
return this.saveIdentityWithAttributesOnQueue(serviceId, attributes); return this.#saveIdentityWithAttributesOnQueue(serviceId, attributes);
} }
); );
} }
private async saveIdentityWithAttributesOnQueue( async #saveIdentityWithAttributesOnQueue(
serviceId: ServiceIdString, serviceId: ServiceIdString,
attributes: Partial<IdentityKeyType> attributes: Partial<IdentityKeyType>
): Promise<void> { ): Promise<void> {
@ -2172,7 +2162,7 @@ export class SignalProtocolStore extends EventEmitter {
}; };
if (validateIdentityKey(updates)) { if (validateIdentityKey(updates)) {
await this._saveIdentityKey(updates); await this.#_saveIdentityKey(updates);
} }
} }
@ -2187,7 +2177,7 @@ export class SignalProtocolStore extends EventEmitter {
throw new Error('setApproval: Invalid approval status'); throw new Error('setApproval: Invalid approval status');
} }
return this._runOnIdentityQueue( return this.#_runOnIdentityQueue(
serviceId, serviceId,
GLOBAL_ZONE, GLOBAL_ZONE,
'setApproval', 'setApproval',
@ -2199,7 +2189,7 @@ export class SignalProtocolStore extends EventEmitter {
} }
identityRecord.nonblockingApproval = nonblockingApproval; identityRecord.nonblockingApproval = nonblockingApproval;
await this._saveIdentityKey(identityRecord); await this.#_saveIdentityKey(identityRecord);
} }
); );
} }
@ -2218,7 +2208,7 @@ export class SignalProtocolStore extends EventEmitter {
throw new Error('setVerified: Invalid verified status'); throw new Error('setVerified: Invalid verified status');
} }
return this._runOnIdentityQueue( return this.#_runOnIdentityQueue(
serviceId, serviceId,
GLOBAL_ZONE, GLOBAL_ZONE,
'setVerified', 'setVerified',
@ -2230,7 +2220,7 @@ export class SignalProtocolStore extends EventEmitter {
} }
if (validateIdentityKey(identityRecord)) { if (validateIdentityKey(identityRecord)) {
await this._saveIdentityKey({ await this.#_saveIdentityKey({
...identityRecord, ...identityRecord,
...extra, ...extra,
verified: verifiedStatus, verified: verifiedStatus,
@ -2304,7 +2294,7 @@ export class SignalProtocolStore extends EventEmitter {
`Invalid verified status: ${verifiedStatus}` `Invalid verified status: ${verifiedStatus}`
); );
return this._runOnIdentityQueue( return this.#_runOnIdentityQueue(
serviceId, serviceId,
GLOBAL_ZONE, GLOBAL_ZONE,
'updateIdentityAfterSync', 'updateIdentityAfterSync',
@ -2319,7 +2309,7 @@ export class SignalProtocolStore extends EventEmitter {
keyMatches && verifiedStatus === identityRecord?.verified; keyMatches && verifiedStatus === identityRecord?.verified;
if (!keyMatches || !statusMatches) { if (!keyMatches || !statusMatches) {
await this.saveIdentityWithAttributesOnQueue(serviceId, { await this.#saveIdentityWithAttributesOnQueue(serviceId, {
publicKey, publicKey,
verified: verifiedStatus, verified: verifiedStatus,
firstUse: !hadEntry, firstUse: !hadEntry,
@ -2440,11 +2430,11 @@ export class SignalProtocolStore extends EventEmitter {
{ zone = GLOBAL_ZONE }: SessionTransactionOptions = {} { zone = GLOBAL_ZONE }: SessionTransactionOptions = {}
): Promise<void> { ): Promise<void> {
return this.withZone(zone, 'addUnprocessed', async () => { return this.withZone(zone, 'addUnprocessed', async () => {
this.pendingUnprocessed.set(data.id, data); this.#pendingUnprocessed.set(data.id, data);
// Current zone doesn't support pending unprocessed - commit immediately // Current zone doesn't support pending unprocessed - commit immediately
if (!zone.supportsPendingUnprocessed()) { if (!zone.supportsPendingUnprocessed()) {
await this.commitZoneChanges('addUnprocessed'); await this.#commitZoneChanges('addUnprocessed');
} }
}); });
} }
@ -2455,11 +2445,11 @@ export class SignalProtocolStore extends EventEmitter {
): Promise<void> { ): Promise<void> {
return this.withZone(zone, 'addMultipleUnprocessed', async () => { return this.withZone(zone, 'addMultipleUnprocessed', async () => {
for (const elem of array) { for (const elem of array) {
this.pendingUnprocessed.set(elem.id, elem); this.#pendingUnprocessed.set(elem.id, elem);
} }
// Current zone doesn't support pending unprocessed - commit immediately // Current zone doesn't support pending unprocessed - commit immediately
if (!zone.supportsPendingUnprocessed()) { if (!zone.supportsPendingUnprocessed()) {
await this.commitZoneChanges('addMultipleUnprocessed'); await this.#commitZoneChanges('addMultipleUnprocessed');
} }
}); });
} }
@ -2505,8 +2495,8 @@ export class SignalProtocolStore extends EventEmitter {
log.info(`SignalProtocolStore.removeOurOldPni(${oldPni})`); log.info(`SignalProtocolStore.removeOurOldPni(${oldPni})`);
// Update caches // Update caches
this.ourIdentityKeys.delete(oldPni); this.#ourIdentityKeys.delete(oldPni);
this.ourRegistrationIds.delete(oldPni); this.#ourRegistrationIds.delete(oldPni);
const preKeyPrefix = `${oldPni}:`; const preKeyPrefix = `${oldPni}:`;
if (this.preKeys) { if (this.preKeys) {
@ -2575,11 +2565,11 @@ export class SignalProtocolStore extends EventEmitter {
const pniPrivateKey = identityKeyPair.privateKey.serialize(); const pniPrivateKey = identityKeyPair.privateKey.serialize();
// Update caches // Update caches
this.ourIdentityKeys.set(pni, { this.#ourIdentityKeys.set(pni, {
pubKey: pniPublicKey, pubKey: pniPublicKey,
privKey: pniPrivateKey, privKey: pniPrivateKey,
}); });
this.ourRegistrationIds.set(pni, registrationId); this.#ourRegistrationIds.set(pni, registrationId);
// Update database // Update database
await Promise.all<void>([ await Promise.all<void>([
@ -2670,8 +2660,8 @@ export class SignalProtocolStore extends EventEmitter {
return undefined; return undefined;
} }
if (this.cachedPniSignatureMessage?.pni === ourPni) { if (this.#cachedPniSignatureMessage?.pni === ourPni) {
return this.cachedPniSignatureMessage; return this.#cachedPniSignatureMessage;
} }
const aciKeyPair = this.getIdentityKeyPair(ourAci); const aciKeyPair = this.getIdentityKeyPair(ourAci);
@ -2690,12 +2680,12 @@ export class SignalProtocolStore extends EventEmitter {
PrivateKey.deserialize(Buffer.from(pniKeyPair.privKey)) PrivateKey.deserialize(Buffer.from(pniKeyPair.privKey))
); );
const aciPubKey = PublicKey.deserialize(Buffer.from(aciKeyPair.pubKey)); const aciPubKey = PublicKey.deserialize(Buffer.from(aciKeyPair.pubKey));
this.cachedPniSignatureMessage = { this.#cachedPniSignatureMessage = {
pni: ourPni, pni: ourPni,
signature: pniIdentity.signAlternateIdentity(aciPubKey), signature: pniIdentity.signAlternateIdentity(aciPubKey),
}; };
return this.cachedPniSignatureMessage; return this.#cachedPniSignatureMessage;
} }
async verifyAlternateIdentity({ async verifyAlternateIdentity({
@ -2725,20 +2715,20 @@ export class SignalProtocolStore extends EventEmitter {
); );
} }
private _getAllSessions(): Array<SessionCacheEntry> { #_getAllSessions(): Array<SessionCacheEntry> {
const union = new Map<string, SessionCacheEntry>(); const union = new Map<string, SessionCacheEntry>();
this.sessions?.forEach((value, key) => { this.sessions?.forEach((value, key) => {
union.set(key, value); union.set(key, value);
}); });
this.pendingSessions.forEach((value, key) => { this.#pendingSessions.forEach((value, key) => {
union.set(key, value); union.set(key, value);
}); });
return Array.from(union.values()); return Array.from(union.values());
} }
private emitLowKeys(ourServiceId: ServiceIdString, source: string) { #emitLowKeys(ourServiceId: ServiceIdString, source: string) {
const logId = `SignalProtocolStore.emitLowKeys/${source}:`; const logId = `SignalProtocolStore.emitLowKeys/${source}:`;
try { try {
log.info(`${logId}: Emitting event`); log.info(`${logId}: Emitting event`);

View file

@ -20,12 +20,12 @@ type OptionsType = {
}; };
export class WebAudioRecorder { export class WebAudioRecorder {
private buffer: Array<Float32Array>; #buffer: Array<Float32Array>;
private options: OptionsType; #options: OptionsType;
private context: BaseAudioContext; #context: BaseAudioContext;
private input: GainNode; #input: GainNode;
private onComplete: (recorder: WebAudioRecorder, blob: Blob) => unknown; #onComplete: (recorder: WebAudioRecorder, blob: Blob) => unknown;
private onError: (recorder: WebAudioRecorder, error: string) => unknown; #onError: (recorder: WebAudioRecorder, error: string) => unknown;
private processor?: ScriptProcessorNode; private processor?: ScriptProcessorNode;
public worker?: Worker; public worker?: Worker;
@ -37,19 +37,19 @@ export class WebAudioRecorder {
onError: (recorder: WebAudioRecorder, error: string) => unknown; onError: (recorder: WebAudioRecorder, error: string) => unknown;
} }
) { ) {
this.options = { this.#options = {
...DEFAULT_OPTIONS, ...DEFAULT_OPTIONS,
...options, ...options,
}; };
this.context = sourceNode.context; this.#context = sourceNode.context;
this.input = this.context.createGain(); this.#input = this.#context.createGain();
sourceNode.connect(this.input); sourceNode.connect(this.#input);
this.buffer = []; this.#buffer = [];
this.initWorker(); this.#initWorker();
this.onComplete = callbacks.onComplete; this.#onComplete = callbacks.onComplete;
this.onError = callbacks.onError; this.#onError = callbacks.onError;
} }
isRecording(): boolean { isRecording(): boolean {
@ -62,21 +62,22 @@ export class WebAudioRecorder {
return; return;
} }
const { buffer, worker } = this; const { worker } = this;
const { bufferSize, numChannels } = this.options; const buffer = this.#buffer;
const { bufferSize, numChannels } = this.#options;
if (!worker) { if (!worker) {
this.error('startRecording: worker not initialized'); this.error('startRecording: worker not initialized');
return; return;
} }
this.processor = this.context.createScriptProcessor( this.processor = this.#context.createScriptProcessor(
bufferSize, bufferSize,
numChannels, numChannels,
numChannels numChannels
); );
this.input.connect(this.processor); this.#input.connect(this.processor);
this.processor.connect(this.context.destination); this.processor.connect(this.#context.destination);
this.processor.onaudioprocess = event => { this.processor.onaudioprocess = event => {
// eslint-disable-next-line no-plusplus // eslint-disable-next-line no-plusplus
for (let ch = 0; ch < numChannels; ++ch) { for (let ch = 0; ch < numChannels; ++ch) {
@ -101,7 +102,7 @@ export class WebAudioRecorder {
return; return;
} }
this.input.disconnect(); this.#input.disconnect();
this.processor.disconnect(); this.processor.disconnect();
delete this.processor; delete this.processor;
this.worker.postMessage({ command: 'cancel' }); this.worker.postMessage({ command: 'cancel' });
@ -118,13 +119,13 @@ export class WebAudioRecorder {
return; return;
} }
this.input.disconnect(); this.#input.disconnect();
this.processor.disconnect(); this.processor.disconnect();
delete this.processor; delete this.processor;
this.worker.postMessage({ command: 'finish' }); this.worker.postMessage({ command: 'finish' });
} }
private initWorker(): void { #initWorker(): void {
if (this.worker != null) { if (this.worker != null) {
this.worker.terminate(); this.worker.terminate();
} }
@ -134,7 +135,7 @@ export class WebAudioRecorder {
const { data } = event; const { data } = event;
switch (data.command) { switch (data.command) {
case 'complete': case 'complete':
this.onComplete(this, data.blob); this.#onComplete(this, data.blob);
break; break;
case 'error': case 'error':
this.error(data.message); this.error(data.message);
@ -146,14 +147,14 @@ export class WebAudioRecorder {
this.worker.postMessage({ this.worker.postMessage({
command: 'init', command: 'init',
config: { config: {
sampleRate: this.context.sampleRate, sampleRate: this.#context.sampleRate,
numChannels: this.options.numChannels, numChannels: this.#options.numChannels,
}, },
options: this.options, options: this.#options,
}); });
} }
error(message: string): void { error(message: string): void {
this.onError(this, `WebAudioRecorder.js: ${message}`); this.#onError(this, `WebAudioRecorder.js: ${message}`);
} }
} }

View file

@ -15,12 +15,11 @@ enum BadgeDownloaderState {
} }
class BadgeImageFileDownloader { class BadgeImageFileDownloader {
private state = BadgeDownloaderState.Idle; #state = BadgeDownloaderState.Idle;
#queue = new PQueue({ concurrency: 3 });
private queue = new PQueue({ concurrency: 3 });
public async checkForFilesToDownload(): Promise<void> { public async checkForFilesToDownload(): Promise<void> {
switch (this.state) { switch (this.#state) {
case BadgeDownloaderState.CheckingWithAnotherCheckEnqueued: case BadgeDownloaderState.CheckingWithAnotherCheckEnqueued:
log.info( log.info(
'BadgeDownloader#checkForFilesToDownload: not enqueuing another check' 'BadgeDownloader#checkForFilesToDownload: not enqueuing another check'
@ -30,10 +29,10 @@ class BadgeImageFileDownloader {
log.info( log.info(
'BadgeDownloader#checkForFilesToDownload: enqueuing another check' 'BadgeDownloader#checkForFilesToDownload: enqueuing another check'
); );
this.state = BadgeDownloaderState.CheckingWithAnotherCheckEnqueued; this.#state = BadgeDownloaderState.CheckingWithAnotherCheckEnqueued;
return; return;
case BadgeDownloaderState.Idle: { case BadgeDownloaderState.Idle: {
this.state = BadgeDownloaderState.Checking; this.#state = BadgeDownloaderState.Checking;
const urlsToDownload = getUrlsToDownload(); const urlsToDownload = getUrlsToDownload();
log.info( log.info(
@ -41,7 +40,7 @@ class BadgeImageFileDownloader {
); );
try { try {
await this.queue.addAll( await this.#queue.addAll(
urlsToDownload.map(url => () => downloadBadgeImageFile(url)) urlsToDownload.map(url => () => downloadBadgeImageFile(url))
); );
} catch (err: unknown) { } catch (err: unknown) {
@ -53,8 +52,8 @@ class BadgeImageFileDownloader {
// issue][0]. // issue][0].
// //
// [0]: https://github.com/microsoft/TypeScript/issues/9998 // [0]: https://github.com/microsoft/TypeScript/issues/9998
const previousState = this.state as BadgeDownloaderState; const previousState = this.#state as BadgeDownloaderState;
this.state = BadgeDownloaderState.Idle; this.#state = BadgeDownloaderState.Idle;
if ( if (
previousState === previousState ===
BadgeDownloaderState.CheckingWithAnotherCheckEnqueued BadgeDownloaderState.CheckingWithAnotherCheckEnqueued
@ -64,7 +63,7 @@ class BadgeImageFileDownloader {
return; return;
} }
default: default:
throw missingCaseError(this.state); throw missingCaseError(this.#state);
} }
} }
} }

View file

@ -123,37 +123,30 @@ export function getChallengeURL(type: 'chat' | 'registration'): string {
// `ChallengeHandler` should be in memory at the same time because they could // `ChallengeHandler` should be in memory at the same time because they could
// overwrite each others storage data. // overwrite each others storage data.
export class ChallengeHandler { export class ChallengeHandler {
private solving = 0; #solving = 0;
#isLoaded = false;
#challengeToken: string | undefined;
#seq = 0;
#isOnline = false;
#challengeRateLimitRetryAt: undefined | number;
readonly #responseHandlers = new Map<number, Handler>();
private isLoaded = false; readonly #registeredConversations = new Map<
private challengeToken: string | undefined;
private seq = 0;
private isOnline = false;
private challengeRateLimitRetryAt: undefined | number;
private readonly responseHandlers = new Map<number, Handler>();
private readonly registeredConversations = new Map<
string, string,
RegisteredChallengeType RegisteredChallengeType
>(); >();
private readonly startTimers = new Map<string, NodeJS.Timeout>(); readonly #startTimers = new Map<string, NodeJS.Timeout>();
readonly #pendingStarts = new Set<string>();
private readonly pendingStarts = new Set<string>();
constructor(private readonly options: Options) {} constructor(private readonly options: Options) {}
public async load(): Promise<void> { public async load(): Promise<void> {
if (this.isLoaded) { if (this.#isLoaded) {
return; return;
} }
this.isLoaded = true; this.#isLoaded = true;
const challenges: ReadonlyArray<RegisteredChallengeType> = const challenges: ReadonlyArray<RegisteredChallengeType> =
this.options.storage.get(STORAGE_KEY) || []; this.options.storage.get(STORAGE_KEY) || [];
@ -182,39 +175,39 @@ export class ChallengeHandler {
} }
public async onOffline(): Promise<void> { public async onOffline(): Promise<void> {
this.isOnline = false; this.#isOnline = false;
log.info('challenge: offline'); log.info('challenge: offline');
} }
public async onOnline(): Promise<void> { public async onOnline(): Promise<void> {
this.isOnline = true; this.#isOnline = true;
const pending = Array.from(this.pendingStarts.values()); const pending = Array.from(this.#pendingStarts.values());
this.pendingStarts.clear(); this.#pendingStarts.clear();
log.info(`challenge: online, starting ${pending.length} queues`); log.info(`challenge: online, starting ${pending.length} queues`);
// Start queues for challenges that matured while we were offline // Start queues for challenges that matured while we were offline
await this.startAllQueues(); await this.#startAllQueues();
} }
public maybeSolve({ conversationId, reason }: MaybeSolveOptionsType): void { public maybeSolve({ conversationId, reason }: MaybeSolveOptionsType): void {
const challenge = this.registeredConversations.get(conversationId); const challenge = this.#registeredConversations.get(conversationId);
if (!challenge) { if (!challenge) {
return; return;
} }
if (this.solving > 0) { if (this.#solving > 0) {
return; return;
} }
if (this.challengeRateLimitRetryAt) { if (this.#challengeRateLimitRetryAt) {
return; return;
} }
if (challenge.token) { if (challenge.token) {
drop(this.solve({ reason, token: challenge.token })); drop(this.#solve({ reason, token: challenge.token }));
} }
} }
@ -224,18 +217,18 @@ export class ChallengeHandler {
reason: string reason: string
): void { ): void {
const waitTime = Math.max(0, retryAt - Date.now()); const waitTime = Math.max(0, retryAt - Date.now());
const oldTimer = this.startTimers.get(conversationId); const oldTimer = this.#startTimers.get(conversationId);
if (oldTimer) { if (oldTimer) {
clearTimeoutIfNecessary(oldTimer); clearTimeoutIfNecessary(oldTimer);
} }
this.startTimers.set( this.#startTimers.set(
conversationId, conversationId,
setTimeout(() => { setTimeout(() => {
this.startTimers.delete(conversationId); this.#startTimers.delete(conversationId);
this.challengeRateLimitRetryAt = undefined; this.#challengeRateLimitRetryAt = undefined;
drop(this.startQueue(conversationId)); drop(this.#startQueue(conversationId));
}, waitTime) }, waitTime)
); );
log.info( log.info(
@ -244,14 +237,14 @@ export class ChallengeHandler {
} }
public forceWaitOnAll(retryAt: number): void { public forceWaitOnAll(retryAt: number): void {
this.challengeRateLimitRetryAt = retryAt; this.#challengeRateLimitRetryAt = retryAt;
for (const conversationId of this.registeredConversations.keys()) { for (const conversationId of this.#registeredConversations.keys()) {
const existing = this.registeredConversations.get(conversationId); const existing = this.#registeredConversations.get(conversationId);
if (!existing) { if (!existing) {
continue; continue;
} }
this.registeredConversations.set(conversationId, { this.#registeredConversations.set(conversationId, {
...existing, ...existing,
retryAt, retryAt,
}); });
@ -271,20 +264,20 @@ export class ChallengeHandler {
return; return;
} }
this.registeredConversations.set(conversationId, challenge); this.#registeredConversations.set(conversationId, challenge);
await this.persist(); await this.#persist();
// Challenge is already retryable - start the queue // Challenge is already retryable - start the queue
if (shouldStartQueue(challenge)) { if (shouldStartQueue(challenge)) {
log.info(`${logId}: starting conversation ${conversationId} immediately`); log.info(`${logId}: starting conversation ${conversationId} immediately`);
await this.startQueue(conversationId); await this.#startQueue(conversationId);
return; return;
} }
if (this.challengeRateLimitRetryAt) { if (this.#challengeRateLimitRetryAt) {
this.scheduleRetry( this.scheduleRetry(
conversationId, conversationId,
this.challengeRateLimitRetryAt, this.#challengeRateLimitRetryAt,
'register-challengeRateLimit' 'register-challengeRateLimit'
); );
} else if (challenge.retryAt) { } else if (challenge.retryAt) {
@ -310,17 +303,17 @@ export class ChallengeHandler {
} }
if (!challenge.silent) { if (!challenge.silent) {
drop(this.solve({ token: challenge.token, reason })); drop(this.#solve({ token: challenge.token, reason }));
} }
} }
public onResponse(response: IPCResponse): void { public onResponse(response: IPCResponse): void {
const handler = this.responseHandlers.get(response.seq); const handler = this.#responseHandlers.get(response.seq);
if (!handler) { if (!handler) {
return; return;
} }
this.responseHandlers.delete(response.seq); this.#responseHandlers.delete(response.seq);
handler.resolve(response.data); handler.resolve(response.data);
} }
@ -331,72 +324,72 @@ export class ChallengeHandler {
log.info( log.info(
`challenge: unregistered conversation ${conversationId} via ${source}` `challenge: unregistered conversation ${conversationId} via ${source}`
); );
this.registeredConversations.delete(conversationId); this.#registeredConversations.delete(conversationId);
this.pendingStarts.delete(conversationId); this.#pendingStarts.delete(conversationId);
const timer = this.startTimers.get(conversationId); const timer = this.#startTimers.get(conversationId);
this.startTimers.delete(conversationId); this.#startTimers.delete(conversationId);
clearTimeoutIfNecessary(timer); clearTimeoutIfNecessary(timer);
await this.persist(); await this.#persist();
} }
public async requestCaptcha({ public async requestCaptcha({
reason, reason,
token = '', token = '',
}: RequestCaptchaOptionsType): Promise<string> { }: RequestCaptchaOptionsType): Promise<string> {
const request: IPCRequest = { seq: this.seq, reason }; const request: IPCRequest = { seq: this.#seq, reason };
this.seq += 1; this.#seq += 1;
this.options.requestChallenge(request); this.options.requestChallenge(request);
const response = await new Promise<ChallengeResponse>((resolve, reject) => { const response = await new Promise<ChallengeResponse>((resolve, reject) => {
this.responseHandlers.set(request.seq, { token, resolve, reject }); this.#responseHandlers.set(request.seq, { token, resolve, reject });
}); });
return response.captcha; return response.captcha;
} }
private async persist(): Promise<void> { async #persist(): Promise<void> {
assertDev( assertDev(
this.isLoaded, this.#isLoaded,
'ChallengeHandler has to be loaded before persisting new data' 'ChallengeHandler has to be loaded before persisting new data'
); );
await this.options.storage.put( await this.options.storage.put(
STORAGE_KEY, STORAGE_KEY,
Array.from(this.registeredConversations.values()) Array.from(this.#registeredConversations.values())
); );
} }
public areAnyRegistered(): boolean { public areAnyRegistered(): boolean {
return this.registeredConversations.size > 0; return this.#registeredConversations.size > 0;
} }
public isRegistered(conversationId: string): boolean { public isRegistered(conversationId: string): boolean {
return this.registeredConversations.has(conversationId); return this.#registeredConversations.has(conversationId);
} }
private startAllQueues({ #startAllQueues({
force = false, force = false,
}: { }: {
force?: boolean; force?: boolean;
} = {}): void { } = {}): void {
log.info(`challenge: startAllQueues force=${force}`); log.info(`challenge: startAllQueues force=${force}`);
Array.from(this.registeredConversations.values()) Array.from(this.#registeredConversations.values())
.filter(challenge => force || shouldStartQueue(challenge)) .filter(challenge => force || shouldStartQueue(challenge))
.forEach(challenge => this.startQueue(challenge.conversationId)); .forEach(challenge => this.#startQueue(challenge.conversationId));
} }
private async startQueue(conversationId: string): Promise<void> { async #startQueue(conversationId: string): Promise<void> {
if (!this.isOnline) { if (!this.#isOnline) {
this.pendingStarts.add(conversationId); this.#pendingStarts.add(conversationId);
return; return;
} }
await this.unregister(conversationId, 'startQueue'); await this.unregister(conversationId, 'startQueue');
if (this.registeredConversations.size === 0) { if (this.#registeredConversations.size === 0) {
this.options.setChallengeStatus('idle'); this.options.setChallengeStatus('idle');
} }
@ -404,21 +397,21 @@ export class ChallengeHandler {
this.options.startQueue(conversationId); this.options.startQueue(conversationId);
} }
private async solve({ reason, token }: SolveOptionsType): Promise<void> { async #solve({ reason, token }: SolveOptionsType): Promise<void> {
this.solving += 1; this.#solving += 1;
this.options.setChallengeStatus('required'); this.options.setChallengeStatus('required');
this.challengeToken = token; this.#challengeToken = token;
const captcha = await this.requestCaptcha({ reason, token }); const captcha = await this.requestCaptcha({ reason, token });
// Another `.solve()` has completed earlier than us // Another `.solve()` has completed earlier than us
if (this.challengeToken === undefined) { if (this.#challengeToken === undefined) {
this.solving -= 1; this.#solving -= 1;
return; return;
} }
const lastToken = this.challengeToken; const lastToken = this.#challengeToken;
this.challengeToken = undefined; this.#challengeToken = undefined;
this.options.setChallengeStatus('pending'); this.options.setChallengeStatus('pending');
@ -465,13 +458,13 @@ export class ChallengeHandler {
this.forceWaitOnAll(retryAt); this.forceWaitOnAll(retryAt);
return; return;
} finally { } finally {
this.solving -= 1; this.#solving -= 1;
} }
log.info(`challenge(${reason}): challenge success. force sending`); log.info(`challenge(${reason}): challenge success. force sending`);
this.options.setChallengeStatus('idle'); this.options.setChallengeStatus('idle');
this.options.onChallengeSolved(); this.options.onChallengeSolved();
this.startAllQueues({ force: true }); this.#startAllQueues({ force: true });
} }
} }

View file

@ -47,8 +47,8 @@ export class ErrorBoundary extends React.PureComponent<Props, State> {
return ( return (
<div <div
className={CSS_MODULE} className={CSS_MODULE}
onClick={this.onClick.bind(this)} onClick={this.#onClick.bind(this)}
onKeyDown={this.onKeyDown.bind(this)} onKeyDown={this.#onKeyDown.bind(this)}
role="button" role="button"
tabIndex={0} tabIndex={0}
> >
@ -62,24 +62,24 @@ export class ErrorBoundary extends React.PureComponent<Props, State> {
); );
} }
private onClick(event: React.MouseEvent): void { #onClick(event: React.MouseEvent): void {
event.stopPropagation(); event.stopPropagation();
event.preventDefault(); event.preventDefault();
this.onAction(); this.#onAction();
} }
private onKeyDown(event: React.KeyboardEvent): void { #onKeyDown(event: React.KeyboardEvent): void {
if (event.key !== 'Enter' && event.key !== ' ') { if (event.key !== 'Enter' && event.key !== ' ') {
return; return;
} }
event.stopPropagation(); event.stopPropagation();
event.preventDefault(); event.preventDefault();
this.onAction(); this.#onAction();
} }
private onAction(): void { #onAction(): void {
const { showDebugLog } = this.props; const { showDebugLog } = this.props;
showDebugLog(); showDebugLog();
} }

View file

@ -410,11 +410,11 @@ export class Message extends React.PureComponent<Props, State> {
public reactionsContainerRef: React.RefObject<HTMLDivElement> = public reactionsContainerRef: React.RefObject<HTMLDivElement> =
React.createRef(); React.createRef();
private hasSelectedTextRef: React.MutableRefObject<boolean> = { #hasSelectedTextRef: React.MutableRefObject<boolean> = {
current: false, current: false,
}; };
private metadataRef: React.RefObject<HTMLDivElement> = React.createRef(); #metadataRef: React.RefObject<HTMLDivElement> = React.createRef();
public reactionsContainerRefMerger = createRefMerger(); public reactionsContainerRefMerger = createRefMerger();
@ -432,7 +432,7 @@ export class Message extends React.PureComponent<Props, State> {
super(props); super(props);
this.state = { this.state = {
metadataWidth: this.guessMetadataWidth(), metadataWidth: this.#guessMetadataWidth(),
expiring: false, expiring: false,
expired: false, expired: false,
@ -447,7 +447,7 @@ export class Message extends React.PureComponent<Props, State> {
showOutgoingGiftBadgeModal: false, showOutgoingGiftBadgeModal: false,
hasDeleteForEveryoneTimerExpired: hasDeleteForEveryoneTimerExpired:
this.getTimeRemainingForDeleteForEveryone() <= 0, this.#getTimeRemainingForDeleteForEveryone() <= 0,
}; };
} }
@ -474,7 +474,7 @@ export class Message extends React.PureComponent<Props, State> {
return state; return state;
} }
private hasReactions(): boolean { #hasReactions(): boolean {
const { reactions } = this.props; const { reactions } = this.props;
return Boolean(reactions && reactions.length); return Boolean(reactions && reactions.length);
} }
@ -518,7 +518,7 @@ export class Message extends React.PureComponent<Props, State> {
window.ConversationController?.onConvoMessageMount(conversationId); window.ConversationController?.onConvoMessageMount(conversationId);
this.startTargetedTimer(); this.startTargetedTimer();
this.startDeleteForEveryoneTimerIfApplicable(); this.#startDeleteForEveryoneTimerIfApplicable();
this.startGiftBadgeInterval(); this.startGiftBadgeInterval();
const { isTargeted } = this.props; const { isTargeted } = this.props;
@ -543,7 +543,7 @@ export class Message extends React.PureComponent<Props, State> {
checkForAccount(contact.firstNumber); checkForAccount(contact.firstNumber);
} }
document.addEventListener('selectionchange', this.handleSelectionChange); document.addEventListener('selectionchange', this.#handleSelectionChange);
} }
public override componentWillUnmount(): void { public override componentWillUnmount(): void {
@ -553,14 +553,17 @@ export class Message extends React.PureComponent<Props, State> {
clearTimeoutIfNecessary(this.deleteForEveryoneTimeout); clearTimeoutIfNecessary(this.deleteForEveryoneTimeout);
clearTimeoutIfNecessary(this.giftBadgeInterval); clearTimeoutIfNecessary(this.giftBadgeInterval);
this.toggleReactionViewer(true); this.toggleReactionViewer(true);
document.removeEventListener('selectionchange', this.handleSelectionChange); document.removeEventListener(
'selectionchange',
this.#handleSelectionChange
);
} }
public override componentDidUpdate(prevProps: Readonly<Props>): void { public override componentDidUpdate(prevProps: Readonly<Props>): void {
const { isTargeted, status, timestamp } = this.props; const { isTargeted, status, timestamp } = this.props;
this.startTargetedTimer(); this.startTargetedTimer();
this.startDeleteForEveryoneTimerIfApplicable(); this.#startDeleteForEveryoneTimerIfApplicable();
if (!prevProps.isTargeted && isTargeted) { if (!prevProps.isTargeted && isTargeted) {
this.setFocus(); this.setFocus();
@ -586,7 +589,7 @@ export class Message extends React.PureComponent<Props, State> {
} }
} }
private getMetadataPlacement( #getMetadataPlacement(
{ {
attachments, attachments,
attachmentDroppedDueToSize, attachmentDroppedDueToSize,
@ -634,11 +637,11 @@ export class Message extends React.PureComponent<Props, State> {
return MetadataPlacement.InlineWithText; return MetadataPlacement.InlineWithText;
} }
if (this.canRenderStickerLikeEmoji()) { if (this.#canRenderStickerLikeEmoji()) {
return MetadataPlacement.Bottom; return MetadataPlacement.Bottom;
} }
if (this.shouldShowJoinButton()) { if (this.#shouldShowJoinButton()) {
return MetadataPlacement.Bottom; return MetadataPlacement.Bottom;
} }
@ -653,7 +656,7 @@ export class Message extends React.PureComponent<Props, State> {
* This will probably guess wrong, but it's valuable to get close to the real value * This will probably guess wrong, but it's valuable to get close to the real value
* because it can reduce layout jumpiness. * because it can reduce layout jumpiness.
*/ */
private guessMetadataWidth(): number { #guessMetadataWidth(): number {
const { direction, expirationLength, isSMS, status, isEditedMessage } = const { direction, expirationLength, isSMS, status, isEditedMessage } =
this.props; this.props;
@ -714,12 +717,12 @@ export class Message extends React.PureComponent<Props, State> {
})); }));
} }
private getTimeRemainingForDeleteForEveryone(): number { #getTimeRemainingForDeleteForEveryone(): number {
const { timestamp } = this.props; const { timestamp } = this.props;
return Math.max(timestamp - Date.now() + DAY, 0); return Math.max(timestamp - Date.now() + DAY, 0);
} }
private startDeleteForEveryoneTimerIfApplicable(): void { #startDeleteForEveryoneTimerIfApplicable(): void {
const { canDeleteForEveryone } = this.props; const { canDeleteForEveryone } = this.props;
const { hasDeleteForEveryoneTimerExpired } = this.state; const { hasDeleteForEveryoneTimerExpired } = this.state;
if ( if (
@ -733,7 +736,7 @@ export class Message extends React.PureComponent<Props, State> {
this.deleteForEveryoneTimeout = setTimeout(() => { this.deleteForEveryoneTimeout = setTimeout(() => {
this.setState({ hasDeleteForEveryoneTimerExpired: true }); this.setState({ hasDeleteForEveryoneTimerExpired: true });
delete this.deleteForEveryoneTimeout; delete this.deleteForEveryoneTimeout;
}, this.getTimeRemainingForDeleteForEveryone()); }, this.#getTimeRemainingForDeleteForEveryone());
} }
public checkExpired(): void { public checkExpired(): void {
@ -761,12 +764,12 @@ export class Message extends React.PureComponent<Props, State> {
} }
} }
private areLinksEnabled(): boolean { #areLinksEnabled(): boolean {
const { isMessageRequestAccepted, isBlocked } = this.props; const { isMessageRequestAccepted, isBlocked } = this.props;
return isMessageRequestAccepted && !isBlocked; return isMessageRequestAccepted && !isBlocked;
} }
private shouldRenderAuthor(): boolean { #shouldRenderAuthor(): boolean {
const { author, conversationType, direction, shouldCollapseAbove } = const { author, conversationType, direction, shouldCollapseAbove } =
this.props; this.props;
return Boolean( return Boolean(
@ -777,7 +780,7 @@ export class Message extends React.PureComponent<Props, State> {
); );
} }
private canRenderStickerLikeEmoji(): boolean { #canRenderStickerLikeEmoji(): boolean {
const { const {
attachments, attachments,
bodyRanges, bodyRanges,
@ -799,7 +802,7 @@ export class Message extends React.PureComponent<Props, State> {
); );
} }
private updateMetadataWidth = (newMetadataWidth: number): void => { #updateMetadataWidth = (newMetadataWidth: number): void => {
this.setState(({ metadataWidth }) => ({ this.setState(({ metadataWidth }) => ({
// We don't want text to jump around if the metadata shrinks, but we want to make // We don't want text to jump around if the metadata shrinks, but we want to make
// sure we have enough room. // sure we have enough room.
@ -807,16 +810,16 @@ export class Message extends React.PureComponent<Props, State> {
})); }));
}; };
private handleSelectionChange = () => { #handleSelectionChange = () => {
const selection = document.getSelection(); const selection = document.getSelection();
if (selection != null && !selection.isCollapsed) { if (selection != null && !selection.isCollapsed) {
this.hasSelectedTextRef.current = true; this.#hasSelectedTextRef.current = true;
} }
}; };
private renderMetadata(): ReactNode { #renderMetadata(): ReactNode {
let isInline: boolean; let isInline: boolean;
const metadataPlacement = this.getMetadataPlacement(); const metadataPlacement = this.#getMetadataPlacement();
switch (metadataPlacement) { switch (metadataPlacement) {
case MetadataPlacement.NotRendered: case MetadataPlacement.NotRendered:
case MetadataPlacement.RenderedByMessageAudioComponent: case MetadataPlacement.RenderedByMessageAudioComponent:
@ -854,7 +857,7 @@ export class Message extends React.PureComponent<Props, State> {
timestamp, timestamp,
} = this.props; } = this.props;
const isStickerLike = isSticker || this.canRenderStickerLikeEmoji(); const isStickerLike = isSticker || this.#canRenderStickerLikeEmoji();
return ( return (
<MessageMetadata <MessageMetadata
@ -874,9 +877,9 @@ export class Message extends React.PureComponent<Props, State> {
isShowingImage={this.isShowingImage()} isShowingImage={this.isShowingImage()}
isSticker={isStickerLike} isSticker={isStickerLike}
isTapToViewExpired={isTapToViewExpired} isTapToViewExpired={isTapToViewExpired}
onWidthMeasured={isInline ? this.updateMetadataWidth : undefined} onWidthMeasured={isInline ? this.#updateMetadataWidth : undefined}
pushPanelForConversation={pushPanelForConversation} pushPanelForConversation={pushPanelForConversation}
ref={this.metadataRef} ref={this.#metadataRef}
retryMessageSend={retryMessageSend} retryMessageSend={retryMessageSend}
showEditHistoryModal={showEditHistoryModal} showEditHistoryModal={showEditHistoryModal}
status={status} status={status}
@ -886,7 +889,7 @@ export class Message extends React.PureComponent<Props, State> {
); );
} }
private renderAuthor(): ReactNode { #renderAuthor(): ReactNode {
const { const {
author, author,
contactNameColor, contactNameColor,
@ -896,7 +899,7 @@ export class Message extends React.PureComponent<Props, State> {
isTapToViewExpired, isTapToViewExpired,
} = this.props; } = this.props;
if (!this.shouldRenderAuthor()) { if (!this.#shouldRenderAuthor()) {
return null; return null;
} }
@ -951,7 +954,7 @@ export class Message extends React.PureComponent<Props, State> {
const { imageBroken } = this.state; const { imageBroken } = this.state;
const collapseMetadata = const collapseMetadata =
this.getMetadataPlacement() === MetadataPlacement.NotRendered; this.#getMetadataPlacement() === MetadataPlacement.NotRendered;
if (!attachments || !attachments[0]) { if (!attachments || !attachments[0]) {
return null; return null;
@ -960,7 +963,7 @@ export class Message extends React.PureComponent<Props, State> {
// For attachments which aren't full-frame // For attachments which aren't full-frame
const withContentBelow = Boolean(text || attachmentDroppedDueToSize); const withContentBelow = Boolean(text || attachmentDroppedDueToSize);
const withContentAbove = Boolean(quote) || this.shouldRenderAuthor(); const withContentAbove = Boolean(quote) || this.#shouldRenderAuthor();
const displayImage = canDisplayImage(attachments); const displayImage = canDisplayImage(attachments);
if (displayImage && !imageBroken) { if (displayImage && !imageBroken) {
@ -1203,7 +1206,7 @@ export class Message extends React.PureComponent<Props, State> {
? i18n('icu:message--call-link-description') ? i18n('icu:message--call-link-description')
: undefined); : undefined);
const isClickable = this.areLinksEnabled(); const isClickable = this.#areLinksEnabled();
const className = classNames( const className = classNames(
'module-message__link-preview', 'module-message__link-preview',
@ -1371,7 +1374,7 @@ export class Message extends React.PureComponent<Props, State> {
const maybeSpacer = text const maybeSpacer = text
? undefined ? undefined
: this.getMetadataPlacement() === MetadataPlacement.InlineWithText && ( : this.#getMetadataPlacement() === MetadataPlacement.InlineWithText && (
<MessageTextMetadataSpacer metadataWidth={metadataWidth} /> <MessageTextMetadataSpacer metadataWidth={metadataWidth} />
); );
@ -1456,12 +1459,12 @@ export class Message extends React.PureComponent<Props, State> {
)} )}
> >
{description} {description}
{this.getMetadataPlacement() === {this.#getMetadataPlacement() ===
MetadataPlacement.InlineWithText && ( MetadataPlacement.InlineWithText && (
<MessageTextMetadataSpacer metadataWidth={metadataWidth} /> <MessageTextMetadataSpacer metadataWidth={metadataWidth} />
)} )}
</div> </div>
{this.renderMetadata()} {this.#renderMetadata()}
</div> </div>
</div> </div>
); );
@ -1569,7 +1572,7 @@ export class Message extends React.PureComponent<Props, State> {
{buttonContents} {buttonContents}
</div> </div>
</button> </button>
{this.renderMetadata()} {this.#renderMetadata()}
{showOutgoingGiftBadgeModal ? ( {showOutgoingGiftBadgeModal ? (
<OutgoingGiftBadgeModal <OutgoingGiftBadgeModal
i18n={i18n} i18n={i18n}
@ -1763,7 +1766,7 @@ export class Message extends React.PureComponent<Props, State> {
conversationType === 'group' && direction === 'incoming'; conversationType === 'group' && direction === 'incoming';
const withContentBelow = const withContentBelow =
withCaption || withCaption ||
this.getMetadataPlacement() !== MetadataPlacement.NotRendered; this.#getMetadataPlacement() !== MetadataPlacement.NotRendered;
const otherContent = const otherContent =
(contact && contact.firstNumber && contact.serviceId) || withCaption; (contact && contact.firstNumber && contact.serviceId) || withCaption;
@ -1833,7 +1836,7 @@ export class Message extends React.PureComponent<Props, State> {
); );
} }
private renderAvatar(): ReactNode { #renderAvatar(): ReactNode {
const { const {
author, author,
conversationId, conversationId,
@ -1854,7 +1857,7 @@ export class Message extends React.PureComponent<Props, State> {
<div <div
className={classNames('module-message__author-avatar-container', { className={classNames('module-message__author-avatar-container', {
'module-message__author-avatar-container--with-reactions': 'module-message__author-avatar-container--with-reactions':
this.hasReactions(), this.#hasReactions(),
})} })}
> >
{shouldCollapseBelow ? ( {shouldCollapseBelow ? (
@ -1887,7 +1890,7 @@ export class Message extends React.PureComponent<Props, State> {
); );
} }
private getContents(): string | undefined { #getContents(): string | undefined {
const { deletedForEveryone, direction, i18n, status, text } = this.props; const { deletedForEveryone, direction, i18n, status, text } = this.props;
if (deletedForEveryone) { if (deletedForEveryone) {
@ -1920,7 +1923,7 @@ export class Message extends React.PureComponent<Props, State> {
} = this.props; } = this.props;
const { metadataWidth } = this.state; const { metadataWidth } = this.state;
const contents = this.getContents(); const contents = this.#getContents();
if (!contents) { if (!contents) {
return null; return null;
@ -1950,10 +1953,10 @@ export class Message extends React.PureComponent<Props, State> {
const range = window.getSelection()?.getRangeAt(0); const range = window.getSelection()?.getRangeAt(0);
if ( if (
clickCount === 3 && clickCount === 3 &&
this.metadataRef.current && this.#metadataRef.current &&
range?.intersectsNode(this.metadataRef.current) range?.intersectsNode(this.#metadataRef.current)
) { ) {
range.setEndBefore(this.metadataRef.current); range.setEndBefore(this.#metadataRef.current);
} }
}} }}
onDoubleClick={(event: React.MouseEvent) => { onDoubleClick={(event: React.MouseEvent) => {
@ -1965,7 +1968,7 @@ export class Message extends React.PureComponent<Props, State> {
<MessageBodyReadMore <MessageBodyReadMore
bodyRanges={bodyRanges} bodyRanges={bodyRanges}
direction={direction} direction={direction}
disableLinks={!this.areLinksEnabled()} disableLinks={!this.#areLinksEnabled()}
displayLimit={displayLimit} displayLimit={displayLimit}
i18n={i18n} i18n={i18n}
id={id} id={id}
@ -1988,14 +1991,14 @@ export class Message extends React.PureComponent<Props, State> {
text={contents || ''} text={contents || ''}
textAttachment={textAttachment} textAttachment={textAttachment}
/> />
{this.getMetadataPlacement() === MetadataPlacement.InlineWithText && ( {this.#getMetadataPlacement() === MetadataPlacement.InlineWithText && (
<MessageTextMetadataSpacer metadataWidth={metadataWidth} /> <MessageTextMetadataSpacer metadataWidth={metadataWidth} />
)} )}
</div> </div>
); );
} }
private shouldShowJoinButton(): boolean { #shouldShowJoinButton(): boolean {
const { previews } = this.props; const { previews } = this.props;
if (previews?.length !== 1) { if (previews?.length !== 1) {
@ -2006,10 +2009,10 @@ export class Message extends React.PureComponent<Props, State> {
return Boolean(onlyPreview.isCallLink); return Boolean(onlyPreview.isCallLink);
} }
private renderAction(): JSX.Element | null { #renderAction(): JSX.Element | null {
const { direction, activeCallConversationId, i18n, previews } = this.props; const { direction, activeCallConversationId, i18n, previews } = this.props;
if (this.shouldShowJoinButton()) { if (this.#shouldShowJoinButton()) {
const firstPreview = previews[0]; const firstPreview = previews[0];
const inAnotherCall = Boolean( const inAnotherCall = Boolean(
activeCallConversationId && activeCallConversationId &&
@ -2044,7 +2047,7 @@ export class Message extends React.PureComponent<Props, State> {
return null; return null;
} }
private renderError(): ReactNode { #renderError(): ReactNode {
const { status, direction } = this.props; const { status, direction } = this.props;
if ( if (
@ -2205,7 +2208,7 @@ export class Message extends React.PureComponent<Props, State> {
} = this.props; } = this.props;
const collapseMetadata = const collapseMetadata =
this.getMetadataPlacement() === MetadataPlacement.NotRendered; this.#getMetadataPlacement() === MetadataPlacement.NotRendered;
const withContentBelow = !collapseMetadata; const withContentBelow = !collapseMetadata;
const withContentAbove = const withContentAbove =
!collapseMetadata && !collapseMetadata &&
@ -2243,7 +2246,7 @@ export class Message extends React.PureComponent<Props, State> {
); );
} }
private popperPreventOverflowModifier(): Partial<PreventOverflowModifier> { #popperPreventOverflowModifier(): Partial<PreventOverflowModifier> {
const { containerElementRef } = this.props; const { containerElementRef } = this.props;
return { return {
name: 'preventOverflow', name: 'preventOverflow',
@ -2302,7 +2305,7 @@ export class Message extends React.PureComponent<Props, State> {
public renderReactions(outgoing: boolean): JSX.Element | null { public renderReactions(outgoing: boolean): JSX.Element | null {
const { getPreferredBadge, reactions = [], i18n, theme } = this.props; const { getPreferredBadge, reactions = [], i18n, theme } = this.props;
if (!this.hasReactions()) { if (!this.#hasReactions()) {
return null; return null;
} }
@ -2465,7 +2468,7 @@ export class Message extends React.PureComponent<Props, State> {
<Popper <Popper
placement={popperPlacement} placement={popperPlacement}
strategy="fixed" strategy="fixed"
modifiers={[this.popperPreventOverflowModifier()]} modifiers={[this.#popperPreventOverflowModifier()]}
> >
{({ ref, style }) => ( {({ ref, style }) => (
<ReactionViewer <ReactionViewer
@ -2495,7 +2498,7 @@ export class Message extends React.PureComponent<Props, State> {
return ( return (
<> <>
{this.renderText()} {this.renderText()}
{this.renderMetadata()} {this.#renderMetadata()}
</> </>
); );
} }
@ -2508,7 +2511,7 @@ export class Message extends React.PureComponent<Props, State> {
return ( return (
<> <>
{this.renderTapToView()} {this.renderTapToView()}
{this.renderMetadata()} {this.#renderMetadata()}
</> </>
); );
} }
@ -2523,8 +2526,8 @@ export class Message extends React.PureComponent<Props, State> {
{this.renderPayment()} {this.renderPayment()}
{this.renderEmbeddedContact()} {this.renderEmbeddedContact()}
{this.renderText()} {this.renderText()}
{this.renderAction()} {this.#renderAction()}
{this.renderMetadata()} {this.#renderMetadata()}
{this.renderSendMessageButton()} {this.renderSendMessageButton()}
</> </>
); );
@ -2740,7 +2743,7 @@ export class Message extends React.PureComponent<Props, State> {
const isAttachmentPending = this.isAttachmentPending(); const isAttachmentPending = this.isAttachmentPending();
const width = this.getWidth(); const width = this.getWidth();
const isEmojiOnly = this.canRenderStickerLikeEmoji(); const isEmojiOnly = this.#canRenderStickerLikeEmoji();
const isStickerLike = isSticker || isEmojiOnly; const isStickerLike = isSticker || isEmojiOnly;
// If it's a mostly-normal gray incoming text box, we don't want to darken it as much // If it's a mostly-normal gray incoming text box, we don't want to darken it as much
@ -2773,7 +2776,7 @@ export class Message extends React.PureComponent<Props, State> {
isTapToViewError isTapToViewError
? 'module-message__container--with-tap-to-view-error' ? 'module-message__container--with-tap-to-view-error'
: null, : null,
this.hasReactions() ? 'module-message__container--with-reactions' : null, this.#hasReactions() ? 'module-message__container--with-reactions' : null,
deletedForEveryone deletedForEveryone
? 'module-message__container--deleted-for-everyone' ? 'module-message__container--deleted-for-everyone'
: null : null
@ -2806,7 +2809,7 @@ export class Message extends React.PureComponent<Props, State> {
}} }}
tabIndex={-1} tabIndex={-1}
> >
{this.renderAuthor()} {this.#renderAuthor()}
<div dir={TextDirectionToDirAttribute[textDirection]}> <div dir={TextDirectionToDirAttribute[textDirection]}>
{this.renderContents()} {this.renderContents()}
</div> </div>
@ -2890,13 +2893,13 @@ export class Message extends React.PureComponent<Props, State> {
} else { } else {
wrapperProps = { wrapperProps = {
onMouseDown: () => { onMouseDown: () => {
this.hasSelectedTextRef.current = false; this.#hasSelectedTextRef.current = false;
}, },
// We use `onClickCapture` here and preven default/stop propagation to // We use `onClickCapture` here and preven default/stop propagation to
// prevent other click handlers from firing. // prevent other click handlers from firing.
onClickCapture: event => { onClickCapture: event => {
if (isMacOS ? event.metaKey : event.ctrlKey) { if (isMacOS ? event.metaKey : event.ctrlKey) {
if (this.hasSelectedTextRef.current) { if (this.#hasSelectedTextRef.current) {
return; return;
} }
@ -2964,8 +2967,8 @@ export class Message extends React.PureComponent<Props, State> {
// eslint-disable-next-line react/no-unknown-property // eslint-disable-next-line react/no-unknown-property
inert={isSelectMode ? '' : undefined} inert={isSelectMode ? '' : undefined}
> >
{this.renderError()} {this.#renderError()}
{this.renderAvatar()} {this.#renderAvatar()}
{this.renderContainer()} {this.renderContainer()}
{renderMenu?.()} {renderMenu?.()}
</div> </div>

View file

@ -189,18 +189,18 @@ export class Timeline extends React.Component<
StateType, StateType,
SnapshotType SnapshotType
> { > {
private readonly containerRef = React.createRef<HTMLDivElement>(); readonly #containerRef = React.createRef<HTMLDivElement>();
private readonly messagesRef = React.createRef<HTMLDivElement>(); readonly #messagesRef = React.createRef<HTMLDivElement>();
private readonly atBottomDetectorRef = React.createRef<HTMLDivElement>(); readonly #atBottomDetectorRef = React.createRef<HTMLDivElement>();
private readonly lastSeenIndicatorRef = React.createRef<HTMLDivElement>(); readonly #lastSeenIndicatorRef = React.createRef<HTMLDivElement>();
private intersectionObserver?: IntersectionObserver; #intersectionObserver?: IntersectionObserver;
// This is a best guess. It will likely be overridden when the timeline is measured. // This is a best guess. It will likely be overridden when the timeline is measured.
private maxVisibleRows = Math.ceil(window.innerHeight / MIN_ROW_HEIGHT); #maxVisibleRows = Math.ceil(window.innerHeight / MIN_ROW_HEIGHT);
private hasRecentlyScrolledTimeout?: NodeJS.Timeout; #hasRecentlyScrolledTimeout?: NodeJS.Timeout;
private delayedPeekTimeout?: NodeJS.Timeout; #delayedPeekTimeout?: NodeJS.Timeout;
private peekInterval?: NodeJS.Timeout; #peekInterval?: NodeJS.Timeout;
// eslint-disable-next-line react/state-in-constructor // eslint-disable-next-line react/state-in-constructor
override state: StateType = { override state: StateType = {
@ -213,31 +213,28 @@ export class Timeline extends React.Component<
widthBreakpoint: WidthBreakpoint.Wide, widthBreakpoint: WidthBreakpoint.Wide,
}; };
private onScrollLockChange = (): void => { #onScrollLockChange = (): void => {
this.setState({ this.setState({
scrollLocked: this.scrollerLock.isLocked(), scrollLocked: this.#scrollerLock.isLocked(),
}); });
}; };
private scrollerLock = createScrollerLock( #scrollerLock = createScrollerLock('Timeline', this.#onScrollLockChange);
'Timeline',
this.onScrollLockChange
);
private onScroll = (event: UIEvent): void => { #onScroll = (event: UIEvent): void => {
// When content is removed from the viewport, such as typing indicators leaving // When content is removed from the viewport, such as typing indicators leaving
// or messages being edited smaller or deleted, scroll events are generated and // or messages being edited smaller or deleted, scroll events are generated and
// they are marked as user-generated (isTrusted === true). Actual user generated // they are marked as user-generated (isTrusted === true). Actual user generated
// scroll events with movement must scroll a nonbottom state at some point. // scroll events with movement must scroll a nonbottom state at some point.
const isAtBottom = this.isAtBottom(); const isAtBottom = this.#isAtBottom();
if (event.isTrusted && !isAtBottom) { if (event.isTrusted && !isAtBottom) {
this.scrollerLock.onUserInterrupt('onScroll'); this.#scrollerLock.onUserInterrupt('onScroll');
} }
// hasRecentlyScrolled is used to show the floating date header, which we only // hasRecentlyScrolled is used to show the floating date header, which we only
// want to show when scrolling through history or on conversation first open. // want to show when scrolling through history or on conversation first open.
// Checking bottom prevents new messages and typing from showing the header. // Checking bottom prevents new messages and typing from showing the header.
if (!this.state.hasRecentlyScrolled && this.isAtBottom()) { if (!this.state.hasRecentlyScrolled && this.#isAtBottom()) {
return; return;
} }
@ -248,24 +245,24 @@ export class Timeline extends React.Component<
// [0]: https://github.com/facebook/react/blob/29b7b775f2ecf878eaf605be959d959030598b07/packages/react-reconciler/src/ReactUpdateQueue.js#L401-L404 // [0]: https://github.com/facebook/react/blob/29b7b775f2ecf878eaf605be959d959030598b07/packages/react-reconciler/src/ReactUpdateQueue.js#L401-L404
oldState.hasRecentlyScrolled ? null : { hasRecentlyScrolled: true } oldState.hasRecentlyScrolled ? null : { hasRecentlyScrolled: true }
); );
clearTimeoutIfNecessary(this.hasRecentlyScrolledTimeout); clearTimeoutIfNecessary(this.#hasRecentlyScrolledTimeout);
this.hasRecentlyScrolledTimeout = setTimeout(() => { this.#hasRecentlyScrolledTimeout = setTimeout(() => {
this.setState({ hasRecentlyScrolled: false }); this.setState({ hasRecentlyScrolled: false });
}, 3000); }, 3000);
}; };
private scrollToItemIndex(itemIndex: number): void { #scrollToItemIndex(itemIndex: number): void {
if (this.scrollerLock.isLocked()) { if (this.#scrollerLock.isLocked()) {
return; return;
} }
this.messagesRef.current this.#messagesRef.current
?.querySelector(`[data-item-index="${itemIndex}"]`) ?.querySelector(`[data-item-index="${itemIndex}"]`)
?.scrollIntoViewIfNeeded(); ?.scrollIntoViewIfNeeded();
} }
private scrollToBottom = (setFocus?: boolean): void => { #scrollToBottom = (setFocus?: boolean): void => {
if (this.scrollerLock.isLocked()) { if (this.#scrollerLock.isLocked()) {
return; return;
} }
@ -276,20 +273,20 @@ export class Timeline extends React.Component<
const lastMessageId = items[lastIndex]; const lastMessageId = items[lastIndex];
targetMessage(lastMessageId, id); targetMessage(lastMessageId, id);
} else { } else {
const containerEl = this.containerRef.current; const containerEl = this.#containerRef.current;
if (containerEl) { if (containerEl) {
scrollToBottom(containerEl); scrollToBottom(containerEl);
} }
} }
}; };
private onClickScrollDownButton = (): void => { #onClickScrollDownButton = (): void => {
this.scrollerLock.onUserInterrupt('onClickScrollDownButton'); this.#scrollerLock.onUserInterrupt('onClickScrollDownButton');
this.scrollDown(false); this.#scrollDown(false);
}; };
private scrollDown = (setFocus?: boolean): void => { #scrollDown = (setFocus?: boolean): void => {
if (this.scrollerLock.isLocked()) { if (this.#scrollerLock.isLocked()) {
return; return;
} }
@ -309,7 +306,7 @@ export class Timeline extends React.Component<
} }
if (messageLoadingState) { if (messageLoadingState) {
this.scrollToBottom(setFocus); this.#scrollToBottom(setFocus);
return; return;
} }
@ -323,10 +320,10 @@ export class Timeline extends React.Component<
const messageId = items[oldestUnseenIndex]; const messageId = items[oldestUnseenIndex];
targetMessage(messageId, id); targetMessage(messageId, id);
} else { } else {
this.scrollToItemIndex(oldestUnseenIndex); this.#scrollToItemIndex(oldestUnseenIndex);
} }
} else if (haveNewest) { } else if (haveNewest) {
this.scrollToBottom(setFocus); this.#scrollToBottom(setFocus);
} else { } else {
const lastId = last(items); const lastId = last(items);
if (lastId) { if (lastId) {
@ -335,8 +332,8 @@ export class Timeline extends React.Component<
} }
}; };
private isAtBottom(): boolean { #isAtBottom(): boolean {
const containerEl = this.containerRef.current; const containerEl = this.#containerRef.current;
if (!containerEl) { if (!containerEl) {
return false; return false;
} }
@ -346,10 +343,10 @@ export class Timeline extends React.Component<
return isScrolledNearBottom || !hasScrollbars; return isScrolledNearBottom || !hasScrollbars;
} }
private updateIntersectionObserver(): void { #updateIntersectionObserver(): void {
const containerEl = this.containerRef.current; const containerEl = this.#containerRef.current;
const messagesEl = this.messagesRef.current; const messagesEl = this.#messagesRef.current;
const atBottomDetectorEl = this.atBottomDetectorRef.current; const atBottomDetectorEl = this.#atBottomDetectorRef.current;
if (!containerEl || !messagesEl || !atBottomDetectorEl) { if (!containerEl || !messagesEl || !atBottomDetectorEl) {
return; return;
} }
@ -368,7 +365,7 @@ export class Timeline extends React.Component<
// We re-initialize the `IntersectionObserver`. We don't want stale references to old // We re-initialize the `IntersectionObserver`. We don't want stale references to old
// props, and we care about the order of `IntersectionObserverEntry`s. (We could do // props, and we care about the order of `IntersectionObserverEntry`s. (We could do
// this another way, but this approach works.) // this another way, but this approach works.)
this.intersectionObserver?.disconnect(); this.#intersectionObserver?.disconnect();
const intersectionRatios = new Map<Element, number>(); const intersectionRatios = new Map<Element, number>();
@ -445,7 +442,7 @@ export class Timeline extends React.Component<
setIsNearBottom(id, newIsNearBottom); setIsNearBottom(id, newIsNearBottom);
if (newestBottomVisibleMessageId) { if (newestBottomVisibleMessageId) {
this.markNewestBottomVisibleMessageRead(); this.#markNewestBottomVisibleMessageRead();
const rowIndex = getRowIndexFromElement(newestBottomVisible); const rowIndex = getRowIndexFromElement(newestBottomVisible);
const maxRowIndex = items.length - 1; const maxRowIndex = items.length - 1;
@ -471,15 +468,15 @@ export class Timeline extends React.Component<
} }
}; };
this.intersectionObserver = new IntersectionObserver( this.#intersectionObserver = new IntersectionObserver(
(entries, observer) => { (entries, observer) => {
assertDev( assertDev(
this.intersectionObserver === observer, this.#intersectionObserver === observer,
'observer.disconnect() should prevent callbacks from firing' 'observer.disconnect() should prevent callbacks from firing'
); );
// Observer was updated from under us // Observer was updated from under us
if (this.intersectionObserver !== observer) { if (this.#intersectionObserver !== observer) {
return; return;
} }
@ -493,13 +490,13 @@ export class Timeline extends React.Component<
for (const child of messagesEl.children) { for (const child of messagesEl.children) {
if ((child as HTMLElement).dataset.messageId) { if ((child as HTMLElement).dataset.messageId) {
this.intersectionObserver.observe(child); this.#intersectionObserver.observe(child);
} }
} }
this.intersectionObserver.observe(atBottomDetectorEl); this.#intersectionObserver.observe(atBottomDetectorEl);
} }
private markNewestBottomVisibleMessageRead = throttle((): void => { #markNewestBottomVisibleMessageRead = throttle((): void => {
const { id, markMessageRead } = this.props; const { id, markMessageRead } = this.props;
const { newestBottomVisibleMessageId } = this.state; const { newestBottomVisibleMessageId } = this.state;
if (newestBottomVisibleMessageId) { if (newestBottomVisibleMessageId) {
@ -507,36 +504,37 @@ export class Timeline extends React.Component<
} }
}, 500); }, 500);
private setupGroupCallPeekTimeouts(): void { #setupGroupCallPeekTimeouts(): void {
this.cleanupGroupCallPeekTimeouts(); this.#cleanupGroupCallPeekTimeouts();
this.delayedPeekTimeout = setTimeout(() => { this.#delayedPeekTimeout = setTimeout(() => {
const { id, peekGroupCallForTheFirstTime } = this.props; const { id, peekGroupCallForTheFirstTime } = this.props;
this.delayedPeekTimeout = undefined; this.#delayedPeekTimeout = undefined;
peekGroupCallForTheFirstTime(id); peekGroupCallForTheFirstTime(id);
}, 500); }, 500);
this.peekInterval = setInterval(() => { this.#peekInterval = setInterval(() => {
const { id, peekGroupCallIfItHasMembers } = this.props; const { id, peekGroupCallIfItHasMembers } = this.props;
peekGroupCallIfItHasMembers(id); peekGroupCallIfItHasMembers(id);
}, MINUTE); }, MINUTE);
} }
private cleanupGroupCallPeekTimeouts(): void { #cleanupGroupCallPeekTimeouts(): void {
const { delayedPeekTimeout, peekInterval } = this; const peekInterval = this.#peekInterval;
const delayedPeekTimeout = this.#delayedPeekTimeout;
clearTimeoutIfNecessary(delayedPeekTimeout); clearTimeoutIfNecessary(delayedPeekTimeout);
this.delayedPeekTimeout = undefined; this.#delayedPeekTimeout = undefined;
if (peekInterval) { if (peekInterval) {
clearInterval(peekInterval); clearInterval(peekInterval);
this.peekInterval = undefined; this.#peekInterval = undefined;
} }
} }
public override componentDidMount(): void { public override componentDidMount(): void {
const containerEl = this.containerRef.current; const containerEl = this.#containerRef.current;
const messagesEl = this.messagesRef.current; const messagesEl = this.#messagesRef.current;
const { conversationType, isConversationSelected } = this.props; const { conversationType, isConversationSelected } = this.props;
strictAssert( strictAssert(
// We don't render anything unless the conversation is selected // We don't render anything unless the conversation is selected
@ -544,31 +542,31 @@ export class Timeline extends React.Component<
'<Timeline> mounted without some refs' '<Timeline> mounted without some refs'
); );
this.updateIntersectionObserver(); this.#updateIntersectionObserver();
window.SignalContext.activeWindowService.registerForActive( window.SignalContext.activeWindowService.registerForActive(
this.markNewestBottomVisibleMessageRead this.#markNewestBottomVisibleMessageRead
); );
if (conversationType === 'group') { if (conversationType === 'group') {
this.setupGroupCallPeekTimeouts(); this.#setupGroupCallPeekTimeouts();
} }
} }
public override componentWillUnmount(): void { public override componentWillUnmount(): void {
window.SignalContext.activeWindowService.unregisterForActive( window.SignalContext.activeWindowService.unregisterForActive(
this.markNewestBottomVisibleMessageRead this.#markNewestBottomVisibleMessageRead
); );
this.intersectionObserver?.disconnect(); this.#intersectionObserver?.disconnect();
this.cleanupGroupCallPeekTimeouts(); this.#cleanupGroupCallPeekTimeouts();
this.props.updateVisibleMessages?.([]); this.props.updateVisibleMessages?.([]);
} }
public override getSnapshotBeforeUpdate( public override getSnapshotBeforeUpdate(
prevProps: Readonly<PropsType> prevProps: Readonly<PropsType>
): SnapshotType { ): SnapshotType {
const containerEl = this.containerRef.current; const containerEl = this.#containerRef.current;
if (!containerEl) { if (!containerEl) {
return null; return null;
} }
@ -579,7 +577,7 @@ export class Timeline extends React.Component<
const scrollAnchor = getScrollAnchorBeforeUpdate( const scrollAnchor = getScrollAnchorBeforeUpdate(
prevProps, prevProps,
props, props,
this.isAtBottom() this.#isAtBottom()
); );
switch (scrollAnchor) { switch (scrollAnchor) {
@ -627,10 +625,10 @@ export class Timeline extends React.Component<
messageLoadingState, messageLoadingState,
} = this.props; } = this.props;
const containerEl = this.containerRef.current; const containerEl = this.#containerRef.current;
if (!this.scrollerLock.isLocked() && containerEl && snapshot) { if (!this.#scrollerLock.isLocked() && containerEl && snapshot) {
if (snapshot === scrollToUnreadIndicator) { if (snapshot === scrollToUnreadIndicator) {
const lastSeenIndicatorEl = this.lastSeenIndicatorRef.current; const lastSeenIndicatorEl = this.#lastSeenIndicatorRef.current;
if (lastSeenIndicatorEl) { if (lastSeenIndicatorEl) {
lastSeenIndicatorEl.scrollIntoView(); lastSeenIndicatorEl.scrollIntoView();
} else { } else {
@ -641,7 +639,7 @@ export class Timeline extends React.Component<
); );
} }
} else if ('scrollToIndex' in snapshot) { } else if ('scrollToIndex' in snapshot) {
this.scrollToItemIndex(snapshot.scrollToIndex); this.#scrollToItemIndex(snapshot.scrollToIndex);
} else if ('scrollTop' in snapshot) { } else if ('scrollTop' in snapshot) {
containerEl.scrollTop = snapshot.scrollTop; containerEl.scrollTop = snapshot.scrollTop;
} else { } else {
@ -657,12 +655,12 @@ export class Timeline extends React.Component<
oldItems.at(-1) !== newItems.at(-1); oldItems.at(-1) !== newItems.at(-1);
if (haveItemsChanged) { if (haveItemsChanged) {
this.updateIntersectionObserver(); this.#updateIntersectionObserver();
// This condition is somewhat arbitrary. // This condition is somewhat arbitrary.
const numberToKeepAtBottom = this.maxVisibleRows * 2; const numberToKeepAtBottom = this.#maxVisibleRows * 2;
const shouldDiscardOlderMessages: boolean = const shouldDiscardOlderMessages: boolean =
this.isAtBottom() && newItems.length > numberToKeepAtBottom; this.#isAtBottom() && newItems.length > numberToKeepAtBottom;
if (shouldDiscardOlderMessages) { if (shouldDiscardOlderMessages) {
discardMessages({ discardMessages({
conversationId: id, conversationId: id,
@ -676,9 +674,9 @@ export class Timeline extends React.Component<
!messageLoadingState && previousMessageLoadingState !messageLoadingState && previousMessageLoadingState
? previousMessageLoadingState ? previousMessageLoadingState
: undefined; : undefined;
const numberToKeepAtTop = this.maxVisibleRows * 5; const numberToKeepAtTop = this.#maxVisibleRows * 5;
const shouldDiscardNewerMessages: boolean = const shouldDiscardNewerMessages: boolean =
!this.isAtBottom() && !this.#isAtBottom() &&
loadingStateThatJustFinished === loadingStateThatJustFinished ===
TimelineMessageLoadingState.LoadingOlderMessages && TimelineMessageLoadingState.LoadingOlderMessages &&
newItems.length > numberToKeepAtTop; newItems.length > numberToKeepAtTop;
@ -691,18 +689,18 @@ export class Timeline extends React.Component<
} }
} }
if (previousMessageChangeCounter !== messageChangeCounter) { if (previousMessageChangeCounter !== messageChangeCounter) {
this.markNewestBottomVisibleMessageRead(); this.#markNewestBottomVisibleMessageRead();
} }
if (previousConversationType !== conversationType) { if (previousConversationType !== conversationType) {
this.cleanupGroupCallPeekTimeouts(); this.#cleanupGroupCallPeekTimeouts();
if (conversationType === 'group') { if (conversationType === 'group') {
this.setupGroupCallPeekTimeouts(); this.#setupGroupCallPeekTimeouts();
} }
} }
} }
private handleBlur = (event: React.FocusEvent): void => { #handleBlur = (event: React.FocusEvent): void => {
const { clearTargetedMessage } = this.props; const { clearTargetedMessage } = this.props;
const { currentTarget } = event; const { currentTarget } = event;
@ -726,9 +724,7 @@ export class Timeline extends React.Component<
}, 0); }, 0);
}; };
private handleKeyDown = ( #handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>): void => {
event: React.KeyboardEvent<HTMLDivElement>
): void => {
const { targetMessage, targetedMessageId, items, id } = this.props; const { targetMessage, targetedMessageId, items, id } = this.props;
const commandKey = get(window, 'platform') === 'darwin' && event.metaKey; const commandKey = get(window, 'platform') === 'darwin' && event.metaKey;
const controlKey = get(window, 'platform') !== 'darwin' && event.ctrlKey; const controlKey = get(window, 'platform') !== 'darwin' && event.ctrlKey;
@ -803,7 +799,7 @@ export class Timeline extends React.Component<
} }
if (event.key === 'End' || (commandOrCtrl && event.key === 'ArrowDown')) { if (event.key === 'End' || (commandOrCtrl && event.key === 'ArrowDown')) {
this.scrollDown(true); this.#scrollDown(true);
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
} }
@ -939,7 +935,7 @@ export class Timeline extends React.Component<
key="last seen indicator" key="last seen indicator"
count={totalUnseen} count={totalUnseen}
i18n={i18n} i18n={i18n}
ref={this.lastSeenIndicatorRef} ref={this.#lastSeenIndicatorRef}
/> />
); );
} else if (oldestUnseenIndex === nextItemIndex) { } else if (oldestUnseenIndex === nextItemIndex) {
@ -964,7 +960,7 @@ export class Timeline extends React.Component<
> >
<ErrorBoundary i18n={i18n} showDebugLog={showDebugLog}> <ErrorBoundary i18n={i18n} showDebugLog={showDebugLog}>
{renderItem({ {renderItem({
containerElementRef: this.containerRef, containerElementRef: this.#containerRef,
containerWidthBreakpoint: widthBreakpoint, containerWidthBreakpoint: widthBreakpoint,
conversationId: id, conversationId: id,
isBlocked, isBlocked,
@ -1098,7 +1094,7 @@ export class Timeline extends React.Component<
} }
return ( return (
<ScrollerLockContext.Provider value={this.scrollerLock}> <ScrollerLockContext.Provider value={this.#scrollerLock}>
<SizeObserver <SizeObserver
onSizeChange={size => { onSizeChange={size => {
const { isNearBottom } = this.props; const { isNearBottom } = this.props;
@ -1107,9 +1103,9 @@ export class Timeline extends React.Component<
widthBreakpoint: getWidthBreakpoint(size.width), widthBreakpoint: getWidthBreakpoint(size.width),
}); });
this.maxVisibleRows = Math.ceil(size.height / MIN_ROW_HEIGHT); this.#maxVisibleRows = Math.ceil(size.height / MIN_ROW_HEIGHT);
const containerEl = this.containerRef.current; const containerEl = this.#containerRef.current;
if (containerEl && isNearBottom) { if (containerEl && isNearBottom) {
scrollToBottom(containerEl); scrollToBottom(containerEl);
} }
@ -1124,8 +1120,8 @@ export class Timeline extends React.Component<
)} )}
role="presentation" role="presentation"
tabIndex={-1} tabIndex={-1}
onBlur={this.handleBlur} onBlur={this.#handleBlur}
onKeyDown={this.handleKeyDown} onKeyDown={this.#handleKeyDown}
ref={ref} ref={ref}
> >
{headerElements} {headerElements}
@ -1134,8 +1130,8 @@ export class Timeline extends React.Component<
<main <main
className="module-timeline__messages__container" className="module-timeline__messages__container"
onScroll={this.onScroll} onScroll={this.#onScroll}
ref={this.containerRef} ref={this.#containerRef}
> >
<div <div
className={classNames( className={classNames(
@ -1144,7 +1140,7 @@ export class Timeline extends React.Component<
haveOldest && 'module-timeline__messages--have-oldest', haveOldest && 'module-timeline__messages--have-oldest',
scrollLocked && 'module-timeline__messages--scroll-locked' scrollLocked && 'module-timeline__messages--scroll-locked'
)} )}
ref={this.messagesRef} ref={this.#messagesRef}
role="list" role="list"
> >
{haveOldest && ( {haveOldest && (
@ -1162,7 +1158,7 @@ export class Timeline extends React.Component<
<div <div
className="module-timeline__messages__at-bottom-detector" className="module-timeline__messages__at-bottom-detector"
ref={this.atBottomDetectorRef} ref={this.#atBottomDetectorRef}
style={AT_BOTTOM_DETECTOR_STYLE} style={AT_BOTTOM_DETECTOR_STYLE}
/> />
</div> </div>
@ -1181,7 +1177,7 @@ export class Timeline extends React.Component<
<ScrollDownButton <ScrollDownButton
variant={ScrollDownButtonVariant.UNREAD_MESSAGES} variant={ScrollDownButtonVariant.UNREAD_MESSAGES}
count={areUnreadBelowCurrentPosition ? unreadCount : 0} count={areUnreadBelowCurrentPosition ? unreadCount : 0}
onClick={this.onClickScrollDownButton} onClick={this.#onClickScrollDownButton}
i18n={i18n} i18n={i18n}
/> />
</div> </div>

View file

@ -34,29 +34,24 @@ export type LeftPaneArchivePropsType =
| (LeftPaneArchiveBasePropsType & LeftPaneSearchPropsType); | (LeftPaneArchiveBasePropsType & LeftPaneSearchPropsType);
export class LeftPaneArchiveHelper extends LeftPaneHelper<LeftPaneArchivePropsType> { export class LeftPaneArchiveHelper extends LeftPaneHelper<LeftPaneArchivePropsType> {
private readonly archivedConversations: ReadonlyArray<ConversationListItemPropsType>; readonly #archivedConversations: ReadonlyArray<ConversationListItemPropsType>;
readonly #isSearchingGlobally: boolean;
private readonly isSearchingGlobally: boolean; readonly #searchConversation: undefined | ConversationType;
readonly #searchTerm: string;
private readonly searchConversation: undefined | ConversationType; readonly #searchHelper: undefined | LeftPaneSearchHelper;
readonly #startSearchCounter: number;
private readonly searchTerm: string;
private readonly searchHelper: undefined | LeftPaneSearchHelper;
private readonly startSearchCounter: number;
constructor(props: Readonly<LeftPaneArchivePropsType>) { constructor(props: Readonly<LeftPaneArchivePropsType>) {
super(); super();
this.archivedConversations = props.archivedConversations; this.#archivedConversations = props.archivedConversations;
this.isSearchingGlobally = props.isSearchingGlobally; this.#isSearchingGlobally = props.isSearchingGlobally;
this.searchConversation = props.searchConversation; this.#searchConversation = props.searchConversation;
this.searchTerm = props.searchTerm; this.#searchTerm = props.searchTerm;
this.startSearchCounter = props.startSearchCounter; this.#startSearchCounter = props.startSearchCounter;
if ('conversationResults' in props) { if ('conversationResults' in props) {
this.searchHelper = new LeftPaneSearchHelper(props); this.#searchHelper = new LeftPaneSearchHelper(props);
} }
} }
@ -100,7 +95,7 @@ export class LeftPaneArchiveHelper extends LeftPaneHelper<LeftPaneArchivePropsTy
updateSearchTerm: (searchTerm: string) => unknown; updateSearchTerm: (searchTerm: string) => unknown;
showConversation: ShowConversationType; showConversation: ShowConversationType;
}>): ReactChild | null { }>): ReactChild | null {
if (!this.searchConversation) { if (!this.#searchConversation) {
return null; return null;
} }
@ -111,11 +106,11 @@ export class LeftPaneArchiveHelper extends LeftPaneHelper<LeftPaneArchivePropsTy
endConversationSearch={endConversationSearch} endConversationSearch={endConversationSearch}
endSearch={endSearch} endSearch={endSearch}
i18n={i18n} i18n={i18n}
isSearchingGlobally={this.isSearchingGlobally} isSearchingGlobally={this.#isSearchingGlobally}
searchConversation={this.searchConversation} searchConversation={this.#searchConversation}
searchTerm={this.searchTerm} searchTerm={this.#searchTerm}
showConversation={showConversation} showConversation={showConversation}
startSearchCounter={this.startSearchCounter} startSearchCounter={this.#startSearchCounter}
updateSearchTerm={updateSearchTerm} updateSearchTerm={updateSearchTerm}
/> />
); );
@ -128,8 +123,8 @@ export class LeftPaneArchiveHelper extends LeftPaneHelper<LeftPaneArchivePropsTy
override getPreRowsNode({ override getPreRowsNode({
i18n, i18n,
}: Readonly<{ i18n: LocalizerType }>): ReactChild | null { }: Readonly<{ i18n: LocalizerType }>): ReactChild | null {
if (this.searchHelper) { if (this.#searchHelper) {
return this.searchHelper.getPreRowsNode({ i18n }); return this.#searchHelper.getPreRowsNode({ i18n });
} }
return ( return (
@ -143,16 +138,16 @@ export class LeftPaneArchiveHelper extends LeftPaneHelper<LeftPaneArchivePropsTy
getRowCount(): number { getRowCount(): number {
return ( return (
this.searchHelper?.getRowCount() ?? this.archivedConversations.length this.#searchHelper?.getRowCount() ?? this.#archivedConversations.length
); );
} }
getRow(rowIndex: number): undefined | Row { getRow(rowIndex: number): undefined | Row {
if (this.searchHelper) { if (this.#searchHelper) {
return this.searchHelper.getRow(rowIndex); return this.#searchHelper.getRow(rowIndex);
} }
const conversation = this.archivedConversations[rowIndex]; const conversation = this.#archivedConversations[rowIndex];
return conversation return conversation
? { ? {
type: RowType.Conversation, type: RowType.Conversation,
@ -164,14 +159,14 @@ export class LeftPaneArchiveHelper extends LeftPaneHelper<LeftPaneArchivePropsTy
override getRowIndexToScrollTo( override getRowIndexToScrollTo(
selectedConversationId: undefined | string selectedConversationId: undefined | string
): undefined | number { ): undefined | number {
if (this.searchHelper) { if (this.#searchHelper) {
return this.searchHelper.getRowIndexToScrollTo(selectedConversationId); return this.#searchHelper.getRowIndexToScrollTo(selectedConversationId);
} }
if (!selectedConversationId) { if (!selectedConversationId) {
return undefined; return undefined;
} }
const result = this.archivedConversations.findIndex( const result = this.#archivedConversations.findIndex(
conversation => conversation.id === selectedConversationId conversation => conversation.id === selectedConversationId
); );
return result === -1 ? undefined : result; return result === -1 ? undefined : result;
@ -180,7 +175,8 @@ export class LeftPaneArchiveHelper extends LeftPaneHelper<LeftPaneArchivePropsTy
getConversationAndMessageAtIndex( getConversationAndMessageAtIndex(
conversationIndex: number conversationIndex: number
): undefined | { conversationId: string } { ): undefined | { conversationId: string } {
const { archivedConversations, searchHelper } = this; const searchHelper = this.#searchHelper;
const archivedConversations = this.#archivedConversations;
if (searchHelper) { if (searchHelper) {
return searchHelper.getConversationAndMessageAtIndex(conversationIndex); return searchHelper.getConversationAndMessageAtIndex(conversationIndex);
@ -196,8 +192,8 @@ export class LeftPaneArchiveHelper extends LeftPaneHelper<LeftPaneArchivePropsTy
selectedConversationId: undefined | string, selectedConversationId: undefined | string,
targetedMessageId: unknown targetedMessageId: unknown
): undefined | { conversationId: string } { ): undefined | { conversationId: string } {
if (this.searchHelper) { if (this.#searchHelper) {
return this.searchHelper.getConversationAndMessageInDirection( return this.#searchHelper.getConversationAndMessageInDirection(
toFind, toFind,
selectedConversationId, selectedConversationId,
targetedMessageId targetedMessageId
@ -205,7 +201,7 @@ export class LeftPaneArchiveHelper extends LeftPaneHelper<LeftPaneArchivePropsTy
} }
return getConversationInDirection( return getConversationInDirection(
this.archivedConversations, this.#archivedConversations,
toFind, toFind,
selectedConversationId selectedConversationId
); );
@ -213,13 +209,13 @@ export class LeftPaneArchiveHelper extends LeftPaneHelper<LeftPaneArchivePropsTy
shouldRecomputeRowHeights(old: Readonly<LeftPaneArchivePropsType>): boolean { shouldRecomputeRowHeights(old: Readonly<LeftPaneArchivePropsType>): boolean {
const hasSearchingChanged = const hasSearchingChanged =
'conversationResults' in old !== Boolean(this.searchHelper); 'conversationResults' in old !== Boolean(this.#searchHelper);
if (hasSearchingChanged) { if (hasSearchingChanged) {
return true; return true;
} }
if ('conversationResults' in old && this.searchHelper) { if ('conversationResults' in old && this.#searchHelper) {
return this.searchHelper.shouldRecomputeRowHeights(old); return this.#searchHelper.shouldRecomputeRowHeights(old);
} }
return false; return false;
@ -251,7 +247,9 @@ export class LeftPaneArchiveHelper extends LeftPaneHelper<LeftPaneArchivePropsTy
!commandAndCtrl && !commandAndCtrl &&
shiftKey && shiftKey &&
(key === 'f' || key === 'F') && (key === 'f' || key === 'F') &&
this.archivedConversations.some(({ id }) => id === selectedConversationId) this.#archivedConversations.some(
({ id }) => id === selectedConversationId
)
) { ) {
searchInConversation(selectedConversationId); searchInConversation(selectedConversationId);

View file

@ -42,31 +42,19 @@ export type LeftPaneChooseGroupMembersPropsType = {
}; };
export class LeftPaneChooseGroupMembersHelper extends LeftPaneHelper<LeftPaneChooseGroupMembersPropsType> { export class LeftPaneChooseGroupMembersHelper extends LeftPaneHelper<LeftPaneChooseGroupMembersPropsType> {
private readonly candidateContacts: ReadonlyArray<ConversationType>; readonly #candidateContacts: ReadonlyArray<ConversationType>;
readonly #isPhoneNumberChecked: boolean;
private readonly isPhoneNumberChecked: boolean; readonly #isUsernameChecked: boolean;
readonly #isShowingMaximumGroupSizeModal: boolean;
private readonly isUsernameChecked: boolean; readonly #isShowingRecommendedGroupSizeModal: boolean;
readonly #groupSizeRecommendedLimit: number;
private readonly isShowingMaximumGroupSizeModal: boolean; readonly #groupSizeHardLimit: number;
readonly #searchTerm: string;
private readonly isShowingRecommendedGroupSizeModal: boolean; readonly #phoneNumber: ParsedE164Type | undefined;
readonly #username: string | undefined;
private readonly groupSizeRecommendedLimit: number; readonly #selectedContacts: Array<ConversationType>;
readonly #selectedConversationIdsSet: Set<string>;
private readonly groupSizeHardLimit: number; readonly #uuidFetchState: UUIDFetchStateType;
private readonly searchTerm: string;
private readonly phoneNumber: ParsedE164Type | undefined;
private readonly username: string | undefined;
private readonly selectedContacts: Array<ConversationType>;
private readonly selectedConversationIdsSet: Set<string>;
private readonly uuidFetchState: UUIDFetchStateType;
constructor({ constructor({
candidateContacts, candidateContacts,
@ -84,27 +72,27 @@ export class LeftPaneChooseGroupMembersHelper extends LeftPaneHelper<LeftPaneCho
}: Readonly<LeftPaneChooseGroupMembersPropsType>) { }: Readonly<LeftPaneChooseGroupMembersPropsType>) {
super(); super();
this.uuidFetchState = uuidFetchState; this.#uuidFetchState = uuidFetchState;
this.groupSizeRecommendedLimit = groupSizeRecommendedLimit - 1; this.#groupSizeRecommendedLimit = groupSizeRecommendedLimit - 1;
this.groupSizeHardLimit = groupSizeHardLimit - 1; this.#groupSizeHardLimit = groupSizeHardLimit - 1;
this.candidateContacts = candidateContacts; this.#candidateContacts = candidateContacts;
this.isShowingMaximumGroupSizeModal = isShowingMaximumGroupSizeModal; this.#isShowingMaximumGroupSizeModal = isShowingMaximumGroupSizeModal;
this.isShowingRecommendedGroupSizeModal = this.#isShowingRecommendedGroupSizeModal =
isShowingRecommendedGroupSizeModal; isShowingRecommendedGroupSizeModal;
this.searchTerm = searchTerm; this.#searchTerm = searchTerm;
const isUsernameVisible = const isUsernameVisible =
username !== undefined && username !== undefined &&
username !== ourUsername && username !== ourUsername &&
this.candidateContacts.every(contact => contact.username !== username); this.#candidateContacts.every(contact => contact.username !== username);
if (isUsernameVisible) { if (isUsernameVisible) {
this.username = username; this.#username = username;
} }
this.isUsernameChecked = selectedContacts.some( this.#isUsernameChecked = selectedContacts.some(
contact => contact.username === this.username contact => contact.username === this.#username
); );
const phoneNumber = parseAndFormatPhoneNumber(searchTerm, regionCode); const phoneNumber = parseAndFormatPhoneNumber(searchTerm, regionCode);
@ -114,22 +102,22 @@ export class LeftPaneChooseGroupMembersHelper extends LeftPaneHelper<LeftPaneCho
phoneNumber phoneNumber
) { ) {
const { e164 } = phoneNumber; const { e164 } = phoneNumber;
this.isPhoneNumberChecked = this.#isPhoneNumberChecked =
phoneNumber.isValid && phoneNumber.isValid &&
selectedContacts.some(contact => contact.e164 === e164); selectedContacts.some(contact => contact.e164 === e164);
const isVisible = const isVisible =
e164 !== ourE164 && e164 !== ourE164 &&
this.candidateContacts.every(contact => contact.e164 !== e164); this.#candidateContacts.every(contact => contact.e164 !== e164);
if (isVisible) { if (isVisible) {
this.phoneNumber = phoneNumber; this.#phoneNumber = phoneNumber;
} }
} else { } else {
this.isPhoneNumberChecked = false; this.#isPhoneNumberChecked = false;
} }
this.selectedContacts = selectedContacts; this.#selectedContacts = selectedContacts;
this.selectedConversationIdsSet = new Set( this.#selectedConversationIdsSet = new Set(
selectedContacts.map(contact => contact.id) selectedContacts.map(contact => contact.id)
); );
} }
@ -183,7 +171,7 @@ export class LeftPaneChooseGroupMembersHelper extends LeftPaneHelper<LeftPaneCho
onChange={onChangeComposeSearchTerm} onChange={onChangeComposeSearchTerm}
placeholder={i18n('icu:contactSearchPlaceholder')} placeholder={i18n('icu:contactSearchPlaceholder')}
ref={focusRef} ref={focusRef}
value={this.searchTerm} value={this.#searchTerm}
/> />
); );
} }
@ -200,20 +188,20 @@ export class LeftPaneChooseGroupMembersHelper extends LeftPaneHelper<LeftPaneCho
removeSelectedContact: (conversationId: string) => unknown; removeSelectedContact: (conversationId: string) => unknown;
}>): ReactChild { }>): ReactChild {
let modalNode: undefined | ReactChild; let modalNode: undefined | ReactChild;
if (this.isShowingMaximumGroupSizeModal) { if (this.#isShowingMaximumGroupSizeModal) {
modalNode = ( modalNode = (
<AddGroupMemberErrorDialog <AddGroupMemberErrorDialog
i18n={i18n} i18n={i18n}
maximumNumberOfContacts={this.groupSizeHardLimit} maximumNumberOfContacts={this.#groupSizeHardLimit}
mode={AddGroupMemberErrorDialogMode.MaximumGroupSize} mode={AddGroupMemberErrorDialogMode.MaximumGroupSize}
onClose={closeMaximumGroupSizeModal} onClose={closeMaximumGroupSizeModal}
/> />
); );
} else if (this.isShowingRecommendedGroupSizeModal) { } else if (this.#isShowingRecommendedGroupSizeModal) {
modalNode = ( modalNode = (
<AddGroupMemberErrorDialog <AddGroupMemberErrorDialog
i18n={i18n} i18n={i18n}
recommendedMaximumNumberOfContacts={this.groupSizeRecommendedLimit} recommendedMaximumNumberOfContacts={this.#groupSizeRecommendedLimit}
mode={AddGroupMemberErrorDialogMode.RecommendedMaximumGroupSize} mode={AddGroupMemberErrorDialogMode.RecommendedMaximumGroupSize}
onClose={closeRecommendedGroupSizeModal} onClose={closeRecommendedGroupSizeModal}
/> />
@ -222,9 +210,9 @@ export class LeftPaneChooseGroupMembersHelper extends LeftPaneHelper<LeftPaneCho
return ( return (
<> <>
{Boolean(this.selectedContacts.length) && ( {Boolean(this.#selectedContacts.length) && (
<ContactPills> <ContactPills>
{this.selectedContacts.map(contact => ( {this.#selectedContacts.map(contact => (
<ContactPill <ContactPill
key={contact.id} key={contact.id}
acceptedMessageRequest={contact.acceptedMessageRequest} acceptedMessageRequest={contact.acceptedMessageRequest}
@ -264,10 +252,10 @@ export class LeftPaneChooseGroupMembersHelper extends LeftPaneHelper<LeftPaneCho
}>): ReactChild { }>): ReactChild {
return ( return (
<Button <Button
disabled={this.hasExceededMaximumNumberOfContacts()} disabled={this.#hasExceededMaximumNumberOfContacts()}
onClick={startSettingGroupMetadata} onClick={startSettingGroupMetadata}
> >
{this.selectedContacts.length {this.#selectedContacts.length
? i18n('icu:chooseGroupMembers__next') ? i18n('icu:chooseGroupMembers__next')
: i18n('icu:chooseGroupMembers__skip')} : i18n('icu:chooseGroupMembers__skip')}
</Button> </Button>
@ -278,18 +266,18 @@ export class LeftPaneChooseGroupMembersHelper extends LeftPaneHelper<LeftPaneCho
let rowCount = 0; let rowCount = 0;
// Header + Phone Number // Header + Phone Number
if (this.phoneNumber) { if (this.#phoneNumber) {
rowCount += 2; rowCount += 2;
} }
// Header + Username // Header + Username
if (this.username) { if (this.#username) {
rowCount += 2; rowCount += 2;
} }
// Header + Contacts // Header + Contacts
if (this.candidateContacts.length) { if (this.#candidateContacts.length) {
rowCount += 1 + this.candidateContacts.length; rowCount += 1 + this.#candidateContacts.length;
} }
// Footer // Footer
@ -301,7 +289,11 @@ export class LeftPaneChooseGroupMembersHelper extends LeftPaneHelper<LeftPaneCho
} }
getRow(actualRowIndex: number): undefined | Row { getRow(actualRowIndex: number): undefined | Row {
if (!this.candidateContacts.length && !this.phoneNumber && !this.username) { if (
!this.#candidateContacts.length &&
!this.#phoneNumber &&
!this.#username
) {
return undefined; return undefined;
} }
@ -314,7 +306,7 @@ export class LeftPaneChooseGroupMembersHelper extends LeftPaneHelper<LeftPaneCho
let virtualRowIndex = actualRowIndex; let virtualRowIndex = actualRowIndex;
if (this.candidateContacts.length) { if (this.#candidateContacts.length) {
if (virtualRowIndex === 0) { if (virtualRowIndex === 0) {
return { return {
type: RowType.Header, type: RowType.Header,
@ -322,12 +314,12 @@ export class LeftPaneChooseGroupMembersHelper extends LeftPaneHelper<LeftPaneCho
}; };
} }
if (virtualRowIndex <= this.candidateContacts.length) { if (virtualRowIndex <= this.#candidateContacts.length) {
const contact = this.candidateContacts[virtualRowIndex - 1]; const contact = this.#candidateContacts[virtualRowIndex - 1];
const isChecked = this.selectedConversationIdsSet.has(contact.id); const isChecked = this.#selectedConversationIdsSet.has(contact.id);
const disabledReason = const disabledReason =
!isChecked && this.hasSelectedMaximumNumberOfContacts() !isChecked && this.#hasSelectedMaximumNumberOfContacts()
? ContactCheckboxDisabledReason.MaximumContactsSelected ? ContactCheckboxDisabledReason.MaximumContactsSelected
: undefined; : undefined;
@ -339,10 +331,10 @@ export class LeftPaneChooseGroupMembersHelper extends LeftPaneHelper<LeftPaneCho
}; };
} }
virtualRowIndex -= 1 + this.candidateContacts.length; virtualRowIndex -= 1 + this.#candidateContacts.length;
} }
if (this.phoneNumber) { if (this.#phoneNumber) {
if (virtualRowIndex === 0) { if (virtualRowIndex === 0) {
return { return {
type: RowType.Header, type: RowType.Header,
@ -352,18 +344,18 @@ export class LeftPaneChooseGroupMembersHelper extends LeftPaneHelper<LeftPaneCho
if (virtualRowIndex === 1) { if (virtualRowIndex === 1) {
return { return {
type: RowType.PhoneNumberCheckbox, type: RowType.PhoneNumberCheckbox,
isChecked: this.isPhoneNumberChecked, isChecked: this.#isPhoneNumberChecked,
isFetching: isFetchingByE164( isFetching: isFetchingByE164(
this.uuidFetchState, this.#uuidFetchState,
this.phoneNumber.e164 this.#phoneNumber.e164
), ),
phoneNumber: this.phoneNumber, phoneNumber: this.#phoneNumber,
}; };
} }
virtualRowIndex -= 2; virtualRowIndex -= 2;
} }
if (this.username) { if (this.#username) {
if (virtualRowIndex === 0) { if (virtualRowIndex === 0) {
return { return {
type: RowType.Header, type: RowType.Header,
@ -373,9 +365,12 @@ export class LeftPaneChooseGroupMembersHelper extends LeftPaneHelper<LeftPaneCho
if (virtualRowIndex === 1) { if (virtualRowIndex === 1) {
return { return {
type: RowType.UsernameCheckbox, type: RowType.UsernameCheckbox,
isChecked: this.isUsernameChecked, isChecked: this.#isUsernameChecked,
isFetching: isFetchingByUsername(this.uuidFetchState, this.username), isFetching: isFetchingByUsername(
username: this.username, this.#uuidFetchState,
this.#username
),
username: this.#username,
}; };
} }
virtualRowIndex -= 2; virtualRowIndex -= 2;
@ -402,13 +397,13 @@ export class LeftPaneChooseGroupMembersHelper extends LeftPaneHelper<LeftPaneCho
return false; return false;
} }
private hasSelectedMaximumNumberOfContacts(): boolean { #hasSelectedMaximumNumberOfContacts(): boolean {
return this.selectedContacts.length >= this.groupSizeHardLimit; return this.#selectedContacts.length >= this.#groupSizeHardLimit;
} }
private hasExceededMaximumNumberOfContacts(): boolean { #hasExceededMaximumNumberOfContacts(): boolean {
// It should be impossible to reach this state. This is here as a failsafe. // It should be impossible to reach this state. This is here as a failsafe.
return this.selectedContacts.length > this.groupSizeHardLimit; return this.#selectedContacts.length > this.#groupSizeHardLimit;
} }
} }

View file

@ -35,21 +35,14 @@ enum TopButtons {
} }
export class LeftPaneComposeHelper extends LeftPaneHelper<LeftPaneComposePropsType> { export class LeftPaneComposeHelper extends LeftPaneHelper<LeftPaneComposePropsType> {
private readonly composeContacts: ReadonlyArray<ContactListItemConversationType>; readonly #composeContacts: ReadonlyArray<ContactListItemConversationType>;
readonly #composeGroups: ReadonlyArray<GroupListItemConversationType>;
private readonly composeGroups: ReadonlyArray<GroupListItemConversationType>; readonly #uuidFetchState: UUIDFetchStateType;
readonly #searchTerm: string;
private readonly uuidFetchState: UUIDFetchStateType; readonly #phoneNumber: ParsedE164Type | undefined;
readonly #isPhoneNumberVisible: boolean;
private readonly searchTerm: string; readonly #username: string | undefined;
readonly #isUsernameVisible: boolean;
private readonly phoneNumber: ParsedE164Type | undefined;
private readonly isPhoneNumberVisible: boolean;
private readonly username: string | undefined;
private readonly isUsernameVisible: boolean;
constructor({ constructor({
composeContacts, composeContacts,
@ -61,24 +54,24 @@ export class LeftPaneComposeHelper extends LeftPaneHelper<LeftPaneComposePropsTy
}: Readonly<LeftPaneComposePropsType>) { }: Readonly<LeftPaneComposePropsType>) {
super(); super();
this.composeContacts = composeContacts; this.#composeContacts = composeContacts;
this.composeGroups = composeGroups; this.#composeGroups = composeGroups;
this.searchTerm = searchTerm; this.#searchTerm = searchTerm;
this.uuidFetchState = uuidFetchState; this.#uuidFetchState = uuidFetchState;
this.username = username; this.#username = username;
this.isUsernameVisible = this.#isUsernameVisible =
Boolean(username) && Boolean(username) &&
this.composeContacts.every(contact => contact.username !== username); this.#composeContacts.every(contact => contact.username !== username);
const phoneNumber = parseAndFormatPhoneNumber(searchTerm, regionCode); const phoneNumber = parseAndFormatPhoneNumber(searchTerm, regionCode);
if (!username && phoneNumber) { if (!username && phoneNumber) {
this.phoneNumber = phoneNumber; this.#phoneNumber = phoneNumber;
this.isPhoneNumberVisible = this.composeContacts.every( this.#isPhoneNumberVisible = this.#composeContacts.every(
contact => contact.e164 !== phoneNumber.e164 contact => contact.e164 !== phoneNumber.e164
); );
} else { } else {
this.isPhoneNumberVisible = false; this.#isPhoneNumberVisible = false;
} }
} }
@ -125,7 +118,7 @@ export class LeftPaneComposeHelper extends LeftPaneHelper<LeftPaneComposePropsTy
onChange={onChangeComposeSearchTerm} onChange={onChangeComposeSearchTerm}
placeholder={i18n('icu:contactSearchPlaceholder')} placeholder={i18n('icu:contactSearchPlaceholder')}
ref={focusRef} ref={focusRef}
value={this.searchTerm} value={this.#searchTerm}
/> />
); );
} }
@ -143,20 +136,20 @@ export class LeftPaneComposeHelper extends LeftPaneHelper<LeftPaneComposePropsTy
} }
getRowCount(): number { getRowCount(): number {
let result = this.composeContacts.length + this.composeGroups.length; let result = this.#composeContacts.length + this.#composeGroups.length;
if (this.hasTopButtons()) { if (this.#hasTopButtons()) {
result += 3; result += 3;
} }
if (this.hasContactsHeader()) { if (this.#hasContactsHeader()) {
result += 1; result += 1;
} }
if (this.hasGroupsHeader()) { if (this.#hasGroupsHeader()) {
result += 1; result += 1;
} }
if (this.isUsernameVisible) { if (this.#isUsernameVisible) {
result += 2; result += 2;
} }
if (this.isPhoneNumberVisible) { if (this.#isPhoneNumberVisible) {
result += 2; result += 2;
} }
@ -165,7 +158,7 @@ export class LeftPaneComposeHelper extends LeftPaneHelper<LeftPaneComposePropsTy
getRow(actualRowIndex: number): undefined | Row { getRow(actualRowIndex: number): undefined | Row {
let virtualRowIndex = actualRowIndex; let virtualRowIndex = actualRowIndex;
if (this.hasTopButtons()) { if (this.#hasTopButtons()) {
if (virtualRowIndex === 0) { if (virtualRowIndex === 0) {
return { type: RowType.CreateNewGroup }; return { type: RowType.CreateNewGroup };
} }
@ -179,7 +172,7 @@ export class LeftPaneComposeHelper extends LeftPaneHelper<LeftPaneComposePropsTy
virtualRowIndex -= 3; virtualRowIndex -= 3;
} }
if (this.hasContactsHeader()) { if (this.#hasContactsHeader()) {
if (virtualRowIndex === 0) { if (virtualRowIndex === 0) {
return { return {
type: RowType.Header, type: RowType.Header,
@ -189,7 +182,7 @@ export class LeftPaneComposeHelper extends LeftPaneHelper<LeftPaneComposePropsTy
virtualRowIndex -= 1; virtualRowIndex -= 1;
const contact = this.composeContacts[virtualRowIndex]; const contact = this.#composeContacts[virtualRowIndex];
if (contact) { if (contact) {
return { return {
type: RowType.Contact, type: RowType.Contact,
@ -198,10 +191,10 @@ export class LeftPaneComposeHelper extends LeftPaneHelper<LeftPaneComposePropsTy
}; };
} }
virtualRowIndex -= this.composeContacts.length; virtualRowIndex -= this.#composeContacts.length;
} }
if (this.hasGroupsHeader()) { if (this.#hasGroupsHeader()) {
if (virtualRowIndex === 0) { if (virtualRowIndex === 0) {
return { return {
type: RowType.Header, type: RowType.Header,
@ -211,7 +204,7 @@ export class LeftPaneComposeHelper extends LeftPaneHelper<LeftPaneComposePropsTy
virtualRowIndex -= 1; virtualRowIndex -= 1;
const group = this.composeGroups[virtualRowIndex]; const group = this.#composeGroups[virtualRowIndex];
if (group) { if (group) {
return { return {
type: RowType.SelectSingleGroup, type: RowType.SelectSingleGroup,
@ -219,10 +212,10 @@ export class LeftPaneComposeHelper extends LeftPaneHelper<LeftPaneComposePropsTy
}; };
} }
virtualRowIndex -= this.composeGroups.length; virtualRowIndex -= this.#composeGroups.length;
} }
if (this.username && this.isUsernameVisible) { if (this.#username && this.#isUsernameVisible) {
if (virtualRowIndex === 0) { if (virtualRowIndex === 0) {
return { return {
type: RowType.Header, type: RowType.Header,
@ -235,16 +228,16 @@ export class LeftPaneComposeHelper extends LeftPaneHelper<LeftPaneComposePropsTy
if (virtualRowIndex === 0) { if (virtualRowIndex === 0) {
return { return {
type: RowType.UsernameSearchResult, type: RowType.UsernameSearchResult,
username: this.username, username: this.#username,
isFetchingUsername: isFetchingByUsername( isFetchingUsername: isFetchingByUsername(
this.uuidFetchState, this.#uuidFetchState,
this.username this.#username
), ),
}; };
} }
} }
if (this.phoneNumber && this.isPhoneNumberVisible) { if (this.#phoneNumber && this.#isPhoneNumberVisible) {
if (virtualRowIndex === 0) { if (virtualRowIndex === 0) {
return { return {
type: RowType.Header, type: RowType.Header,
@ -257,10 +250,10 @@ export class LeftPaneComposeHelper extends LeftPaneHelper<LeftPaneComposePropsTy
if (virtualRowIndex === 0) { if (virtualRowIndex === 0) {
return { return {
type: RowType.StartNewConversation, type: RowType.StartNewConversation,
phoneNumber: this.phoneNumber, phoneNumber: this.#phoneNumber,
isFetching: isFetchingByE164( isFetching: isFetchingByE164(
this.uuidFetchState, this.#uuidFetchState,
this.phoneNumber.e164 this.#phoneNumber.e164
), ),
}; };
} }
@ -287,8 +280,8 @@ export class LeftPaneComposeHelper extends LeftPaneHelper<LeftPaneComposePropsTy
exProps: Readonly<LeftPaneComposePropsType> exProps: Readonly<LeftPaneComposePropsType>
): boolean { ): boolean {
const prev = new LeftPaneComposeHelper(exProps); const prev = new LeftPaneComposeHelper(exProps);
const currHeaderIndices = this.getHeaderIndices(); const currHeaderIndices = this.#getHeaderIndices();
const prevHeaderIndices = prev.getHeaderIndices(); const prevHeaderIndices = prev.#getHeaderIndices();
return ( return (
currHeaderIndices.top !== prevHeaderIndices.top || currHeaderIndices.top !== prevHeaderIndices.top ||
@ -299,26 +292,26 @@ export class LeftPaneComposeHelper extends LeftPaneHelper<LeftPaneComposePropsTy
); );
} }
private getTopButtons(): TopButtons { #getTopButtons(): TopButtons {
if (this.searchTerm) { if (this.#searchTerm) {
return TopButtons.None; return TopButtons.None;
} }
return TopButtons.Visible; return TopButtons.Visible;
} }
private hasTopButtons(): boolean { #hasTopButtons(): boolean {
return this.getTopButtons() !== TopButtons.None; return this.#getTopButtons() !== TopButtons.None;
} }
private hasContactsHeader(): boolean { #hasContactsHeader(): boolean {
return Boolean(this.composeContacts.length); return Boolean(this.#composeContacts.length);
} }
private hasGroupsHeader(): boolean { #hasGroupsHeader(): boolean {
return Boolean(this.composeGroups.length); return Boolean(this.#composeGroups.length);
} }
private getHeaderIndices(): { #getHeaderIndices(): {
top?: number; top?: number;
contact?: number; contact?: number;
group?: number; group?: number;
@ -333,22 +326,22 @@ export class LeftPaneComposeHelper extends LeftPaneHelper<LeftPaneComposePropsTy
let rowCount = 0; let rowCount = 0;
if (this.hasTopButtons()) { if (this.#hasTopButtons()) {
top = 0; top = 0;
rowCount += 3; rowCount += 3;
} }
if (this.hasContactsHeader()) { if (this.#hasContactsHeader()) {
contact = rowCount; contact = rowCount;
rowCount += this.composeContacts.length; rowCount += this.#composeContacts.length;
} }
if (this.hasGroupsHeader()) { if (this.#hasGroupsHeader()) {
group = rowCount; group = rowCount;
rowCount += this.composeContacts.length; rowCount += this.#composeContacts.length;
} }
if (this.phoneNumber) { if (this.#phoneNumber) {
phoneNumber = rowCount; phoneNumber = rowCount;
} }
if (this.username) { if (this.#username) {
username = rowCount; username = rowCount;
} }

View file

@ -36,17 +36,12 @@ type DoLookupActionsType = Readonly<{
LookupConversationWithoutServiceIdActionsType; LookupConversationWithoutServiceIdActionsType;
export class LeftPaneFindByPhoneNumberHelper extends LeftPaneHelper<LeftPaneFindByPhoneNumberPropsType> { export class LeftPaneFindByPhoneNumberHelper extends LeftPaneHelper<LeftPaneFindByPhoneNumberPropsType> {
private readonly searchTerm: string; readonly #searchTerm: string;
readonly #phoneNumber: ParsedE164Type | undefined;
private readonly phoneNumber: ParsedE164Type | undefined; readonly #regionCode: string | undefined;
readonly #uuidFetchState: UUIDFetchStateType;
private readonly regionCode: string | undefined; readonly #countries: ReadonlyArray<CountryDataType>;
readonly #selectedRegion: string;
private readonly uuidFetchState: UUIDFetchStateType;
private readonly countries: ReadonlyArray<CountryDataType>;
private readonly selectedRegion: string;
constructor({ constructor({
searchTerm, searchTerm,
@ -57,14 +52,14 @@ export class LeftPaneFindByPhoneNumberHelper extends LeftPaneHelper<LeftPaneFind
}: Readonly<LeftPaneFindByPhoneNumberPropsType>) { }: Readonly<LeftPaneFindByPhoneNumberPropsType>) {
super(); super();
this.searchTerm = searchTerm; this.#searchTerm = searchTerm;
this.uuidFetchState = uuidFetchState; this.#uuidFetchState = uuidFetchState;
this.regionCode = regionCode; this.#regionCode = regionCode;
this.countries = countries; this.#countries = countries;
this.selectedRegion = selectedRegion; this.#selectedRegion = selectedRegion;
this.phoneNumber = parseAndFormatPhoneNumber( this.#phoneNumber = parseAndFormatPhoneNumber(
this.searchTerm, this.#searchTerm,
selectedRegion || regionCode selectedRegion || regionCode
); );
} }
@ -83,7 +78,7 @@ export class LeftPaneFindByPhoneNumberHelper extends LeftPaneHelper<LeftPaneFind
<button <button
aria-label={backButtonLabel} aria-label={backButtonLabel}
className="module-left-pane__header__contents__back-button" className="module-left-pane__header__contents__back-button"
disabled={this.isFetching()} disabled={this.#isFetching()}
onClick={this.getBackAction({ startComposing })} onClick={this.getBackAction({ startComposing })}
title={backButtonLabel} title={backButtonLabel}
type="button" type="button"
@ -100,7 +95,7 @@ export class LeftPaneFindByPhoneNumberHelper extends LeftPaneHelper<LeftPaneFind
}: { }: {
startComposing: () => void; startComposing: () => void;
}): undefined | (() => void) { }): undefined | (() => void) {
return this.isFetching() ? undefined : startComposing; return this.#isFetching() ? undefined : startComposing;
} }
override getSearchInput({ override getSearchInput({
@ -122,25 +117,25 @@ export class LeftPaneFindByPhoneNumberHelper extends LeftPaneHelper<LeftPaneFind
return ( return (
<div className="LeftPaneFindByPhoneNumberHelper__container"> <div className="LeftPaneFindByPhoneNumberHelper__container">
<CountryCodeSelect <CountryCodeSelect
countries={this.countries} countries={this.#countries}
i18n={i18n} i18n={i18n}
defaultRegion={this.regionCode ?? ''} defaultRegion={this.#regionCode ?? ''}
value={this.selectedRegion} value={this.#selectedRegion}
onChange={onChangeComposeSelectedRegion} onChange={onChangeComposeSelectedRegion}
/> />
<SearchInput <SearchInput
hasSearchIcon={false} hasSearchIcon={false}
disabled={this.isFetching()} disabled={this.#isFetching()}
i18n={i18n} i18n={i18n}
moduleClassName="LeftPaneFindByPhoneNumberHelper__search-input" moduleClassName="LeftPaneFindByPhoneNumberHelper__search-input"
onChange={onChangeComposeSearchTerm} onChange={onChangeComposeSearchTerm}
placeholder={placeholder} placeholder={placeholder}
ref={focusRef} ref={focusRef}
value={this.searchTerm} value={this.#searchTerm}
onKeyDown={ev => { onKeyDown={ev => {
if (ev.key === 'Enter') { if (ev.key === 'Enter') {
drop(this.doLookup(lookupActions)); drop(this.#doLookup(lookupActions));
} }
}} }}
/> />
@ -157,10 +152,10 @@ export class LeftPaneFindByPhoneNumberHelper extends LeftPaneHelper<LeftPaneFind
DoLookupActionsType): ReactChild { DoLookupActionsType): ReactChild {
return ( return (
<Button <Button
disabled={this.isLookupDisabled()} disabled={this.#isLookupDisabled()}
onClick={() => drop(this.doLookup(lookupActions))} onClick={() => drop(this.#doLookup(lookupActions))}
> >
{this.isFetching() ? ( {this.#isFetching() ? (
<span aria-label={i18n('icu:loading')} role="status"> <span aria-label={i18n('icu:loading')} role="status">
<Spinner size="20px" svgSize="small" direction="on-avatar" /> <Spinner size="20px" svgSize="small" direction="on-avatar" />
</span> </span>
@ -198,14 +193,14 @@ export class LeftPaneFindByPhoneNumberHelper extends LeftPaneHelper<LeftPaneFind
return false; return false;
} }
private async doLookup({ async #doLookup({
lookupConversationWithoutServiceId, lookupConversationWithoutServiceId,
showUserNotFoundModal, showUserNotFoundModal,
setIsFetchingUUID, setIsFetchingUUID,
showInbox, showInbox,
showConversation, showConversation,
}: DoLookupActionsType): Promise<void> { }: DoLookupActionsType): Promise<void> {
if (!this.phoneNumber || this.isLookupDisabled()) { if (!this.#phoneNumber || this.#isLookupDisabled()) {
return; return;
} }
@ -213,8 +208,8 @@ export class LeftPaneFindByPhoneNumberHelper extends LeftPaneHelper<LeftPaneFind
showUserNotFoundModal, showUserNotFoundModal,
setIsFetchingUUID, setIsFetchingUUID,
type: 'e164', type: 'e164',
e164: this.phoneNumber.e164, e164: this.#phoneNumber.e164,
phoneNumber: this.searchTerm, phoneNumber: this.#searchTerm,
}); });
if (conversationId != null) { if (conversationId != null) {
@ -223,20 +218,20 @@ export class LeftPaneFindByPhoneNumberHelper extends LeftPaneHelper<LeftPaneFind
} }
} }
private isFetching(): boolean { #isFetching(): boolean {
if (this.phoneNumber != null) { if (this.#phoneNumber != null) {
return isFetchingByE164(this.uuidFetchState, this.phoneNumber.e164); return isFetchingByE164(this.#uuidFetchState, this.#phoneNumber.e164);
} }
return false; return false;
} }
private isLookupDisabled(): boolean { #isLookupDisabled(): boolean {
if (this.isFetching()) { if (this.#isFetching()) {
return true; return true;
} }
return !this.phoneNumber?.isValid; return !this.#phoneNumber?.isValid;
} }
} }

View file

@ -30,11 +30,9 @@ type DoLookupActionsType = Readonly<{
LookupConversationWithoutServiceIdActionsType; LookupConversationWithoutServiceIdActionsType;
export class LeftPaneFindByUsernameHelper extends LeftPaneHelper<LeftPaneFindByUsernamePropsType> { export class LeftPaneFindByUsernameHelper extends LeftPaneHelper<LeftPaneFindByUsernamePropsType> {
private readonly searchTerm: string; readonly #searchTerm: string;
readonly #username: string | undefined;
private readonly username: string | undefined; readonly #uuidFetchState: UUIDFetchStateType;
private readonly uuidFetchState: UUIDFetchStateType;
constructor({ constructor({
searchTerm, searchTerm,
@ -43,10 +41,10 @@ export class LeftPaneFindByUsernameHelper extends LeftPaneHelper<LeftPaneFindByU
}: Readonly<LeftPaneFindByUsernamePropsType>) { }: Readonly<LeftPaneFindByUsernamePropsType>) {
super(); super();
this.searchTerm = searchTerm; this.#searchTerm = searchTerm;
this.uuidFetchState = uuidFetchState; this.#uuidFetchState = uuidFetchState;
this.username = username; this.#username = username;
} }
override getHeaderContents({ override getHeaderContents({
@ -63,7 +61,7 @@ export class LeftPaneFindByUsernameHelper extends LeftPaneHelper<LeftPaneFindByU
<button <button
aria-label={backButtonLabel} aria-label={backButtonLabel}
className="module-left-pane__header__contents__back-button" className="module-left-pane__header__contents__back-button"
disabled={this.isFetching()} disabled={this.#isFetching()}
onClick={this.getBackAction({ startComposing })} onClick={this.getBackAction({ startComposing })}
title={backButtonLabel} title={backButtonLabel}
type="button" type="button"
@ -80,7 +78,7 @@ export class LeftPaneFindByUsernameHelper extends LeftPaneHelper<LeftPaneFindByU
}: { }: {
startComposing: () => void; startComposing: () => void;
}): undefined | (() => void) { }): undefined | (() => void) {
return this.isFetching() ? undefined : startComposing; return this.#isFetching() ? undefined : startComposing;
} }
override getSearchInput({ override getSearchInput({
@ -103,17 +101,17 @@ export class LeftPaneFindByUsernameHelper extends LeftPaneHelper<LeftPaneFindByU
return ( return (
<SearchInput <SearchInput
hasSearchIcon={false} hasSearchIcon={false}
disabled={this.isFetching()} disabled={this.#isFetching()}
i18n={i18n} i18n={i18n}
moduleClassName="LeftPaneFindByUsernameHelper__search-input" moduleClassName="LeftPaneFindByUsernameHelper__search-input"
onChange={onChangeComposeSearchTerm} onChange={onChangeComposeSearchTerm}
placeholder={placeholder} placeholder={placeholder}
ref={focusRef} ref={focusRef}
value={this.searchTerm} value={this.#searchTerm}
description={description} description={description}
onKeyDown={ev => { onKeyDown={ev => {
if (ev.key === 'Enter') { if (ev.key === 'Enter') {
drop(this.doLookup(lookupActions)); drop(this.#doLookup(lookupActions));
} }
}} }}
/> />
@ -129,10 +127,10 @@ export class LeftPaneFindByUsernameHelper extends LeftPaneHelper<LeftPaneFindByU
DoLookupActionsType): ReactChild { DoLookupActionsType): ReactChild {
return ( return (
<Button <Button
disabled={this.isLookupDisabled()} disabled={this.#isLookupDisabled()}
onClick={() => drop(this.doLookup(lookupActions))} onClick={() => drop(this.#doLookup(lookupActions))}
> >
{this.isFetching() ? ( {this.#isFetching() ? (
<span aria-label={i18n('icu:loading')} role="status"> <span aria-label={i18n('icu:loading')} role="status">
<Spinner size="20px" svgSize="small" direction="on-avatar" /> <Spinner size="20px" svgSize="small" direction="on-avatar" />
</span> </span>
@ -170,14 +168,14 @@ export class LeftPaneFindByUsernameHelper extends LeftPaneHelper<LeftPaneFindByU
return false; return false;
} }
private async doLookup({ async #doLookup({
lookupConversationWithoutServiceId, lookupConversationWithoutServiceId,
showUserNotFoundModal, showUserNotFoundModal,
setIsFetchingUUID, setIsFetchingUUID,
showInbox, showInbox,
showConversation, showConversation,
}: DoLookupActionsType): Promise<void> { }: DoLookupActionsType): Promise<void> {
if (!this.username || this.isLookupDisabled()) { if (!this.#username || this.#isLookupDisabled()) {
return; return;
} }
@ -185,7 +183,7 @@ export class LeftPaneFindByUsernameHelper extends LeftPaneHelper<LeftPaneFindByU
showUserNotFoundModal, showUserNotFoundModal,
setIsFetchingUUID, setIsFetchingUUID,
type: 'username', type: 'username',
username: this.username, username: this.#username,
}); });
if (conversationId != null) { if (conversationId != null) {
@ -194,20 +192,20 @@ export class LeftPaneFindByUsernameHelper extends LeftPaneHelper<LeftPaneFindByU
} }
} }
private isFetching(): boolean { #isFetching(): boolean {
if (this.username != null) { if (this.#username != null) {
return isFetchingByUsername(this.uuidFetchState, this.username); return isFetchingByUsername(this.#uuidFetchState, this.#username);
} }
return false; return false;
} }
private isLookupDisabled(): boolean { #isLookupDisabled(): boolean {
if (this.isFetching()) { if (this.#isFetching()) {
return true; return true;
} }
return this.username == null; return this.#username == null;
} }
} }

View file

@ -34,25 +34,16 @@ export type LeftPaneInboxPropsType = {
}; };
export class LeftPaneInboxHelper extends LeftPaneHelper<LeftPaneInboxPropsType> { export class LeftPaneInboxHelper extends LeftPaneHelper<LeftPaneInboxPropsType> {
private readonly conversations: ReadonlyArray<ConversationListItemPropsType>; readonly #conversations: ReadonlyArray<ConversationListItemPropsType>;
readonly #archivedConversations: ReadonlyArray<ConversationListItemPropsType>;
private readonly archivedConversations: ReadonlyArray<ConversationListItemPropsType>; readonly #pinnedConversations: ReadonlyArray<ConversationListItemPropsType>;
readonly #isAboutToSearch: boolean;
private readonly pinnedConversations: ReadonlyArray<ConversationListItemPropsType>; readonly #isSearchingGlobally: boolean;
readonly #startSearchCounter: number;
private readonly isAboutToSearch: boolean; readonly #searchDisabled: boolean;
readonly #searchTerm: string;
private readonly isSearchingGlobally: boolean; readonly #searchConversation: undefined | ConversationType;
readonly #filterByUnread: boolean;
private readonly startSearchCounter: number;
private readonly searchDisabled: boolean;
private readonly searchTerm: string;
private readonly searchConversation: undefined | ConversationType;
private readonly filterByUnread: boolean;
constructor({ constructor({
conversations, conversations,
@ -68,25 +59,25 @@ export class LeftPaneInboxHelper extends LeftPaneHelper<LeftPaneInboxPropsType>
}: Readonly<LeftPaneInboxPropsType>) { }: Readonly<LeftPaneInboxPropsType>) {
super(); super();
this.conversations = conversations; this.#conversations = conversations;
this.archivedConversations = archivedConversations; this.#archivedConversations = archivedConversations;
this.pinnedConversations = pinnedConversations; this.#pinnedConversations = pinnedConversations;
this.isAboutToSearch = isAboutToSearch; this.#isAboutToSearch = isAboutToSearch;
this.isSearchingGlobally = isSearchingGlobally; this.#isSearchingGlobally = isSearchingGlobally;
this.startSearchCounter = startSearchCounter; this.#startSearchCounter = startSearchCounter;
this.searchDisabled = searchDisabled; this.#searchDisabled = searchDisabled;
this.searchTerm = searchTerm; this.#searchTerm = searchTerm;
this.searchConversation = searchConversation; this.#searchConversation = searchConversation;
this.filterByUnread = filterByUnread; this.#filterByUnread = filterByUnread;
} }
getRowCount(): number { getRowCount(): number {
const headerCount = this.hasPinnedAndNonpinned() ? 2 : 0; const headerCount = this.#hasPinnedAndNonpinned() ? 2 : 0;
const buttonCount = this.archivedConversations.length ? 1 : 0; const buttonCount = this.#archivedConversations.length ? 1 : 0;
return ( return (
headerCount + headerCount +
this.pinnedConversations.length + this.#pinnedConversations.length +
this.conversations.length + this.#conversations.length +
buttonCount buttonCount
); );
} }
@ -116,17 +107,17 @@ export class LeftPaneInboxHelper extends LeftPaneHelper<LeftPaneInboxPropsType>
clearSearchQuery={clearSearchQuery} clearSearchQuery={clearSearchQuery}
endConversationSearch={endConversationSearch} endConversationSearch={endConversationSearch}
endSearch={endSearch} endSearch={endSearch}
disabled={this.searchDisabled} disabled={this.#searchDisabled}
i18n={i18n} i18n={i18n}
isSearchingGlobally={this.isSearchingGlobally} isSearchingGlobally={this.#isSearchingGlobally}
searchConversation={this.searchConversation} searchConversation={this.#searchConversation}
searchTerm={this.searchTerm} searchTerm={this.#searchTerm}
showConversation={showConversation} showConversation={showConversation}
startSearchCounter={this.startSearchCounter} startSearchCounter={this.#startSearchCounter}
updateSearchTerm={updateSearchTerm} updateSearchTerm={updateSearchTerm}
onFilterClick={updateFilterByUnread} onFilterClick={updateFilterByUnread}
filterButtonEnabled={!this.searchConversation} filterButtonEnabled={!this.#searchConversation}
filterPressed={this.filterByUnread} filterPressed={this.#filterByUnread}
/> />
); );
} }
@ -149,11 +140,13 @@ export class LeftPaneInboxHelper extends LeftPaneHelper<LeftPaneInboxPropsType>
} }
getRow(rowIndex: number): undefined | Row { getRow(rowIndex: number): undefined | Row {
const { conversations, archivedConversations, pinnedConversations } = this; const pinnedConversations = this.#pinnedConversations;
const archivedConversations = this.#archivedConversations;
const conversations = this.#conversations;
const archivedConversationsCount = archivedConversations.length; const archivedConversationsCount = archivedConversations.length;
if (this.hasPinnedAndNonpinned()) { if (this.#hasPinnedAndNonpinned()) {
switch (rowIndex) { switch (rowIndex) {
case 0: case 0:
return { return {
@ -226,9 +219,9 @@ export class LeftPaneInboxHelper extends LeftPaneHelper<LeftPaneInboxPropsType>
const isConversationSelected = ( const isConversationSelected = (
conversation: Readonly<ConversationListItemPropsType> conversation: Readonly<ConversationListItemPropsType>
) => conversation.id === selectedConversationId; ) => conversation.id === selectedConversationId;
const hasHeaders = this.hasPinnedAndNonpinned(); const hasHeaders = this.#hasPinnedAndNonpinned();
const pinnedConversationIndex = this.pinnedConversations.findIndex( const pinnedConversationIndex = this.#pinnedConversations.findIndex(
isConversationSelected isConversationSelected
); );
if (pinnedConversationIndex !== -1) { if (pinnedConversationIndex !== -1) {
@ -236,11 +229,11 @@ export class LeftPaneInboxHelper extends LeftPaneHelper<LeftPaneInboxPropsType>
return pinnedConversationIndex + headerOffset; return pinnedConversationIndex + headerOffset;
} }
const conversationIndex = this.conversations.findIndex( const conversationIndex = this.#conversations.findIndex(
isConversationSelected isConversationSelected
); );
if (conversationIndex !== -1) { if (conversationIndex !== -1) {
const pinnedOffset = this.pinnedConversations.length; const pinnedOffset = this.#pinnedConversations.length;
const headerOffset = hasHeaders ? 2 : 0; const headerOffset = hasHeaders ? 2 : 0;
return conversationIndex + pinnedOffset + headerOffset; return conversationIndex + pinnedOffset + headerOffset;
} }
@ -250,20 +243,21 @@ export class LeftPaneInboxHelper extends LeftPaneHelper<LeftPaneInboxPropsType>
override requiresFullWidth(): boolean { override requiresFullWidth(): boolean {
const hasNoConversations = const hasNoConversations =
!this.conversations.length && !this.#conversations.length &&
!this.pinnedConversations.length && !this.#pinnedConversations.length &&
!this.archivedConversations.length; !this.#archivedConversations.length;
return hasNoConversations || this.isAboutToSearch; return hasNoConversations || this.#isAboutToSearch;
} }
shouldRecomputeRowHeights(old: Readonly<LeftPaneInboxPropsType>): boolean { shouldRecomputeRowHeights(old: Readonly<LeftPaneInboxPropsType>): boolean {
return old.pinnedConversations.length !== this.pinnedConversations.length; return old.pinnedConversations.length !== this.#pinnedConversations.length;
} }
getConversationAndMessageAtIndex( getConversationAndMessageAtIndex(
conversationIndex: number conversationIndex: number
): undefined | { conversationId: string } { ): undefined | { conversationId: string } {
const { conversations, pinnedConversations } = this; const pinnedConversations = this.#pinnedConversations;
const conversations = this.#conversations;
const conversation = const conversation =
pinnedConversations[conversationIndex] || pinnedConversations[conversationIndex] ||
conversations[conversationIndex - pinnedConversations.length] || conversations[conversationIndex - pinnedConversations.length] ||
@ -278,7 +272,7 @@ export class LeftPaneInboxHelper extends LeftPaneHelper<LeftPaneInboxPropsType>
_targetedMessageId: unknown _targetedMessageId: unknown
): undefined | { conversationId: string } { ): undefined | { conversationId: string } {
return getConversationInDirection( return getConversationInDirection(
[...this.pinnedConversations, ...this.conversations], [...this.#pinnedConversations, ...this.#conversations],
toFind, toFind,
selectedConversationId selectedConversationId
); );
@ -295,9 +289,9 @@ export class LeftPaneInboxHelper extends LeftPaneHelper<LeftPaneInboxPropsType>
handleKeydownForSearch(event, options); handleKeydownForSearch(event, options);
} }
private hasPinnedAndNonpinned(): boolean { #hasPinnedAndNonpinned(): boolean {
return Boolean( return Boolean(
this.pinnedConversations.length && this.conversations.length this.#pinnedConversations.length && this.#conversations.length
); );
} }
} }

View file

@ -50,36 +50,24 @@ export type LeftPaneSearchPropsType = {
searchConversation: undefined | ConversationType; searchConversation: undefined | ConversationType;
}; };
const searchResultKeys: Array<
'conversationResults' | 'contactResults' | 'messageResults'
> = ['conversationResults', 'contactResults', 'messageResults'];
export class LeftPaneSearchHelper extends LeftPaneHelper<LeftPaneSearchPropsType> { export class LeftPaneSearchHelper extends LeftPaneHelper<LeftPaneSearchPropsType> {
private readonly conversationResults: MaybeLoadedSearchResultsType<ConversationListItemPropsType>; readonly #conversationResults: MaybeLoadedSearchResultsType<ConversationListItemPropsType>;
readonly #contactResults: MaybeLoadedSearchResultsType<ConversationListItemPropsType>;
readonly #isSearchingGlobally: boolean;
private readonly contactResults: MaybeLoadedSearchResultsType<ConversationListItemPropsType>; readonly #messageResults: MaybeLoadedSearchResultsType<{
private readonly isSearchingGlobally: boolean;
private readonly messageResults: MaybeLoadedSearchResultsType<{
id: string; id: string;
conversationId: string; conversationId: string;
type: string; type: string;
}>; }>;
private readonly searchConversationName?: string; readonly #searchConversationName?: string;
readonly #primarySendsSms: boolean;
private readonly primarySendsSms: boolean; readonly #searchTerm: string;
readonly #startSearchCounter: number;
private readonly searchTerm: string; readonly #searchDisabled: boolean;
readonly #searchConversation: undefined | ConversationType;
private readonly startSearchCounter: number; readonly #filterByUnread: boolean;
private readonly searchDisabled: boolean;
private readonly searchConversation: undefined | ConversationType;
private readonly filterByUnread: boolean;
constructor({ constructor({
contactResults, contactResults,
@ -96,18 +84,17 @@ export class LeftPaneSearchHelper extends LeftPaneHelper<LeftPaneSearchPropsType
}: Readonly<LeftPaneSearchPropsType>) { }: Readonly<LeftPaneSearchPropsType>) {
super(); super();
this.contactResults = contactResults; this.#contactResults = contactResults;
this.conversationResults = conversationResults; this.#conversationResults = conversationResults;
this.isSearchingGlobally = isSearchingGlobally; this.#isSearchingGlobally = isSearchingGlobally;
this.messageResults = messageResults; this.#messageResults = messageResults;
this.primarySendsSms = primarySendsSms; this.#primarySendsSms = primarySendsSms;
this.searchConversation = searchConversation; this.#searchConversation = searchConversation;
this.searchConversationName = searchConversationName; this.#searchConversationName = searchConversationName;
this.searchDisabled = searchDisabled; this.#searchDisabled = searchDisabled;
this.searchTerm = searchTerm; this.#searchTerm = searchTerm;
this.startSearchCounter = startSearchCounter; this.#startSearchCounter = startSearchCounter;
this.filterByUnread = filterByUnread; this.#filterByUnread = filterByUnread;
this.onEnterKeyDown = this.onEnterKeyDown.bind(this);
} }
override getSearchInput({ override getSearchInput({
@ -135,17 +122,17 @@ export class LeftPaneSearchHelper extends LeftPaneHelper<LeftPaneSearchPropsType
clearSearchQuery={clearSearchQuery} clearSearchQuery={clearSearchQuery}
endConversationSearch={endConversationSearch} endConversationSearch={endConversationSearch}
endSearch={endSearch} endSearch={endSearch}
disabled={this.searchDisabled} disabled={this.#searchDisabled}
i18n={i18n} i18n={i18n}
isSearchingGlobally={this.isSearchingGlobally} isSearchingGlobally={this.#isSearchingGlobally}
onEnterKeyDown={this.onEnterKeyDown} onEnterKeyDown={this.#onEnterKeyDown}
searchConversation={this.searchConversation} searchConversation={this.#searchConversation}
searchTerm={this.searchTerm} searchTerm={this.#searchTerm}
showConversation={showConversation} showConversation={showConversation}
startSearchCounter={this.startSearchCounter} startSearchCounter={this.#startSearchCounter}
updateSearchTerm={updateSearchTerm} updateSearchTerm={updateSearchTerm}
filterButtonEnabled={!this.searchConversation} filterButtonEnabled={!this.#searchConversation}
filterPressed={this.filterByUnread} filterPressed={this.#filterByUnread}
onFilterClick={updateFilterByUnread} onFilterClick={updateFilterByUnread}
/> />
); );
@ -156,7 +143,7 @@ export class LeftPaneSearchHelper extends LeftPaneHelper<LeftPaneSearchPropsType
}: Readonly<{ }: Readonly<{
i18n: LocalizerType; i18n: LocalizerType;
}>): ReactChild | null { }>): ReactChild | null {
const mightHaveSearchResults = this.allResults().some( const mightHaveSearchResults = this.#allResults().some(
searchResult => searchResult.isLoading || searchResult.results.length searchResult => searchResult.isLoading || searchResult.results.length
); );
@ -164,7 +151,9 @@ export class LeftPaneSearchHelper extends LeftPaneHelper<LeftPaneSearchPropsType
return null; return null;
} }
const { searchConversationName, primarySendsSms, searchTerm } = this; const searchTerm = this.#searchTerm;
const primarySendsSms = this.#primarySendsSms;
const searchConversationName = this.#searchConversationName;
let noResults: ReactChild; let noResults: ReactChild;
if (searchConversationName) { if (searchConversationName) {
@ -182,11 +171,11 @@ export class LeftPaneSearchHelper extends LeftPaneHelper<LeftPaneSearchPropsType
); );
} else { } else {
let noResultsMessage: string; let noResultsMessage: string;
if (this.filterByUnread && this.searchTerm.length > 0) { if (this.#filterByUnread && this.#searchTerm.length > 0) {
noResultsMessage = i18n('icu:noSearchResultsWithUnreadFilter', { noResultsMessage = i18n('icu:noSearchResultsWithUnreadFilter', {
searchTerm, searchTerm,
}); });
} else if (this.filterByUnread) { } else if (this.#filterByUnread) {
noResultsMessage = i18n('icu:noSearchResultsOnlyUnreadFilter'); noResultsMessage = i18n('icu:noSearchResultsOnlyUnreadFilter');
} else { } else {
noResultsMessage = i18n('icu:noSearchResults', { noResultsMessage = i18n('icu:noSearchResults', {
@ -195,7 +184,7 @@ export class LeftPaneSearchHelper extends LeftPaneHelper<LeftPaneSearchPropsType
} }
noResults = ( noResults = (
<> <>
{this.filterByUnread && ( {this.#filterByUnread && (
<div <div
className="module-conversation-list__item--header module-left-pane__no-search-results__unread-header" className="module-conversation-list__item--header module-left-pane__no-search-results__unread-header"
aria-label={i18n('icu:conversationsUnreadHeader')} aria-label={i18n('icu:conversationsUnreadHeader')}
@ -218,7 +207,7 @@ export class LeftPaneSearchHelper extends LeftPaneHelper<LeftPaneSearchPropsType
// We need this for Ctrl-T shortcut cycling through parts of app // We need this for Ctrl-T shortcut cycling through parts of app
tabIndex={-1} tabIndex={-1}
className={ className={
this.filterByUnread this.#filterByUnread
? 'module-left-pane__no-search-results--withHeader' ? 'module-left-pane__no-search-results--withHeader'
: 'module-left-pane__no-search-results' : 'module-left-pane__no-search-results'
} }
@ -230,19 +219,19 @@ export class LeftPaneSearchHelper extends LeftPaneHelper<LeftPaneSearchPropsType
} }
getRowCount(): number { getRowCount(): number {
if (this.isLoading()) { if (this.#isLoading()) {
// 1 for the header. // 1 for the header.
return 1 + SEARCH_RESULTS_FAKE_ROW_COUNT; return 1 + SEARCH_RESULTS_FAKE_ROW_COUNT;
} }
let count = this.allResults().reduce( let count = this.#allResults().reduce(
(result: number, searchResults) => (result: number, searchResults) =>
result + getRowCountForLoadedSearchResults(searchResults), result + getRowCountForLoadedSearchResults(searchResults),
0 0
); );
// The clear unread filter button adds an extra row // The clear unread filter button adds an extra row
if (this.filterByUnread) { if (this.#filterByUnread) {
count += 1; count += 1;
} }
@ -257,9 +246,11 @@ export class LeftPaneSearchHelper extends LeftPaneHelper<LeftPaneSearchPropsType
} }
getRow(rowIndex: number): undefined | Row { getRow(rowIndex: number): undefined | Row {
const { conversationResults, contactResults, messageResults } = this; const messageResults = this.#messageResults;
const contactResults = this.#contactResults;
const conversationResults = this.#conversationResults;
if (this.isLoading()) { if (this.#isLoading()) {
if (rowIndex === 0) { if (rowIndex === 0) {
return { type: RowType.SearchResultsLoadingFakeHeader }; return { type: RowType.SearchResultsLoadingFakeHeader };
} }
@ -273,7 +264,7 @@ export class LeftPaneSearchHelper extends LeftPaneHelper<LeftPaneSearchPropsType
getRowCountForLoadedSearchResults(conversationResults); getRowCountForLoadedSearchResults(conversationResults);
const contactRowCount = getRowCountForLoadedSearchResults(contactResults); const contactRowCount = getRowCountForLoadedSearchResults(contactResults);
const messageRowCount = getRowCountForLoadedSearchResults(messageResults); const messageRowCount = getRowCountForLoadedSearchResults(messageResults);
const clearFilterButtonRowCount = this.filterByUnread ? 1 : 0; const clearFilterButtonRowCount = this.#filterByUnread ? 1 : 0;
let rowOffset = 0; let rowOffset = 0;
@ -283,7 +274,7 @@ export class LeftPaneSearchHelper extends LeftPaneHelper<LeftPaneSearchPropsType
return { return {
type: RowType.Header, type: RowType.Header,
getHeaderText: i18n => getHeaderText: i18n =>
this.filterByUnread this.#filterByUnread
? i18n('icu:conversationsUnreadHeader') ? i18n('icu:conversationsUnreadHeader')
: i18n('icu:conversationsHeader'), : i18n('icu:conversationsHeader'),
}; };
@ -350,7 +341,7 @@ export class LeftPaneSearchHelper extends LeftPaneHelper<LeftPaneSearchPropsType
if (rowIndex < rowOffset) { if (rowIndex < rowOffset) {
return { return {
type: RowType.ClearFilterButton, type: RowType.ClearFilterButton,
isOnNoResultsPage: this.allResults().every( isOnNoResultsPage: this.#allResults().every(
searchResult => searchResult =>
searchResult.isLoading || searchResult.results.length === 0 searchResult.isLoading || searchResult.results.length === 0
), ),
@ -361,24 +352,30 @@ export class LeftPaneSearchHelper extends LeftPaneHelper<LeftPaneSearchPropsType
} }
override isScrollable(): boolean { override isScrollable(): boolean {
return !this.isLoading(); return !this.#isLoading();
} }
shouldRecomputeRowHeights(old: Readonly<LeftPaneSearchPropsType>): boolean { shouldRecomputeRowHeights(old: Readonly<LeftPaneSearchPropsType>): boolean {
const oldSearchPaneHelper = new LeftPaneSearchHelper(old); const oldSearchPaneHelper = new LeftPaneSearchHelper(old);
const oldIsLoading = oldSearchPaneHelper.isLoading(); const oldIsLoading = oldSearchPaneHelper.#isLoading();
const newIsLoading = this.isLoading(); const newIsLoading = this.#isLoading();
if (oldIsLoading && newIsLoading) { if (oldIsLoading && newIsLoading) {
return false; return false;
} }
if (oldIsLoading !== newIsLoading) { if (oldIsLoading !== newIsLoading) {
return true; return true;
} }
return searchResultKeys.some( const searchResultsByKey = [
key => { current: this.#conversationResults, prev: old.conversationResults },
getRowCountForLoadedSearchResults(old[key]) !== { current: this.#contactResults, prev: old.contactResults },
getRowCountForLoadedSearchResults(this[key]) { current: this.#messageResults, prev: old.messageResults },
); ];
return searchResultsByKey.some(item => {
return (
getRowCountForLoadedSearchResults(item.prev) !==
getRowCountForLoadedSearchResults(item.current)
);
});
} }
getConversationAndMessageAtIndex( getConversationAndMessageAtIndex(
@ -388,7 +385,7 @@ export class LeftPaneSearchHelper extends LeftPaneHelper<LeftPaneSearchPropsType
return undefined; return undefined;
} }
let pointer = conversationIndex; let pointer = conversationIndex;
for (const list of this.allResults()) { for (const list of this.#allResults()) {
if (list.isLoading) { if (list.isLoading) {
continue; continue;
} }
@ -426,25 +423,29 @@ export class LeftPaneSearchHelper extends LeftPaneHelper<LeftPaneSearchPropsType
handleKeydownForSearch(event, options); handleKeydownForSearch(event, options);
} }
private allResults() { #allResults() {
return [this.conversationResults, this.contactResults, this.messageResults]; return [
this.#conversationResults,
this.#contactResults,
this.#messageResults,
];
} }
private isLoading(): boolean { #isLoading(): boolean {
return this.allResults().some(results => results.isLoading); return this.#allResults().some(results => results.isLoading);
} }
private onEnterKeyDown( #onEnterKeyDown = (
clearSearchQuery: () => unknown, clearSearchQuery: () => unknown,
showConversation: ShowConversationType showConversation: ShowConversationType
): void { ): void => {
const conversation = this.getConversationAndMessageAtIndex(0); const conversation = this.getConversationAndMessageAtIndex(0);
if (!conversation) { if (!conversation) {
return; return;
} }
showConversation(conversation); showConversation(conversation);
clearSearchQuery(); clearSearchQuery();
} };
} }
function getRowCountForLoadedSearchResults( function getRowCountForLoadedSearchResults(

View file

@ -38,21 +38,14 @@ export type LeftPaneSetGroupMetadataPropsType = {
}; };
export class LeftPaneSetGroupMetadataHelper extends LeftPaneHelper<LeftPaneSetGroupMetadataPropsType> { export class LeftPaneSetGroupMetadataHelper extends LeftPaneHelper<LeftPaneSetGroupMetadataPropsType> {
private readonly groupAvatar: undefined | Uint8Array; readonly #groupAvatar: undefined | Uint8Array;
readonly #groupName: string;
private readonly groupName: string; readonly #groupExpireTimer: DurationInSeconds;
readonly #hasError: boolean;
private readonly groupExpireTimer: DurationInSeconds; readonly #isCreating: boolean;
readonly #isEditingAvatar: boolean;
private readonly hasError: boolean; readonly #selectedContacts: ReadonlyArray<ContactListItemConversationType>;
readonly #userAvatarData: ReadonlyArray<AvatarDataType>;
private readonly isCreating: boolean;
private readonly isEditingAvatar: boolean;
private readonly selectedContacts: ReadonlyArray<ContactListItemConversationType>;
private readonly userAvatarData: ReadonlyArray<AvatarDataType>;
constructor({ constructor({
groupAvatar, groupAvatar,
@ -66,14 +59,14 @@ export class LeftPaneSetGroupMetadataHelper extends LeftPaneHelper<LeftPaneSetGr
}: Readonly<LeftPaneSetGroupMetadataPropsType>) { }: Readonly<LeftPaneSetGroupMetadataPropsType>) {
super(); super();
this.groupAvatar = groupAvatar; this.#groupAvatar = groupAvatar;
this.groupName = groupName; this.#groupName = groupName;
this.groupExpireTimer = groupExpireTimer; this.#groupExpireTimer = groupExpireTimer;
this.hasError = hasError; this.#hasError = hasError;
this.isCreating = isCreating; this.#isCreating = isCreating;
this.isEditingAvatar = isEditingAvatar; this.#isEditingAvatar = isEditingAvatar;
this.selectedContacts = selectedContacts; this.#selectedContacts = selectedContacts;
this.userAvatarData = userAvatarData; this.#userAvatarData = userAvatarData;
} }
override getHeaderContents({ override getHeaderContents({
@ -90,7 +83,7 @@ export class LeftPaneSetGroupMetadataHelper extends LeftPaneHelper<LeftPaneSetGr
<button <button
aria-label={backButtonLabel} aria-label={backButtonLabel}
className="module-left-pane__header__contents__back-button" className="module-left-pane__header__contents__back-button"
disabled={this.isCreating} disabled={this.#isCreating}
onClick={this.getBackAction({ showChooseGroupMembers })} onClick={this.getBackAction({ showChooseGroupMembers })}
title={backButtonLabel} title={backButtonLabel}
type="button" type="button"
@ -107,7 +100,7 @@ export class LeftPaneSetGroupMetadataHelper extends LeftPaneHelper<LeftPaneSetGr
}: { }: {
showChooseGroupMembers: () => void; showChooseGroupMembers: () => void;
}): undefined | (() => void) { }): undefined | (() => void) {
return this.isCreating ? undefined : showChooseGroupMembers; return this.#isCreating ? undefined : showChooseGroupMembers;
} }
override getPreRowsNode({ override getPreRowsNode({
@ -134,7 +127,7 @@ export class LeftPaneSetGroupMetadataHelper extends LeftPaneHelper<LeftPaneSetGr
toggleComposeEditingAvatar: () => unknown; toggleComposeEditingAvatar: () => unknown;
}>): ReactChild { }>): ReactChild {
const [avatarColor] = AvatarColors; const [avatarColor] = AvatarColors;
const disabled = this.isCreating; const disabled = this.#isCreating;
return ( return (
<form <form
@ -143,14 +136,14 @@ export class LeftPaneSetGroupMetadataHelper extends LeftPaneHelper<LeftPaneSetGr
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
if (!this.canCreateGroup()) { if (!this.#canCreateGroup()) {
return; return;
} }
createGroup(); createGroup();
}} }}
> >
{this.isEditingAvatar && ( {this.#isEditingAvatar && (
<Modal <Modal
modalName="LeftPaneSetGroupMetadataHelper.AvatarEditor" modalName="LeftPaneSetGroupMetadataHelper.AvatarEditor"
hasXButton hasXButton
@ -162,7 +155,7 @@ export class LeftPaneSetGroupMetadataHelper extends LeftPaneHelper<LeftPaneSetGr
> >
<AvatarEditor <AvatarEditor
avatarColor={avatarColor} avatarColor={avatarColor}
avatarValue={this.groupAvatar} avatarValue={this.#groupAvatar}
deleteAvatarFromDisk={composeDeleteAvatarFromDisk} deleteAvatarFromDisk={composeDeleteAvatarFromDisk}
i18n={i18n} i18n={i18n}
isGroup isGroup
@ -171,7 +164,7 @@ export class LeftPaneSetGroupMetadataHelper extends LeftPaneHelper<LeftPaneSetGr
setComposeGroupAvatar(newAvatar); setComposeGroupAvatar(newAvatar);
toggleComposeEditingAvatar(); toggleComposeEditingAvatar();
}} }}
userAvatarData={this.userAvatarData} userAvatarData={this.#userAvatarData}
replaceAvatar={composeReplaceAvatar} replaceAvatar={composeReplaceAvatar}
saveAvatarToDisk={composeSaveAvatarToDisk} saveAvatarToDisk={composeSaveAvatarToDisk}
/> />
@ -179,7 +172,7 @@ export class LeftPaneSetGroupMetadataHelper extends LeftPaneHelper<LeftPaneSetGr
)} )}
<AvatarPreview <AvatarPreview
avatarColor={avatarColor} avatarColor={avatarColor}
avatarValue={this.groupAvatar} avatarValue={this.#groupAvatar}
i18n={i18n} i18n={i18n}
isEditable isEditable
isGroup isGroup
@ -196,7 +189,7 @@ export class LeftPaneSetGroupMetadataHelper extends LeftPaneHelper<LeftPaneSetGr
i18n={i18n} i18n={i18n}
onChangeValue={setComposeGroupName} onChangeValue={setComposeGroupName}
ref={focusRef} ref={focusRef}
value={this.groupName} value={this.#groupName}
/> />
</div> </div>
@ -206,12 +199,12 @@ export class LeftPaneSetGroupMetadataHelper extends LeftPaneHelper<LeftPaneSetGr
</div> </div>
<DisappearingTimerSelect <DisappearingTimerSelect
i18n={i18n} i18n={i18n}
value={this.groupExpireTimer} value={this.#groupExpireTimer}
onChange={setComposeGroupExpireTimer} onChange={setComposeGroupExpireTimer}
/> />
</section> </section>
{this.hasError && ( {this.#hasError && (
<Alert <Alert
body={i18n('icu:setGroupMetadata__error-message')} body={i18n('icu:setGroupMetadata__error-message')}
i18n={i18n} i18n={i18n}
@ -231,12 +224,12 @@ export class LeftPaneSetGroupMetadataHelper extends LeftPaneHelper<LeftPaneSetGr
}>): ReactChild { }>): ReactChild {
return ( return (
<Button <Button
disabled={!this.canCreateGroup()} disabled={!this.#canCreateGroup()}
onClick={() => { onClick={() => {
createGroup(); createGroup();
}} }}
> >
{this.isCreating ? ( {this.#isCreating ? (
<span aria-label={i18n('icu:loading')} role="status"> <span aria-label={i18n('icu:loading')} role="status">
<Spinner size="20px" svgSize="small" direction="on-avatar" /> <Spinner size="20px" svgSize="small" direction="on-avatar" />
</span> </span>
@ -248,14 +241,14 @@ export class LeftPaneSetGroupMetadataHelper extends LeftPaneHelper<LeftPaneSetGr
} }
getRowCount(): number { getRowCount(): number {
if (!this.selectedContacts.length) { if (!this.#selectedContacts.length) {
return 0; return 0;
} }
return this.selectedContacts.length + 2; return this.#selectedContacts.length + 2;
} }
getRow(rowIndex: number): undefined | Row { getRow(rowIndex: number): undefined | Row {
if (!this.selectedContacts.length) { if (!this.#selectedContacts.length) {
return undefined; return undefined;
} }
@ -267,11 +260,11 @@ export class LeftPaneSetGroupMetadataHelper extends LeftPaneHelper<LeftPaneSetGr
} }
// This puts a blank row for the footer. // This puts a blank row for the footer.
if (rowIndex === this.selectedContacts.length + 1) { if (rowIndex === this.#selectedContacts.length + 1) {
return { type: RowType.Blank }; return { type: RowType.Blank };
} }
const contact = this.selectedContacts[rowIndex - 1]; const contact = this.#selectedContacts[rowIndex - 1];
return contact return contact
? { ? {
type: RowType.Contact, type: RowType.Contact,
@ -299,8 +292,8 @@ export class LeftPaneSetGroupMetadataHelper extends LeftPaneHelper<LeftPaneSetGr
return false; return false;
} }
private canCreateGroup(): boolean { #canCreateGroup(): boolean {
return !this.isCreating && Boolean(this.groupName.trim()); return !this.#isCreating && Boolean(this.#groupName.trim());
} }
} }

View file

@ -9,35 +9,34 @@ export type Timeout = {
}; };
export class Timers { export class Timers {
private counter = 0; #counter = 0;
readonly #timers = new Map<number, NodeJS.Timeout>();
private readonly timers = new Map<number, NodeJS.Timeout>();
public setTimeout(callback: () => void, delay: number): Timeout { public setTimeout(callback: () => void, delay: number): Timeout {
let id: number; let id: number;
do { do {
id = this.counter; id = this.#counter;
// eslint-disable-next-line no-bitwise // eslint-disable-next-line no-bitwise
this.counter = (this.counter + 1) >>> 0; this.#counter = (this.#counter + 1) >>> 0;
} while (this.timers.has(id)); } while (this.#timers.has(id));
const timer = setTimeout(() => { const timer = setTimeout(() => {
this.timers.delete(id); this.#timers.delete(id);
callback(); callback();
}, delay); }, delay);
this.timers.set(id, timer); this.#timers.set(id, timer);
return { id } as unknown as Timeout; return { id } as unknown as Timeout;
} }
public clearTimeout({ id }: Timeout): ReturnType<typeof clearTimeout> { public clearTimeout({ id }: Timeout): ReturnType<typeof clearTimeout> {
const timer = this.timers.get(id); const timer = this.#timers.get(id);
if (timer === undefined) { if (timer === undefined) {
return; return;
} }
this.timers.delete(id); this.#timers.delete(id);
return clearTimeout(timer); return clearTimeout(timer);
} }
} }

View file

@ -119,8 +119,9 @@ function getJobIdForLogging(job: CoreAttachmentDownloadJobType): string {
} }
export class AttachmentDownloadManager extends JobManager<CoreAttachmentDownloadJobType> { export class AttachmentDownloadManager extends JobManager<CoreAttachmentDownloadJobType> {
private visibleTimelineMessages: Set<string> = new Set(); #visibleTimelineMessages: Set<string> = new Set();
private saveJobsBatcher = createBatcher<AttachmentDownloadJobType>({
#saveJobsBatcher = createBatcher<AttachmentDownloadJobType>({
name: 'saveAttachmentDownloadJobs', name: 'saveAttachmentDownloadJobs',
wait: 150, wait: 150,
maxSize: 1000, maxSize: 1000,
@ -129,6 +130,7 @@ export class AttachmentDownloadManager extends JobManager<CoreAttachmentDownload
drop(this.maybeStartJobs()); drop(this.maybeStartJobs());
}, },
}); });
private static _instance: AttachmentDownloadManager | undefined; private static _instance: AttachmentDownloadManager | undefined;
override logPrefix = 'AttachmentDownloadManager'; override logPrefix = 'AttachmentDownloadManager';
@ -136,7 +138,9 @@ export class AttachmentDownloadManager extends JobManager<CoreAttachmentDownload
markAllJobsInactive: DataWriter.resetAttachmentDownloadActive, markAllJobsInactive: DataWriter.resetAttachmentDownloadActive,
saveJob: async (job, options) => { saveJob: async (job, options) => {
if (options?.allowBatching) { if (options?.allowBatching) {
AttachmentDownloadManager._instance?.saveJobsBatcher.add(job); if (AttachmentDownloadManager._instance != null) {
AttachmentDownloadManager._instance.#saveJobsBatcher.add(job);
}
} else { } else {
await DataWriter.saveAttachmentDownloadJob(job); await DataWriter.saveAttachmentDownloadJob(job);
} }
@ -166,7 +170,7 @@ export class AttachmentDownloadManager extends JobManager<CoreAttachmentDownload
getNextJobs: ({ limit }) => { getNextJobs: ({ limit }) => {
return params.getNextJobs({ return params.getNextJobs({
limit, limit,
prioritizeMessageIds: [...this.visibleTimelineMessages], prioritizeMessageIds: [...this.#visibleTimelineMessages],
sources: window.storage.get('backupMediaDownloadPaused') sources: window.storage.get('backupMediaDownloadPaused')
? [AttachmentDownloadSource.STANDARD] ? [AttachmentDownloadSource.STANDARD]
: undefined, : undefined,
@ -180,7 +184,7 @@ export class AttachmentDownloadManager extends JobManager<CoreAttachmentDownload
isLastAttempt, isLastAttempt,
}: { abortSignal: AbortSignal; isLastAttempt: boolean } }: { abortSignal: AbortSignal; isLastAttempt: boolean }
) => { ) => {
const isForCurrentlyVisibleMessage = this.visibleTimelineMessages.has( const isForCurrentlyVisibleMessage = this.#visibleTimelineMessages.has(
job.messageId job.messageId
); );
return params.runDownloadAttachmentJob({ return params.runDownloadAttachmentJob({
@ -250,7 +254,7 @@ export class AttachmentDownloadManager extends JobManager<CoreAttachmentDownload
} }
updateVisibleTimelineMessages(messageIds: Array<string>): void { updateVisibleTimelineMessages(messageIds: Array<string>): void {
this.visibleTimelineMessages = new Set(messageIds); this.#visibleTimelineMessages = new Set(messageIds);
} }
static get instance(): AttachmentDownloadManager { static get instance(): AttachmentDownloadManager {
@ -268,7 +272,7 @@ export class AttachmentDownloadManager extends JobManager<CoreAttachmentDownload
} }
static async saveBatchedJobs(): Promise<void> { static async saveBatchedJobs(): Promise<void> {
await AttachmentDownloadManager.instance.saveJobsBatcher.flushAndWait(); await AttachmentDownloadManager.instance.#saveJobsBatcher.flushAndWait();
} }
static async stop(): Promise<void> { static async stop(): Promise<void> {

View file

@ -5,9 +5,8 @@ import type { LoggerType } from '../types/Logging';
import type { ParsedJob } from './types'; import type { ParsedJob } from './types';
export class JobLogger implements LoggerType { export class JobLogger implements LoggerType {
private id: string; #id: string;
#queueType: string;
private queueType: string;
public attempt = -1; public attempt = -1;
@ -15,35 +14,35 @@ export class JobLogger implements LoggerType {
job: Readonly<Pick<ParsedJob<unknown>, 'id' | 'queueType'>>, job: Readonly<Pick<ParsedJob<unknown>, 'id' | 'queueType'>>,
private logger: LoggerType private logger: LoggerType
) { ) {
this.id = job.id; this.#id = job.id;
this.queueType = job.queueType; this.#queueType = job.queueType;
} }
fatal(...args: ReadonlyArray<unknown>): void { fatal(...args: ReadonlyArray<unknown>): void {
this.logger.fatal(this.prefix(), ...args); this.logger.fatal(this.#prefix(), ...args);
} }
error(...args: ReadonlyArray<unknown>): void { error(...args: ReadonlyArray<unknown>): void {
this.logger.error(this.prefix(), ...args); this.logger.error(this.#prefix(), ...args);
} }
warn(...args: ReadonlyArray<unknown>): void { warn(...args: ReadonlyArray<unknown>): void {
this.logger.warn(this.prefix(), ...args); this.logger.warn(this.#prefix(), ...args);
} }
info(...args: ReadonlyArray<unknown>): void { info(...args: ReadonlyArray<unknown>): void {
this.logger.info(this.prefix(), ...args); this.logger.info(this.#prefix(), ...args);
} }
debug(...args: ReadonlyArray<unknown>): void { debug(...args: ReadonlyArray<unknown>): void {
this.logger.debug(this.prefix(), ...args); this.logger.debug(this.#prefix(), ...args);
} }
trace(...args: ReadonlyArray<unknown>): void { trace(...args: ReadonlyArray<unknown>): void {
this.logger.trace(this.prefix(), ...args); this.logger.trace(this.#prefix(), ...args);
} }
private prefix(): string { #prefix(): string {
return `${this.queueType} job queue, job ID ${this.id}, attempt ${this.attempt}:`; return `${this.#queueType} job queue, job ID ${this.#id}, attempt ${this.attempt}:`;
} }
} }

View file

@ -77,14 +77,12 @@ export type ActiveJobData<CoreJobType> = {
}; };
export abstract class JobManager<CoreJobType> { export abstract class JobManager<CoreJobType> {
private enabled: boolean = false; #enabled: boolean = false;
private activeJobs: Map<string, ActiveJobData<CoreJobType>> = new Map(); #activeJobs: Map<string, ActiveJobData<CoreJobType>> = new Map();
private jobStartPromises: Map<string, ExplodePromiseResultType<void>> = #jobStartPromises: Map<string, ExplodePromiseResultType<void>> = new Map();
new Map(); #jobCompletePromises: Map<string, ExplodePromiseResultType<void>> = new Map();
private jobCompletePromises: Map<string, ExplodePromiseResultType<void>> = #tickTimeout: NodeJS.Timeout | null = null;
new Map(); #idleCallbacks = new Array<() => void>();
private tickTimeout: NodeJS.Timeout | null = null;
private idleCallbacks = new Array<() => void>();
protected logPrefix = 'JobManager'; protected logPrefix = 'JobManager';
public tickInterval = DEFAULT_TICK_INTERVAL; public tickInterval = DEFAULT_TICK_INTERVAL;
@ -92,25 +90,25 @@ export abstract class JobManager<CoreJobType> {
async start(): Promise<void> { async start(): Promise<void> {
log.info(`${this.logPrefix}: starting`); log.info(`${this.logPrefix}: starting`);
if (!this.enabled) { if (!this.#enabled) {
this.enabled = true; this.#enabled = true;
await this.params.markAllJobsInactive(); await this.params.markAllJobsInactive();
} }
await this.maybeStartJobs(); await this.maybeStartJobs();
this.tick(); this.#tick();
} }
async stop(): Promise<void> { async stop(): Promise<void> {
const activeJobs = [...this.activeJobs.values()]; const activeJobs = [...this.#activeJobs.values()];
log.info( log.info(
`${this.logPrefix}: stopping. There are ` + `${this.logPrefix}: stopping. There are ` +
`${activeJobs.length} active job(s)` `${activeJobs.length} active job(s)`
); );
this.enabled = false; this.#enabled = false;
clearTimeoutIfNecessary(this.tickTimeout); clearTimeoutIfNecessary(this.#tickTimeout);
this.tickTimeout = null; this.#tickTimeout = null;
await Promise.all( await Promise.all(
activeJobs.map(async ({ abortController, completionPromise }) => { activeJobs.map(async ({ abortController, completionPromise }) => {
abortController.abort(); abortController.abort();
@ -120,26 +118,26 @@ export abstract class JobManager<CoreJobType> {
} }
async waitForIdle(): Promise<void> { async waitForIdle(): Promise<void> {
if (this.activeJobs.size === 0) { if (this.#activeJobs.size === 0) {
return; return;
} }
await new Promise<void>(resolve => this.idleCallbacks.push(resolve)); await new Promise<void>(resolve => this.#idleCallbacks.push(resolve));
} }
private tick(): void { #tick(): void {
clearTimeoutIfNecessary(this.tickTimeout); clearTimeoutIfNecessary(this.#tickTimeout);
this.tickTimeout = null; this.#tickTimeout = null;
drop(this.maybeStartJobs()); drop(this.maybeStartJobs());
this.tickTimeout = setTimeout(() => this.tick(), this.tickInterval); this.#tickTimeout = setTimeout(() => this.#tick(), this.tickInterval);
} }
private pauseForDuration(durationMs: number): void { #pauseForDuration(durationMs: number): void {
this.enabled = false; this.#enabled = false;
clearTimeoutIfNecessary(this.tickTimeout); clearTimeoutIfNecessary(this.#tickTimeout);
this.tickTimeout = setTimeout(() => { this.#tickTimeout = setTimeout(() => {
this.enabled = true; this.#enabled = true;
this.tick(); this.#tick();
}, durationMs); }, durationMs);
} }
@ -147,26 +145,26 @@ export abstract class JobManager<CoreJobType> {
waitForJobToBeStarted( waitForJobToBeStarted(
job: CoreJobType & Pick<JobManagerJobType, 'attempts'> job: CoreJobType & Pick<JobManagerJobType, 'attempts'>
): Promise<void> { ): Promise<void> {
const id = this.getJobIdIncludingAttempts(job); const id = this.#getJobIdIncludingAttempts(job);
const existingPromise = this.jobStartPromises.get(id)?.promise; const existingPromise = this.#jobStartPromises.get(id)?.promise;
if (existingPromise) { if (existingPromise) {
return existingPromise; return existingPromise;
} }
const { promise, resolve, reject } = explodePromise<void>(); const { promise, resolve, reject } = explodePromise<void>();
this.jobStartPromises.set(id, { promise, resolve, reject }); this.#jobStartPromises.set(id, { promise, resolve, reject });
return promise; return promise;
} }
waitForJobToBeCompleted( waitForJobToBeCompleted(
job: CoreJobType & Pick<JobManagerJobType, 'attempts'> job: CoreJobType & Pick<JobManagerJobType, 'attempts'>
): Promise<void> { ): Promise<void> {
const id = this.getJobIdIncludingAttempts(job); const id = this.#getJobIdIncludingAttempts(job);
const existingPromise = this.jobCompletePromises.get(id)?.promise; const existingPromise = this.#jobCompletePromises.get(id)?.promise;
if (existingPromise) { if (existingPromise) {
return existingPromise; return existingPromise;
} }
const { promise, resolve, reject } = explodePromise<void>(); const { promise, resolve, reject } = explodePromise<void>();
this.jobCompletePromises.set(id, { promise, resolve, reject }); this.#jobCompletePromises.set(id, { promise, resolve, reject });
return promise; return promise;
} }
@ -188,7 +186,7 @@ export abstract class JobManager<CoreJobType> {
}; };
const logId = this.params.getJobIdForLogging(job); const logId = this.params.getJobIdForLogging(job);
try { try {
const runningJob = this.getRunningJob(job); const runningJob = this.#getRunningJob(job);
if (runningJob) { if (runningJob) {
log.info(`${logId}: already running; resetting attempts`); log.info(`${logId}: already running; resetting attempts`);
runningJob.attempts = 0; runningJob.attempts = 0;
@ -205,7 +203,7 @@ export abstract class JobManager<CoreJobType> {
await this.params.saveJob(job, { allowBatching: !options?.forceStart }); await this.params.saveJob(job, { allowBatching: !options?.forceStart });
if (options?.forceStart) { if (options?.forceStart) {
if (!this.enabled) { if (!this.#enabled) {
log.warn( log.warn(
`${logId}: added but jobManager not enabled, can't start immediately` `${logId}: added but jobManager not enabled, can't start immediately`
); );
@ -213,7 +211,7 @@ export abstract class JobManager<CoreJobType> {
log.info(`${logId}: starting job immediately`); log.info(`${logId}: starting job immediately`);
drop(this.startJob(job)); drop(this.startJob(job));
} }
} else if (this.enabled) { } else if (this.#enabled) {
drop(this.maybeStartJobs()); drop(this.maybeStartJobs());
} }
@ -230,20 +228,21 @@ export abstract class JobManager<CoreJobType> {
// 3. after a job finishes (via startJob) // 3. after a job finishes (via startJob)
// preventing re-entrancy allow us to simplify some logic and ensure we don't try to // preventing re-entrancy allow us to simplify some logic and ensure we don't try to
// start too many jobs // start too many jobs
private _inMaybeStartJobs = false; #_inMaybeStartJobs = false;
protected async maybeStartJobs(): Promise<void> { protected async maybeStartJobs(): Promise<void> {
if (this._inMaybeStartJobs) { if (this.#_inMaybeStartJobs) {
return; return;
} }
try { try {
this._inMaybeStartJobs = true; this.#_inMaybeStartJobs = true;
if (!this.enabled) { if (!this.#enabled) {
log.info(`${this.logPrefix}/_maybeStartJobs: not enabled, returning`); log.info(`${this.logPrefix}/_maybeStartJobs: not enabled, returning`);
return; return;
} }
const numJobsToStart = this.getMaximumNumberOfJobsToStart(); const numJobsToStart = this.#getMaximumNumberOfJobsToStart();
if (numJobsToStart <= 0) { if (numJobsToStart <= 0) {
return; return;
@ -254,10 +253,10 @@ export abstract class JobManager<CoreJobType> {
timestamp: Date.now(), timestamp: Date.now(),
}); });
if (nextJobs.length === 0 && this.activeJobs.size === 0) { if (nextJobs.length === 0 && this.#activeJobs.size === 0) {
if (this.idleCallbacks.length > 0) { if (this.#idleCallbacks.length > 0) {
const callbacks = this.idleCallbacks; const callbacks = this.#idleCallbacks;
this.idleCallbacks = []; this.#idleCallbacks = [];
for (const callback of callbacks) { for (const callback of callbacks) {
callback(); callback();
} }
@ -276,7 +275,7 @@ export abstract class JobManager<CoreJobType> {
drop(this.startJob(job)); drop(this.startJob(job));
} }
} finally { } finally {
this._inMaybeStartJobs = false; this.#_inMaybeStartJobs = false;
} }
} }
@ -286,7 +285,7 @@ export abstract class JobManager<CoreJobType> {
const logId = `${this.logPrefix}/startJob(${this.params.getJobIdForLogging( const logId = `${this.logPrefix}/startJob(${this.params.getJobIdForLogging(
job job
)})`; )})`;
if (this.isJobRunning(job)) { if (this.#isJobRunning(job)) {
log.info(`${logId}: job is already running`); log.info(`${logId}: job is already running`);
return; return;
} }
@ -298,13 +297,13 @@ export abstract class JobManager<CoreJobType> {
let jobRunResult: JobManagerJobResultType<CoreJobType> | undefined; let jobRunResult: JobManagerJobResultType<CoreJobType> | undefined;
try { try {
log.info(`${logId}: starting job`); log.info(`${logId}: starting job`);
const { abortController } = this.addRunningJob(job); const { abortController } = this.#addRunningJob(job);
await this.params.saveJob({ ...job, active: true }); await this.params.saveJob({ ...job, active: true });
const runJobPromise = this.params.runJob(job, { const runJobPromise = this.params.runJob(job, {
abortSignal: abortController.signal, abortSignal: abortController.signal,
isLastAttempt, isLastAttempt,
}); });
this.handleJobStartPromises(job); this.#handleJobStartPromises(job);
jobRunResult = await runJobPromise; jobRunResult = await runJobPromise;
const { status } = jobRunResult; const { status } = jobRunResult;
log.info(`${logId}: job completed with status: ${status}`); log.info(`${logId}: job completed with status: ${status}`);
@ -317,14 +316,14 @@ export abstract class JobManager<CoreJobType> {
if (isLastAttempt) { if (isLastAttempt) {
throw new Error('Cannot retry on last attempt'); throw new Error('Cannot retry on last attempt');
} }
await this.retryJobLater(job); await this.#retryJobLater(job);
return; return;
case 'rate-limited': case 'rate-limited':
log.info( log.info(
`${logId}: rate-limited; retrying in ${jobRunResult.pauseDurationMs}` `${logId}: rate-limited; retrying in ${jobRunResult.pauseDurationMs}`
); );
this.pauseForDuration(jobRunResult.pauseDurationMs); this.#pauseForDuration(jobRunResult.pauseDurationMs);
await this.retryJobLater(job); await this.#retryJobLater(job);
return; return;
default: default:
throw missingCaseError(status); throw missingCaseError(status);
@ -334,10 +333,10 @@ export abstract class JobManager<CoreJobType> {
if (isLastAttempt) { if (isLastAttempt) {
await this.params.removeJob(job); await this.params.removeJob(job);
} else { } else {
await this.retryJobLater(job); await this.#retryJobLater(job);
} }
} finally { } finally {
this.removeRunningJob(job); this.#removeRunningJob(job);
if (jobRunResult?.status === 'finished') { if (jobRunResult?.status === 'finished') {
if (jobRunResult.newJob) { if (jobRunResult.newJob) {
log.info( log.info(
@ -350,7 +349,7 @@ export abstract class JobManager<CoreJobType> {
} }
} }
private async retryJobLater(job: CoreJobType & JobManagerJobType) { async #retryJobLater(job: CoreJobType & JobManagerJobType) {
const now = Date.now(); const now = Date.now();
await this.params.saveJob({ await this.params.saveJob({
...job, ...job,
@ -366,43 +365,43 @@ export abstract class JobManager<CoreJobType> {
}); });
} }
private getActiveJobCount(): number { #getActiveJobCount(): number {
return this.activeJobs.size; return this.#activeJobs.size;
} }
private getMaximumNumberOfJobsToStart(): number { #getMaximumNumberOfJobsToStart(): number {
return Math.max( return Math.max(
0, 0,
this.params.maxConcurrentJobs - this.getActiveJobCount() this.params.maxConcurrentJobs - this.#getActiveJobCount()
); );
} }
private getRunningJob( #getRunningJob(
job: CoreJobType & JobManagerJobType job: CoreJobType & JobManagerJobType
): (CoreJobType & JobManagerJobType) | undefined { ): (CoreJobType & JobManagerJobType) | undefined {
const id = this.params.getJobId(job); const id = this.params.getJobId(job);
return this.activeJobs.get(id)?.job; return this.#activeJobs.get(id)?.job;
} }
private isJobRunning(job: CoreJobType & JobManagerJobType): boolean { #isJobRunning(job: CoreJobType & JobManagerJobType): boolean {
return Boolean(this.getRunningJob(job)); return Boolean(this.#getRunningJob(job));
} }
private removeRunningJob(job: CoreJobType & JobManagerJobType) { #removeRunningJob(job: CoreJobType & JobManagerJobType) {
const idWithAttempts = this.getJobIdIncludingAttempts(job); const idWithAttempts = this.#getJobIdIncludingAttempts(job);
this.jobCompletePromises.get(idWithAttempts)?.resolve(); this.#jobCompletePromises.get(idWithAttempts)?.resolve();
this.jobCompletePromises.delete(idWithAttempts); this.#jobCompletePromises.delete(idWithAttempts);
const id = this.params.getJobId(job); const id = this.params.getJobId(job);
this.activeJobs.get(id)?.completionPromise.resolve(); this.#activeJobs.get(id)?.completionPromise.resolve();
this.activeJobs.delete(id); this.#activeJobs.delete(id);
} }
public async cancelJobs( public async cancelJobs(
predicate: (job: CoreJobType & JobManagerJobType) => boolean predicate: (job: CoreJobType & JobManagerJobType) => boolean
): Promise<void> { ): Promise<void> {
const logId = `${this.logPrefix}/cancelJobs`; const logId = `${this.logPrefix}/cancelJobs`;
const jobs = Array.from(this.activeJobs.values()).filter(data => const jobs = Array.from(this.#activeJobs.values()).filter(data =>
predicate(data.job) predicate(data.job)
); );
@ -419,15 +418,15 @@ export abstract class JobManager<CoreJobType> {
// First tell those waiting for the job that it's not happening // First tell those waiting for the job that it's not happening
const rejectionError = new Error('Cancelled at JobManager.cancelJobs'); const rejectionError = new Error('Cancelled at JobManager.cancelJobs');
const idWithAttempts = this.getJobIdIncludingAttempts(job); const idWithAttempts = this.#getJobIdIncludingAttempts(job);
this.jobCompletePromises.get(idWithAttempts)?.reject(rejectionError); this.#jobCompletePromises.get(idWithAttempts)?.reject(rejectionError);
this.jobCompletePromises.delete(idWithAttempts); this.#jobCompletePromises.delete(idWithAttempts);
// Give the job 1 second to cancel itself // Give the job 1 second to cancel itself
await Promise.race([completionPromise.promise, sleep(SECOND)]); await Promise.race([completionPromise.promise, sleep(SECOND)]);
const jobId = this.params.getJobId(job); const jobId = this.params.getJobId(job);
const hasCompleted = Boolean(this.activeJobs.get(jobId)); const hasCompleted = Boolean(this.#activeJobs.get(jobId));
if (!hasCompleted) { if (!hasCompleted) {
const jobIdForLogging = this.params.getJobIdForLogging(job); const jobIdForLogging = this.params.getJobIdForLogging(job);
@ -435,7 +434,7 @@ export abstract class JobManager<CoreJobType> {
`${logId}: job ${jobIdForLogging} didn't complete; rejecting promises` `${logId}: job ${jobIdForLogging} didn't complete; rejecting promises`
); );
completionPromise.reject(rejectionError); completionPromise.reject(rejectionError);
this.activeJobs.delete(jobId); this.#activeJobs.delete(jobId);
} }
await this.params.removeJob(job); await this.params.removeJob(job);
@ -445,10 +444,10 @@ export abstract class JobManager<CoreJobType> {
log.warn(`${logId}: Successfully cancelled ${jobs.length} jobs`); log.warn(`${logId}: Successfully cancelled ${jobs.length} jobs`);
} }
private addRunningJob( #addRunningJob(
job: CoreJobType & JobManagerJobType job: CoreJobType & JobManagerJobType
): ActiveJobData<CoreJobType> { ): ActiveJobData<CoreJobType> {
if (this.isJobRunning(job)) { if (this.#isJobRunning(job)) {
const jobIdForLogging = this.params.getJobIdForLogging(job); const jobIdForLogging = this.params.getJobIdForLogging(job);
log.warn( log.warn(
`${this.logPrefix}/addRunningJob: job ${jobIdForLogging} is already running` `${this.logPrefix}/addRunningJob: job ${jobIdForLogging} is already running`
@ -460,18 +459,18 @@ export abstract class JobManager<CoreJobType> {
abortController: new AbortController(), abortController: new AbortController(),
job, job,
}; };
this.activeJobs.set(this.params.getJobId(job), activeJob); this.#activeJobs.set(this.params.getJobId(job), activeJob);
return activeJob; return activeJob;
} }
private handleJobStartPromises(job: CoreJobType & JobManagerJobType) { #handleJobStartPromises(job: CoreJobType & JobManagerJobType) {
const id = this.getJobIdIncludingAttempts(job); const id = this.#getJobIdIncludingAttempts(job);
this.jobStartPromises.get(id)?.resolve(); this.#jobStartPromises.get(id)?.resolve();
this.jobStartPromises.delete(id); this.#jobStartPromises.delete(id);
} }
private getJobIdIncludingAttempts( #getJobIdIncludingAttempts(
job: CoreJobType & Pick<JobManagerJobType, 'attempts'> job: CoreJobType & Pick<JobManagerJobType, 'attempts'>
) { ) {
return `${this.params.getJobId(job)}.${job.attempts}`; return `${this.params.getJobId(job)}.${job.attempts}`;

View file

@ -53,20 +53,15 @@ export enum JOB_STATUS {
} }
export abstract class JobQueue<T> { export abstract class JobQueue<T> {
private readonly maxAttempts: number; readonly #maxAttempts: number;
readonly #queueType: string;
readonly #store: JobQueueStore;
readonly #logger: LoggerType;
readonly #logPrefix: string;
#shuttingDown = false;
#paused = false;
private readonly queueType: string; readonly #onCompleteCallbacks = new Map<
private readonly store: JobQueueStore;
private readonly logger: LoggerType;
private readonly logPrefix: string;
private shuttingDown = false;
private paused = false;
private readonly onCompleteCallbacks = new Map<
string, string,
{ {
resolve: () => void; resolve: () => void;
@ -74,15 +69,15 @@ export abstract class JobQueue<T> {
} }
>(); >();
private readonly defaultInMemoryQueue = new PQueue({ concurrency: 1 }); readonly #defaultInMemoryQueue = new PQueue({ concurrency: 1 });
#started = false;
private started = false;
get isShuttingDown(): boolean { get isShuttingDown(): boolean {
return this.shuttingDown; return this.#shuttingDown;
} }
get isPaused(): boolean { get isPaused(): boolean {
return this.paused; return this.#paused;
} }
constructor(options: Readonly<JobQueueOptions>) { constructor(options: Readonly<JobQueueOptions>) {
@ -99,12 +94,12 @@ export abstract class JobQueue<T> {
'queueType should be a non-blank string' 'queueType should be a non-blank string'
); );
this.maxAttempts = options.maxAttempts; this.#maxAttempts = options.maxAttempts;
this.queueType = options.queueType; this.#queueType = options.queueType;
this.store = options.store; this.#store = options.store;
this.logger = options.logger ?? log; this.#logger = options.logger ?? log;
this.logPrefix = `${this.queueType} job queue:`; this.#logPrefix = `${this.#queueType} job queue:`;
} }
/** /**
@ -135,35 +130,37 @@ export abstract class JobQueue<T> {
): Promise<JOB_STATUS.NEEDS_RETRY | undefined>; ): Promise<JOB_STATUS.NEEDS_RETRY | undefined>;
protected getQueues(): ReadonlySet<PQueue> { protected getQueues(): ReadonlySet<PQueue> {
return new Set([this.defaultInMemoryQueue]); return new Set([this.#defaultInMemoryQueue]);
} }
/** /**
* Start streaming jobs from the store. * Start streaming jobs from the store.
*/ */
async streamJobs(): Promise<void> { async streamJobs(): Promise<void> {
if (this.started) { if (this.#started) {
throw new Error( throw new Error(
`${this.logPrefix} should not start streaming more than once` `${this.#logPrefix} should not start streaming more than once`
); );
} }
this.started = true; this.#started = true;
log.info(`${this.logPrefix} starting to stream jobs`); log.info(`${this.#logPrefix} starting to stream jobs`);
const stream = this.store.stream(this.queueType); const stream = this.#store.stream(this.#queueType);
for await (const storedJob of stream) { for await (const storedJob of stream) {
if (this.shuttingDown) { if (this.#shuttingDown) {
log.info(`${this.logPrefix} is shutting down. Can't accept more work.`); log.info(
`${this.#logPrefix} is shutting down. Can't accept more work.`
);
break; break;
} }
if (this.paused) { if (this.#paused) {
log.info(`${this.logPrefix} is paused. Waiting until resume.`); log.info(`${this.#logPrefix} is paused. Waiting until resume.`);
while (this.paused) { while (this.#paused) {
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
await sleep(SECOND); await sleep(SECOND);
} }
log.info(`${this.logPrefix} has been resumed. Queuing job.`); log.info(`${this.#logPrefix} has been resumed. Queuing job.`);
} }
drop(this.enqueueStoredJob(storedJob)); drop(this.enqueueStoredJob(storedJob));
} }
@ -183,18 +180,18 @@ export abstract class JobQueue<T> {
): Promise<Job<T>> { ): Promise<Job<T>> {
const job = this.createJob(data); const job = this.createJob(data);
if (!this.started) { if (!this.#started) {
log.warn( log.warn(
`${this.logPrefix} This queue has not started streaming, adding job ${job.id} to database only.` `${this.#logPrefix} This queue has not started streaming, adding job ${job.id} to database only.`
); );
} }
if (insert) { if (insert) {
await insert(job); await insert(job);
} }
await this.store.insert(job, { shouldPersist: !insert }); await this.#store.insert(job, { shouldPersist: !insert });
log.info(`${this.logPrefix} added new job ${job.id}`); log.info(`${this.#logPrefix} added new job ${job.id}`);
return job; return job;
} }
@ -203,7 +200,7 @@ export abstract class JobQueue<T> {
const timestamp = Date.now(); const timestamp = Date.now();
const completionPromise = new Promise<void>((resolve, reject) => { const completionPromise = new Promise<void>((resolve, reject) => {
this.onCompleteCallbacks.set(id, { resolve, reject }); this.#onCompleteCallbacks.set(id, { resolve, reject });
}); });
const completion = (async () => { const completion = (async () => {
try { try {
@ -211,41 +208,41 @@ export abstract class JobQueue<T> {
} catch (err: unknown) { } catch (err: unknown) {
throw new JobError(err); throw new JobError(err);
} finally { } finally {
this.onCompleteCallbacks.delete(id); this.#onCompleteCallbacks.delete(id);
} }
})(); })();
return new Job(id, timestamp, this.queueType, data, completion); return new Job(id, timestamp, this.#queueType, data, completion);
} }
protected getInMemoryQueue(_parsedJob: ParsedJob<T>): PQueue { protected getInMemoryQueue(_parsedJob: ParsedJob<T>): PQueue {
return this.defaultInMemoryQueue; return this.#defaultInMemoryQueue;
} }
protected async enqueueStoredJob( protected async enqueueStoredJob(
storedJob: Readonly<StoredJob> storedJob: Readonly<StoredJob>
): Promise<void> { ): Promise<void> {
assertDev( assertDev(
storedJob.queueType === this.queueType, storedJob.queueType === this.#queueType,
'Received a mis-matched queue type' 'Received a mis-matched queue type'
); );
log.info(`${this.logPrefix} enqueuing job ${storedJob.id}`); log.info(`${this.#logPrefix} enqueuing job ${storedJob.id}`);
// It's okay if we don't have a callback; that likely means the job was created before // It's okay if we don't have a callback; that likely means the job was created before
// the process was started (e.g., from a previous run). // the process was started (e.g., from a previous run).
const { resolve, reject } = const { resolve, reject } =
this.onCompleteCallbacks.get(storedJob.id) || noopOnCompleteCallbacks; this.#onCompleteCallbacks.get(storedJob.id) || noopOnCompleteCallbacks;
let parsedData: T; let parsedData: T;
try { try {
parsedData = this.parseData(storedJob.data); parsedData = this.parseData(storedJob.data);
} catch (err) { } catch (err) {
log.error( log.error(
`${this.logPrefix} failed to parse data for job ${storedJob.id}, created ${storedJob.timestamp}. Deleting job. Parse error:`, `${this.#logPrefix} failed to parse data for job ${storedJob.id}, created ${storedJob.timestamp}. Deleting job. Parse error:`,
Errors.toLogFormat(err) Errors.toLogFormat(err)
); );
await this.store.delete(storedJob.id); await this.#store.delete(storedJob.id);
reject( reject(
new Error( new Error(
'Failed to parse job data. Was unexpected data loaded from the database?' 'Failed to parse job data. Was unexpected data loaded from the database?'
@ -261,7 +258,7 @@ export abstract class JobQueue<T> {
const queue: PQueue = this.getInMemoryQueue(parsedJob); const queue: PQueue = this.getInMemoryQueue(parsedJob);
const logger = new JobLogger(parsedJob, this.logger); const logger = new JobLogger(parsedJob, this.#logger);
const result: const result:
| undefined | undefined
@ -269,18 +266,18 @@ export abstract class JobQueue<T> {
| { status: JOB_STATUS.NEEDS_RETRY } | { status: JOB_STATUS.NEEDS_RETRY }
| { status: JOB_STATUS.ERROR; err: unknown } = await queue.add( | { status: JOB_STATUS.ERROR; err: unknown } = await queue.add(
async () => { async () => {
for (let attempt = 1; attempt <= this.maxAttempts; attempt += 1) { for (let attempt = 1; attempt <= this.#maxAttempts; attempt += 1) {
const isFinalAttempt = attempt === this.maxAttempts; const isFinalAttempt = attempt === this.#maxAttempts;
logger.attempt = attempt; logger.attempt = attempt;
log.info( log.info(
`${this.logPrefix} running job ${storedJob.id}, attempt ${attempt} of ${this.maxAttempts}` `${this.#logPrefix} running job ${storedJob.id}, attempt ${attempt} of ${this.#maxAttempts}`
); );
if (this.isShuttingDown) { if (this.isShuttingDown) {
log.warn( log.warn(
`${this.logPrefix} returning early for job ${storedJob.id}; shutting down` `${this.#logPrefix} returning early for job ${storedJob.id}; shutting down`
); );
return { return {
status: JOB_STATUS.ERROR, status: JOB_STATUS.ERROR,
@ -298,17 +295,17 @@ export abstract class JobQueue<T> {
}); });
if (!jobStatus) { if (!jobStatus) {
log.info( log.info(
`${this.logPrefix} job ${storedJob.id} succeeded on attempt ${attempt}` `${this.#logPrefix} job ${storedJob.id} succeeded on attempt ${attempt}`
); );
return { status: JOB_STATUS.SUCCESS }; return { status: JOB_STATUS.SUCCESS };
} }
log.info( log.info(
`${this.logPrefix} job ${storedJob.id} returned status ${jobStatus} on attempt ${attempt}` `${this.#logPrefix} job ${storedJob.id} returned status ${jobStatus} on attempt ${attempt}`
); );
return { status: jobStatus }; return { status: jobStatus };
} catch (err: unknown) { } catch (err: unknown) {
log.error( log.error(
`${this.logPrefix} job ${ `${this.#logPrefix} job ${
storedJob.id storedJob.id
} failed on attempt ${attempt}. ${Errors.toLogFormat(err)}` } failed on attempt ${attempt}. ${Errors.toLogFormat(err)}`
); );
@ -330,14 +327,14 @@ export abstract class JobQueue<T> {
logger, logger,
}); });
if (!addJobSuccess) { if (!addJobSuccess) {
await this.store.delete(storedJob.id); await this.#store.delete(storedJob.id);
} }
} }
if ( if (
result?.status === JOB_STATUS.SUCCESS || result?.status === JOB_STATUS.SUCCESS ||
(result?.status === JOB_STATUS.ERROR && !this.isShuttingDown) (result?.status === JOB_STATUS.ERROR && !this.isShuttingDown)
) { ) {
await this.store.delete(storedJob.id); await this.#store.delete(storedJob.id);
} }
assertDev( assertDev(
@ -359,7 +356,7 @@ export abstract class JobQueue<T> {
logger: LoggerType; logger: LoggerType;
}): Promise<boolean> { }): Promise<boolean> {
logger.error( logger.error(
`retryJobOnQueueIdle: not implemented for queue ${this.queueType}; dropping job` `retryJobOnQueueIdle: not implemented for queue ${this.#queueType}; dropping job`
); );
return false; return false;
} }
@ -367,18 +364,20 @@ export abstract class JobQueue<T> {
async shutdown(): Promise<void> { async shutdown(): Promise<void> {
const queues = this.getQueues(); const queues = this.getQueues();
log.info( log.info(
`${this.logPrefix} shutdown: stop accepting new work and drain ${queues.size} promise queues` `${this.#logPrefix} shutdown: stop accepting new work and drain ${queues.size} promise queues`
); );
this.shuttingDown = true; this.#shuttingDown = true;
await Promise.all([...queues].map(q => q.onIdle())); await Promise.all([...queues].map(q => q.onIdle()));
log.info(`${this.logPrefix} shutdown: complete`); log.info(`${this.#logPrefix} shutdown: complete`);
} }
pause(): void { pause(): void {
log.info(`${this.logPrefix}: pausing queue`); log.info(`${this.#logPrefix}: pausing queue`);
this.paused = true; this.#paused = true;
} }
resume(): void { resume(): void {
log.info(`${this.logPrefix}: resuming queue`); log.info(`${this.#logPrefix}: resuming queue`);
this.paused = false; this.#paused = false;
} }
} }

View file

@ -16,11 +16,9 @@ type Database = {
}; };
export class JobQueueDatabaseStore implements JobQueueStore { export class JobQueueDatabaseStore implements JobQueueStore {
private activeQueueTypes = new Set<string>(); #activeQueueTypes = new Set<string>();
#queues = new Map<string, AsyncQueue<StoredJob>>();
private queues = new Map<string, AsyncQueue<StoredJob>>(); #initialFetchPromises = new Map<string, Promise<void>>();
private initialFetchPromises = new Map<string, Promise<void>>();
constructor(private readonly db: Database) {} constructor(private readonly db: Database) {}
@ -34,7 +32,7 @@ export class JobQueueDatabaseStore implements JobQueueStore {
)}` )}`
); );
const initialFetchPromise = this.initialFetchPromises.get(job.queueType); const initialFetchPromise = this.#initialFetchPromises.get(job.queueType);
if (initialFetchPromise) { if (initialFetchPromise) {
await initialFetchPromise; await initialFetchPromise;
} else { } else {
@ -48,7 +46,7 @@ export class JobQueueDatabaseStore implements JobQueueStore {
} }
if (initialFetchPromise) { if (initialFetchPromise) {
this.getQueue(job.queueType).add(job); this.#getQueue(job.queueType).add(job);
} }
} }
@ -57,31 +55,31 @@ export class JobQueueDatabaseStore implements JobQueueStore {
} }
stream(queueType: string): AsyncIterable<StoredJob> { stream(queueType: string): AsyncIterable<StoredJob> {
if (this.activeQueueTypes.has(queueType)) { if (this.#activeQueueTypes.has(queueType)) {
throw new Error( throw new Error(
`Cannot stream queue type ${JSON.stringify(queueType)} more than once` `Cannot stream queue type ${JSON.stringify(queueType)} more than once`
); );
} }
this.activeQueueTypes.add(queueType); this.#activeQueueTypes.add(queueType);
return concat([ return concat([
wrapPromise(this.fetchJobsAtStart(queueType)), wrapPromise(this.#fetchJobsAtStart(queueType)),
this.getQueue(queueType), this.#getQueue(queueType),
]); ]);
} }
private getQueue(queueType: string): AsyncQueue<StoredJob> { #getQueue(queueType: string): AsyncQueue<StoredJob> {
const existingQueue = this.queues.get(queueType); const existingQueue = this.#queues.get(queueType);
if (existingQueue) { if (existingQueue) {
return existingQueue; return existingQueue;
} }
const result = new AsyncQueue<StoredJob>(); const result = new AsyncQueue<StoredJob>();
this.queues.set(queueType, result); this.#queues.set(queueType, result);
return result; return result;
} }
private async fetchJobsAtStart(queueType: string): Promise<Array<StoredJob>> { async #fetchJobsAtStart(queueType: string): Promise<Array<StoredJob>> {
log.info( log.info(
`JobQueueDatabaseStore fetching existing jobs for queue ${JSON.stringify( `JobQueueDatabaseStore fetching existing jobs for queue ${JSON.stringify(
queueType queueType
@ -94,7 +92,7 @@ export class JobQueueDatabaseStore implements JobQueueStore {
const initialFetchPromise = new Promise<void>(resolve => { const initialFetchPromise = new Promise<void>(resolve => {
onFinished = resolve; onFinished = resolve;
}); });
this.initialFetchPromises.set(queueType, initialFetchPromise); this.#initialFetchPromises.set(queueType, initialFetchPromise);
const result = await this.db.getJobsInQueue(queueType); const result = await this.db.getJobsInQueue(queueType);
log.info( log.info(

View file

@ -45,18 +45,17 @@ export type CallLinkRefreshJobData = z.infer<
>; >;
export class CallLinkRefreshJobQueue extends JobQueue<CallLinkRefreshJobData> { export class CallLinkRefreshJobQueue extends JobQueue<CallLinkRefreshJobData> {
private parallelQueue = new PQueue({ concurrency: MAX_PARALLEL_JOBS }); #parallelQueue = new PQueue({ concurrency: MAX_PARALLEL_JOBS });
readonly #pendingCallLinks = new Map<string, PendingCallLinkType>();
private readonly pendingCallLinks = new Map<string, PendingCallLinkType>();
protected override getQueues(): ReadonlySet<PQueue> { protected override getQueues(): ReadonlySet<PQueue> {
return new Set([this.parallelQueue]); return new Set([this.#parallelQueue]);
} }
protected override getInMemoryQueue( protected override getInMemoryQueue(
_parsedJob: ParsedJob<CallLinkRefreshJobData> _parsedJob: ParsedJob<CallLinkRefreshJobData>
): PQueue { ): PQueue {
return this.parallelQueue; return this.#parallelQueue;
} }
protected parseData(data: unknown): CallLinkRefreshJobData { protected parseData(data: unknown): CallLinkRefreshJobData {
@ -81,7 +80,7 @@ export class CallLinkRefreshJobQueue extends JobQueue<CallLinkRefreshJobData> {
adminKey, adminKey,
} = parsedData ?? {}; } = parsedData ?? {};
if (storageID && storageVersion && rootKey) { if (storageID && storageVersion && rootKey) {
this.pendingCallLinks.set(rootKey, { this.#pendingCallLinks.set(rootKey, {
rootKey, rootKey,
adminKey: adminKey ?? null, adminKey: adminKey ?? null,
storageID: storageID ?? undefined, storageID: storageID ?? undefined,
@ -94,7 +93,7 @@ export class CallLinkRefreshJobQueue extends JobQueue<CallLinkRefreshJobData> {
await super.enqueueStoredJob(storedJob); await super.enqueueStoredJob(storedJob);
if (rootKey) { if (rootKey) {
this.pendingCallLinks.delete(rootKey); this.#pendingCallLinks.delete(rootKey);
} }
} }
@ -102,13 +101,13 @@ export class CallLinkRefreshJobQueue extends JobQueue<CallLinkRefreshJobData> {
// depending on the refresh result, we will create either CallLinks or DefunctCallLinks, // depending on the refresh result, we will create either CallLinks or DefunctCallLinks,
// and we'll save storageID and version onto those records. // and we'll save storageID and version onto those records.
public getPendingAdminCallLinks(): ReadonlyArray<PendingCallLinkType> { public getPendingAdminCallLinks(): ReadonlyArray<PendingCallLinkType> {
return Array.from(this.pendingCallLinks.values()).filter( return Array.from(this.#pendingCallLinks.values()).filter(
callLink => callLink.adminKey != null callLink => callLink.adminKey != null
); );
} }
public hasPendingCallLink(rootKey: string): boolean { public hasPendingCallLink(rootKey: string): boolean {
return this.pendingCallLinks.has(rootKey); return this.#pendingCallLinks.has(rootKey);
} }
// If a new version of storage is uploaded before we get a chance to refresh the // If a new version of storage is uploaded before we get a chance to refresh the
@ -118,7 +117,7 @@ export class CallLinkRefreshJobQueue extends JobQueue<CallLinkRefreshJobData> {
rootKey: string, rootKey: string,
storageFields: StorageServiceFieldsType storageFields: StorageServiceFieldsType
): void { ): void {
const existingStorageFields = this.pendingCallLinks.get(rootKey); const existingStorageFields = this.#pendingCallLinks.get(rootKey);
if (!existingStorageFields) { if (!existingStorageFields) {
globalLogger.warn( globalLogger.warn(
'callLinkRefreshJobQueue.updatePendingCallLinkStorageFields: unknown rootKey' 'callLinkRefreshJobQueue.updatePendingCallLinkStorageFields: unknown rootKey'
@ -126,7 +125,7 @@ export class CallLinkRefreshJobQueue extends JobQueue<CallLinkRefreshJobData> {
return; return;
} }
this.pendingCallLinks.set(rootKey, { this.#pendingCallLinks.set(rootKey, {
...existingStorageFields, ...existingStorageFields,
...storageFields, ...storageFields,
}); });
@ -136,7 +135,7 @@ export class CallLinkRefreshJobQueue extends JobQueue<CallLinkRefreshJobData> {
storageID: string, storageID: string,
jobData: CallLinkRefreshJobData jobData: CallLinkRefreshJobData
): StorageServiceFieldsType | undefined { ): StorageServiceFieldsType | undefined {
const storageFields = this.pendingCallLinks.get(storageID); const storageFields = this.#pendingCallLinks.get(storageID);
if (storageFields) { if (storageFields) {
return { return {
storageID: storageFields.storageID, storageID: storageFields.storageID,

View file

@ -384,12 +384,14 @@ type ConversationData = Readonly<
>; >;
export class ConversationJobQueue extends JobQueue<ConversationQueueJobData> { export class ConversationJobQueue extends JobQueue<ConversationQueueJobData> {
private readonly perConversationData = new Map< readonly #perConversationData = new Map<
string, string,
ConversationData | undefined ConversationData | undefined
>(); >();
private readonly inMemoryQueues = new InMemoryQueues();
private readonly verificationWaitMap = new Map< readonly #inMemoryQueues = new InMemoryQueues();
readonly #verificationWaitMap = new Map<
string, string,
{ {
resolve: (value: unknown) => unknown; resolve: (value: unknown) => unknown;
@ -397,10 +399,11 @@ export class ConversationJobQueue extends JobQueue<ConversationQueueJobData> {
promise: Promise<unknown>; promise: Promise<unknown>;
} }
>(); >();
private callbackCount = 0;
#callbackCount = 0;
override getQueues(): ReadonlySet<PQueue> { override getQueues(): ReadonlySet<PQueue> {
return this.inMemoryQueues.allQueues; return this.#inMemoryQueues.allQueues;
} }
public override async add( public override async add(
@ -430,11 +433,11 @@ export class ConversationJobQueue extends JobQueue<ConversationQueueJobData> {
protected override getInMemoryQueue({ protected override getInMemoryQueue({
data, data,
}: Readonly<{ data: ConversationQueueJobData }>): PQueue { }: Readonly<{ data: ConversationQueueJobData }>): PQueue {
return this.inMemoryQueues.get(data.conversationId); return this.#inMemoryQueues.get(data.conversationId);
} }
private startVerificationWaiter(conversationId: string): Promise<unknown> { #startVerificationWaiter(conversationId: string): Promise<unknown> {
const existing = this.verificationWaitMap.get(conversationId); const existing = this.#verificationWaitMap.get(conversationId);
if (existing) { if (existing) {
globalLogger.info( globalLogger.info(
`startVerificationWaiter: Found existing waiter for conversation ${conversationId}. Returning it.` `startVerificationWaiter: Found existing waiter for conversation ${conversationId}. Returning it.`
@ -446,7 +449,7 @@ export class ConversationJobQueue extends JobQueue<ConversationQueueJobData> {
`startVerificationWaiter: Starting new waiter for conversation ${conversationId}.` `startVerificationWaiter: Starting new waiter for conversation ${conversationId}.`
); );
const { resolve, reject, promise } = explodePromise(); const { resolve, reject, promise } = explodePromise();
this.verificationWaitMap.set(conversationId, { this.#verificationWaitMap.set(conversationId, {
resolve, resolve,
reject, reject,
promise, promise,
@ -456,25 +459,25 @@ export class ConversationJobQueue extends JobQueue<ConversationQueueJobData> {
} }
public resolveVerificationWaiter(conversationId: string): void { public resolveVerificationWaiter(conversationId: string): void {
const existing = this.verificationWaitMap.get(conversationId); const existing = this.#verificationWaitMap.get(conversationId);
if (existing) { if (existing) {
globalLogger.info( globalLogger.info(
`resolveVerificationWaiter: Found waiter for conversation ${conversationId}. Resolving.` `resolveVerificationWaiter: Found waiter for conversation ${conversationId}. Resolving.`
); );
existing.resolve('resolveVerificationWaiter: success'); existing.resolve('resolveVerificationWaiter: success');
this.verificationWaitMap.delete(conversationId); this.#verificationWaitMap.delete(conversationId);
} else { } else {
globalLogger.warn( globalLogger.warn(
`resolveVerificationWaiter: Missing waiter for conversation ${conversationId}.` `resolveVerificationWaiter: Missing waiter for conversation ${conversationId}.`
); );
this.unblockConversationRetries(conversationId); this.#unblockConversationRetries(conversationId);
} }
} }
private unblockConversationRetries(conversationId: string) { #unblockConversationRetries(conversationId: string) {
const logId = `unblockConversationRetries/${conversationId}`; const logId = `unblockConversationRetries/${conversationId}`;
const perConversationData = this.perConversationData.get(conversationId); const perConversationData = this.#perConversationData.get(conversationId);
if (!perConversationData) { if (!perConversationData) {
return; return;
} }
@ -484,7 +487,7 @@ export class ConversationJobQueue extends JobQueue<ConversationQueueJobData> {
globalLogger.info( globalLogger.info(
`${logId}: Previously BLOCKED, moving to RUNNING state` `${logId}: Previously BLOCKED, moving to RUNNING state`
); );
this.perConversationData.set(conversationId, { this.#perConversationData.set(conversationId, {
status: RETRY_STATUS.RUNNING, status: RETRY_STATUS.RUNNING,
attempts, attempts,
callback: undefined, callback: undefined,
@ -496,7 +499,7 @@ export class ConversationJobQueue extends JobQueue<ConversationQueueJobData> {
globalLogger.info( globalLogger.info(
`${logId}: Moving previous BLOCKED state to UNBLOCKED, calling callback directly` `${logId}: Moving previous BLOCKED state to UNBLOCKED, calling callback directly`
); );
this.perConversationData.set(conversationId, { this.#perConversationData.set(conversationId, {
...perConversationData, ...perConversationData,
status: RETRY_STATUS.UNBLOCKED, status: RETRY_STATUS.UNBLOCKED,
retryAt: undefined, retryAt: undefined,
@ -516,10 +519,10 @@ export class ConversationJobQueue extends JobQueue<ConversationQueueJobData> {
} }
} }
private recordSuccessfulSend(conversationId: string) { #recordSuccessfulSend(conversationId: string) {
const logId = `recordSuccessfulSend/${conversationId}`; const logId = `recordSuccessfulSend/${conversationId}`;
const perConversationData = this.perConversationData.get(conversationId); const perConversationData = this.#perConversationData.get(conversationId);
if (!perConversationData) { if (!perConversationData) {
return; return;
} }
@ -527,7 +530,7 @@ export class ConversationJobQueue extends JobQueue<ConversationQueueJobData> {
const { status } = perConversationData; const { status } = perConversationData;
if (status === RETRY_STATUS.RUNNING || status === RETRY_STATUS.BLOCKED) { if (status === RETRY_STATUS.RUNNING || status === RETRY_STATUS.BLOCKED) {
globalLogger.info(`${logId}: Previously ${status}; clearing state`); globalLogger.info(`${logId}: Previously ${status}; clearing state`);
this.perConversationData.delete(conversationId); this.#perConversationData.delete(conversationId);
} else if ( } else if (
status === RETRY_STATUS.BLOCKED_WITH_JOBS || status === RETRY_STATUS.BLOCKED_WITH_JOBS ||
status === RETRY_STATUS.UNBLOCKED status === RETRY_STATUS.UNBLOCKED
@ -536,23 +539,23 @@ export class ConversationJobQueue extends JobQueue<ConversationQueueJobData> {
`${logId}: We're still in ${status} state; calling unblockConversationRetries` `${logId}: We're still in ${status} state; calling unblockConversationRetries`
); );
// We have to do this because in these states there are jobs that need to be retried // We have to do this because in these states there are jobs that need to be retried
this.unblockConversationRetries(conversationId); this.#unblockConversationRetries(conversationId);
} else { } else {
throw missingCaseError(status); throw missingCaseError(status);
} }
} }
private getRetryWithBackoff(attempts: number) { #getRetryWithBackoff(attempts: number) {
return ( return (
Date.now() + Date.now() +
MINUTE * (FIBONACCI[attempts] ?? FIBONACCI[FIBONACCI.length - 1]) MINUTE * (FIBONACCI[attempts] ?? FIBONACCI[FIBONACCI.length - 1])
); );
} }
private captureRetryAt(conversationId: string, retryAt: number | undefined) { #captureRetryAt(conversationId: string, retryAt: number | undefined) {
const logId = `captureRetryAt/${conversationId}`; const logId = `captureRetryAt/${conversationId}`;
const perConversationData = this.perConversationData.get(conversationId); const perConversationData = this.#perConversationData.get(conversationId);
if (!perConversationData) { if (!perConversationData) {
const newRetryAt = retryAt || Date.now() + MINUTE; const newRetryAt = retryAt || Date.now() + MINUTE;
if (!retryAt) { if (!retryAt) {
@ -560,7 +563,7 @@ export class ConversationJobQueue extends JobQueue<ConversationQueueJobData> {
`${logId}: No existing data, using retryAt of ${newRetryAt}` `${logId}: No existing data, using retryAt of ${newRetryAt}`
); );
} }
this.perConversationData.set(conversationId, { this.#perConversationData.set(conversationId, {
status: RETRY_STATUS.BLOCKED, status: RETRY_STATUS.BLOCKED,
attempts: 1, attempts: 1,
retryAt: newRetryAt, retryAt: newRetryAt,
@ -573,7 +576,7 @@ export class ConversationJobQueue extends JobQueue<ConversationQueueJobData> {
const { status, retryAt: existingRetryAt } = perConversationData; const { status, retryAt: existingRetryAt } = perConversationData;
const attempts = perConversationData.attempts + 1; const attempts = perConversationData.attempts + 1;
const retryWithBackoff = this.getRetryWithBackoff(attempts); const retryWithBackoff = this.#getRetryWithBackoff(attempts);
if (existingRetryAt && existingRetryAt >= retryWithBackoff) { if (existingRetryAt && existingRetryAt >= retryWithBackoff) {
globalLogger.warn( globalLogger.warn(
@ -589,7 +592,7 @@ export class ConversationJobQueue extends JobQueue<ConversationQueueJobData> {
globalLogger.info( globalLogger.info(
`${logId}: Updating to new retryAt ${retryWithBackoff} (attempts ${attempts}) from existing retryAt ${existingRetryAt}, status ${status}` `${logId}: Updating to new retryAt ${retryWithBackoff} (attempts ${attempts}) from existing retryAt ${existingRetryAt}, status ${status}`
); );
this.perConversationData.set(conversationId, { this.#perConversationData.set(conversationId, {
...perConversationData, ...perConversationData,
retryAt: retryWithBackoff, retryAt: retryWithBackoff,
}); });
@ -597,7 +600,7 @@ export class ConversationJobQueue extends JobQueue<ConversationQueueJobData> {
globalLogger.info( globalLogger.info(
`${logId}: Updating to new retryAt ${retryWithBackoff} (attempts ${attempts}) from previous UNBLOCKED status` `${logId}: Updating to new retryAt ${retryWithBackoff} (attempts ${attempts}) from previous UNBLOCKED status`
); );
this.perConversationData.set(conversationId, { this.#perConversationData.set(conversationId, {
...perConversationData, ...perConversationData,
status: RETRY_STATUS.BLOCKED_WITH_JOBS, status: RETRY_STATUS.BLOCKED_WITH_JOBS,
retryAt: retryWithBackoff, retryAt: retryWithBackoff,
@ -606,7 +609,7 @@ export class ConversationJobQueue extends JobQueue<ConversationQueueJobData> {
globalLogger.info( globalLogger.info(
`${logId}: Updating to new retryAt ${retryWithBackoff} (attempts ${attempts}) from previous RUNNING status` `${logId}: Updating to new retryAt ${retryWithBackoff} (attempts ${attempts}) from previous RUNNING status`
); );
this.perConversationData.set(conversationId, { this.#perConversationData.set(conversationId, {
status: RETRY_STATUS.BLOCKED, status: RETRY_STATUS.BLOCKED,
attempts, attempts,
retryAt: retryWithBackoff, retryAt: retryWithBackoff,
@ -629,7 +632,7 @@ export class ConversationJobQueue extends JobQueue<ConversationQueueJobData> {
}): Promise<boolean> { }): Promise<boolean> {
const { conversationId } = job.data; const { conversationId } = job.data;
const logId = `retryJobOnQueueIdle/${conversationId}/${job.id}`; const logId = `retryJobOnQueueIdle/${conversationId}/${job.id}`;
const perConversationData = this.perConversationData.get(conversationId); const perConversationData = this.#perConversationData.get(conversationId);
if (!perConversationData) { if (!perConversationData) {
logger.warn(`${logId}: no data for conversation; using default retryAt`); logger.warn(`${logId}: no data for conversation; using default retryAt`);
@ -653,13 +656,13 @@ export class ConversationJobQueue extends JobQueue<ConversationQueueJobData> {
); );
const newCallback = const newCallback =
callback || this.createRetryCallback(conversationId, job.id); callback || this.#createRetryCallback(conversationId, job.id);
if ( if (
status === RETRY_STATUS.BLOCKED || status === RETRY_STATUS.BLOCKED ||
status === RETRY_STATUS.BLOCKED_WITH_JOBS status === RETRY_STATUS.BLOCKED_WITH_JOBS
) { ) {
this.perConversationData.set(conversationId, { this.#perConversationData.set(conversationId, {
status: RETRY_STATUS.BLOCKED_WITH_JOBS, status: RETRY_STATUS.BLOCKED_WITH_JOBS,
attempts, attempts,
retryAt, retryAt,
@ -668,11 +671,11 @@ export class ConversationJobQueue extends JobQueue<ConversationQueueJobData> {
}); });
} else if (status === RETRY_STATUS.RUNNING) { } else if (status === RETRY_STATUS.RUNNING) {
const newAttempts = attempts + 1; const newAttempts = attempts + 1;
const newRetryAt = this.getRetryWithBackoff(newAttempts); const newRetryAt = this.#getRetryWithBackoff(newAttempts);
logger.warn( logger.warn(
`${logId}: Moving from state RUNNING to BLOCKED_WITH_JOBS, with retryAt ${newRetryAt}, (attempts ${newAttempts})` `${logId}: Moving from state RUNNING to BLOCKED_WITH_JOBS, with retryAt ${newRetryAt}, (attempts ${newAttempts})`
); );
this.perConversationData.set(conversationId, { this.#perConversationData.set(conversationId, {
status: RETRY_STATUS.BLOCKED_WITH_JOBS, status: RETRY_STATUS.BLOCKED_WITH_JOBS,
attempts: newAttempts, attempts: newAttempts,
retryAt: newRetryAt, retryAt: newRetryAt,
@ -680,7 +683,7 @@ export class ConversationJobQueue extends JobQueue<ConversationQueueJobData> {
callback: newCallback, callback: newCallback,
}); });
} else { } else {
this.perConversationData.set(conversationId, { this.#perConversationData.set(conversationId, {
status: RETRY_STATUS.UNBLOCKED, status: RETRY_STATUS.UNBLOCKED,
attempts, attempts,
retryAt, retryAt,
@ -703,9 +706,9 @@ export class ConversationJobQueue extends JobQueue<ConversationQueueJobData> {
return true; return true;
} }
private createRetryCallback(conversationId: string, jobId: string) { #createRetryCallback(conversationId: string, jobId: string) {
this.callbackCount += 1; this.#callbackCount += 1;
const id = this.callbackCount; const id = this.#callbackCount;
globalLogger.info( globalLogger.info(
`createRetryCallback/${conversationId}/${id}: callback created for job ${jobId}` `createRetryCallback/${conversationId}/${id}: callback created for job ${jobId}`
@ -714,7 +717,7 @@ export class ConversationJobQueue extends JobQueue<ConversationQueueJobData> {
return () => { return () => {
const logId = `retryCallback/${conversationId}/${id}`; const logId = `retryCallback/${conversationId}/${id}`;
const perConversationData = this.perConversationData.get(conversationId); const perConversationData = this.#perConversationData.get(conversationId);
if (!perConversationData) { if (!perConversationData) {
globalLogger.warn(`${logId}: no perConversationData, returning early.`); globalLogger.warn(`${logId}: no perConversationData, returning early.`);
return; return;
@ -741,7 +744,7 @@ export class ConversationJobQueue extends JobQueue<ConversationQueueJobData> {
// We're starting to retry jobs; remove the challenge handler // We're starting to retry jobs; remove the challenge handler
drop(window.Signal.challengeHandler?.unregister(conversationId, logId)); drop(window.Signal.challengeHandler?.unregister(conversationId, logId));
this.perConversationData.set(conversationId, { this.#perConversationData.set(conversationId, {
status: RETRY_STATUS.RUNNING, status: RETRY_STATUS.RUNNING,
attempts, attempts,
callback: undefined, callback: undefined,
@ -761,7 +764,7 @@ export class ConversationJobQueue extends JobQueue<ConversationQueueJobData> {
`${logId}: retryAt ${retryAt} is in the future, scheduling timeout for ${timeLeft}ms` `${logId}: retryAt ${retryAt} is in the future, scheduling timeout for ${timeLeft}ms`
); );
this.perConversationData.set(conversationId, { this.#perConversationData.set(conversationId, {
...perConversationData, ...perConversationData,
retryAtTimeout: setTimeout(() => { retryAtTimeout: setTimeout(() => {
globalLogger.info(`${logId}: Running callback due to timeout`); globalLogger.info(`${logId}: Running callback due to timeout`);
@ -780,7 +783,7 @@ export class ConversationJobQueue extends JobQueue<ConversationQueueJobData> {
): Promise<typeof JOB_STATUS.NEEDS_RETRY | undefined> { ): Promise<typeof JOB_STATUS.NEEDS_RETRY | undefined> {
const { type, conversationId } = data; const { type, conversationId } = data;
const isFinalAttempt = attempt >= MAX_ATTEMPTS; const isFinalAttempt = attempt >= MAX_ATTEMPTS;
const perConversationData = this.perConversationData.get(conversationId); const perConversationData = this.#perConversationData.get(conversationId);
await window.ConversationController.load(); await window.ConversationController.load();
@ -818,7 +821,7 @@ export class ConversationJobQueue extends JobQueue<ConversationQueueJobData> {
const isChallengeRegistered = const isChallengeRegistered =
window.Signal.challengeHandler?.isRegistered(conversationId); window.Signal.challengeHandler?.isRegistered(conversationId);
if (!isChallengeRegistered) { if (!isChallengeRegistered) {
this.unblockConversationRetries(conversationId); this.#unblockConversationRetries(conversationId);
} }
if (isChallengeRegistered && shouldSendShowCaptcha(type)) { if (isChallengeRegistered && shouldSendShowCaptcha(type)) {
@ -838,7 +841,7 @@ export class ConversationJobQueue extends JobQueue<ConversationQueueJobData> {
); );
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
await Promise.race([ await Promise.race([
this.startVerificationWaiter(conversation.id), this.#startVerificationWaiter(conversation.id),
// don't resolve on shutdown, otherwise we end up in an infinite loop // don't resolve on shutdown, otherwise we end up in an infinite loop
sleeper.sleep( sleeper.sleep(
5 * MINUTE, 5 * MINUTE,
@ -877,7 +880,7 @@ export class ConversationJobQueue extends JobQueue<ConversationQueueJobData> {
); );
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
await Promise.race([ await Promise.race([
this.startVerificationWaiter(conversation.id), this.#startVerificationWaiter(conversation.id),
// don't resolve on shutdown, otherwise we end up in an infinite loop // don't resolve on shutdown, otherwise we end up in an infinite loop
sleeper.sleep( sleeper.sleep(
5 * MINUTE, 5 * MINUTE,
@ -986,7 +989,7 @@ export class ConversationJobQueue extends JobQueue<ConversationQueueJobData> {
} }
if (shouldContinue && !this.isShuttingDown) { if (shouldContinue && !this.isShuttingDown) {
this.recordSuccessfulSend(conversationId); this.#recordSuccessfulSend(conversationId);
} }
return undefined; return undefined;
@ -1030,7 +1033,7 @@ export class ConversationJobQueue extends JobQueue<ConversationQueueJobData> {
); );
if (silent) { if (silent) {
this.captureRetryAt(conversationId, toProcess.retryAt); this.#captureRetryAt(conversationId, toProcess.retryAt);
return JOB_STATUS.NEEDS_RETRY; return JOB_STATUS.NEEDS_RETRY;
} }
} }

View file

@ -4,24 +4,24 @@
import PQueue from 'p-queue'; import PQueue from 'p-queue';
export class InMemoryQueues { export class InMemoryQueues {
private readonly queues = new Map<string, PQueue>(); readonly #queues = new Map<string, PQueue>();
get(key: string): PQueue { get(key: string): PQueue {
const existingQueue = this.queues.get(key); const existingQueue = this.#queues.get(key);
if (existingQueue) { if (existingQueue) {
return existingQueue; return existingQueue;
} }
const newQueue = new PQueue({ concurrency: 1 }); const newQueue = new PQueue({ concurrency: 1 });
newQueue.once('idle', () => { newQueue.once('idle', () => {
this.queues.delete(key); this.#queues.delete(key);
}); });
this.queues.set(key, newQueue); this.#queues.set(key, newQueue);
return newQueue; return newQueue;
} }
get allQueues(): ReadonlySet<PQueue> { get allQueues(): ReadonlySet<PQueue> {
return new Set(this.queues.values()); return new Set(this.#queues.values());
} }
} }

View file

@ -38,10 +38,10 @@ const reportSpamJobDataSchema = z.object({
export type ReportSpamJobData = z.infer<typeof reportSpamJobDataSchema>; export type ReportSpamJobData = z.infer<typeof reportSpamJobDataSchema>;
export class ReportSpamJobQueue extends JobQueue<ReportSpamJobData> { export class ReportSpamJobQueue extends JobQueue<ReportSpamJobData> {
private server?: WebAPIType; #server?: WebAPIType;
public initialize({ server }: { server: WebAPIType }): void { public initialize({ server }: { server: WebAPIType }): void {
this.server = server; this.#server = server;
} }
protected parseData(data: unknown): ReportSpamJobData { protected parseData(data: unknown): ReportSpamJobData {
@ -65,7 +65,7 @@ export class ReportSpamJobQueue extends JobQueue<ReportSpamJobData> {
await waitForOnline(); await waitForOnline();
const { server } = this; const server = this.#server;
strictAssert(server !== undefined, 'ReportSpamJobQueue not initialized'); strictAssert(server !== undefined, 'ReportSpamJobQueue not initialized');
try { try {

View file

@ -31,16 +31,16 @@ const MAX_PARALLEL_JOBS = 5;
const MAX_ATTEMPTS = exponentialBackoffMaxAttempts(MAX_RETRY_TIME); const MAX_ATTEMPTS = exponentialBackoffMaxAttempts(MAX_RETRY_TIME);
export class SingleProtoJobQueue extends JobQueue<SingleProtoJobData> { export class SingleProtoJobQueue extends JobQueue<SingleProtoJobData> {
private parallelQueue = new PQueue({ concurrency: MAX_PARALLEL_JOBS }); #parallelQueue = new PQueue({ concurrency: MAX_PARALLEL_JOBS });
protected override getQueues(): ReadonlySet<PQueue> { protected override getQueues(): ReadonlySet<PQueue> {
return new Set([this.parallelQueue]); return new Set([this.#parallelQueue]);
} }
protected override getInMemoryQueue( protected override getInMemoryQueue(
_parsedJob: ParsedJob<SingleProtoJobData> _parsedJob: ParsedJob<SingleProtoJobData>
): PQueue { ): PQueue {
return this.parallelQueue; return this.#parallelQueue;
} }
protected parseData(data: unknown): SingleProtoJobData { protected parseData(data: unknown): SingleProtoJobData {

View file

@ -13,11 +13,11 @@ function getState(): NativeThemeState {
} }
export class NativeThemeNotifier { export class NativeThemeNotifier {
private readonly listeners = new Set<BrowserWindow>(); readonly #listeners = new Set<BrowserWindow>();
public initialize(): void { public initialize(): void {
nativeTheme.on('updated', () => { nativeTheme.on('updated', () => {
this.notifyListeners(); this.#notifyListeners();
}); });
ipc.on('native-theme:init', event => { ipc.on('native-theme:init', event => {
@ -27,19 +27,19 @@ export class NativeThemeNotifier {
} }
public addWindow(window: BrowserWindow): void { public addWindow(window: BrowserWindow): void {
if (this.listeners.has(window)) { if (this.#listeners.has(window)) {
return; return;
} }
this.listeners.add(window); this.#listeners.add(window);
window.once('closed', () => { window.once('closed', () => {
this.listeners.delete(window); this.#listeners.delete(window);
}); });
} }
private notifyListeners(): void { #notifyListeners(): void {
for (const window of this.listeners) { for (const window of this.#listeners) {
window.webContents.send('native-theme:changed', getState()); window.webContents.send('native-theme:changed', getState());
} }
} }

View file

@ -8,17 +8,17 @@ import * as log from '../logging/log';
import type { IPCRequest, IPCResponse, ChallengeResponse } from '../challenge'; import type { IPCRequest, IPCResponse, ChallengeResponse } from '../challenge';
export class ChallengeMainHandler { export class ChallengeMainHandler {
private handlers: Array<(response: ChallengeResponse) => void> = []; #handlers: Array<(response: ChallengeResponse) => void> = [];
constructor() { constructor() {
this.initialize(); this.#initialize();
} }
public handleCaptcha(captcha: string): void { public handleCaptcha(captcha: string): void {
const response: ChallengeResponse = { captcha }; const response: ChallengeResponse = { captcha };
const { handlers } = this; const handlers = this.#handlers;
this.handlers = []; this.#handlers = [];
log.info( log.info(
'challengeMain.handleCaptcha: sending captcha response to ' + 'challengeMain.handleCaptcha: sending captcha response to ' +
@ -29,17 +29,14 @@ export class ChallengeMainHandler {
} }
} }
private async onRequest( async #onRequest(event: IpcMainEvent, request: IPCRequest): Promise<void> {
event: IpcMainEvent,
request: IPCRequest
): Promise<void> {
const logId = `challengeMain.onRequest(${request.reason})`; const logId = `challengeMain.onRequest(${request.reason})`;
log.info(`${logId}: received challenge request, waiting for response`); log.info(`${logId}: received challenge request, waiting for response`);
const start = Date.now(); const start = Date.now();
const data = await new Promise<ChallengeResponse>(resolve => { const data = await new Promise<ChallengeResponse>(resolve => {
this.handlers.push(resolve); this.#handlers.push(resolve);
}); });
const duration = Date.now() - start; const duration = Date.now() - start;
@ -52,9 +49,9 @@ export class ChallengeMainHandler {
event.sender.send('challenge:response', ipcResponse); event.sender.send('challenge:response', ipcResponse);
} }
private initialize(): void { #initialize(): void {
ipc.on('challenge:request', (event, request) => { ipc.on('challenge:request', (event, request) => {
void this.onRequest(event, request); void this.#onRequest(event, request);
}); });
} }
} }

View file

@ -32,85 +32,83 @@ type SettingChangeEventType<Key extends keyof SettingsValuesType> =
`change:${Key}`; `change:${Key}`;
export class SettingsChannel extends EventEmitter { export class SettingsChannel extends EventEmitter {
private mainWindow?: BrowserWindow; #mainWindow?: BrowserWindow;
readonly #responseQueue = new Map<number, ResponseQueueEntry>();
private readonly responseQueue = new Map<number, ResponseQueueEntry>(); #responseSeq = 0;
private responseSeq = 0;
public setMainWindow(mainWindow: BrowserWindow | undefined): void { public setMainWindow(mainWindow: BrowserWindow | undefined): void {
this.mainWindow = mainWindow; this.#mainWindow = mainWindow;
} }
public getMainWindow(): BrowserWindow | undefined { public getMainWindow(): BrowserWindow | undefined {
return this.mainWindow; return this.#mainWindow;
} }
public install(): void { public install(): void {
this.installSetting('deviceName', { setter: false }); this.#installSetting('deviceName', { setter: false });
this.installSetting('phoneNumber', { setter: false }); this.#installSetting('phoneNumber', { setter: false });
// ChatColorPicker redux hookups // ChatColorPicker redux hookups
this.installCallback('getCustomColors'); this.#installCallback('getCustomColors');
this.installCallback('getConversationsWithCustomColor'); this.#installCallback('getConversationsWithCustomColor');
this.installCallback('resetAllChatColors'); this.#installCallback('resetAllChatColors');
this.installCallback('resetDefaultChatColor'); this.#installCallback('resetDefaultChatColor');
this.installCallback('addCustomColor'); this.#installCallback('addCustomColor');
this.installCallback('editCustomColor'); this.#installCallback('editCustomColor');
this.installCallback('removeCustomColor'); this.#installCallback('removeCustomColor');
this.installCallback('removeCustomColorOnConversations'); this.#installCallback('removeCustomColorOnConversations');
this.installCallback('setGlobalDefaultConversationColor'); this.#installCallback('setGlobalDefaultConversationColor');
this.installCallback('getDefaultConversationColor'); this.#installCallback('getDefaultConversationColor');
// Various callbacks // Various callbacks
this.installCallback('deleteAllMyStories'); this.#installCallback('deleteAllMyStories');
this.installCallback('getAvailableIODevices'); this.#installCallback('getAvailableIODevices');
this.installCallback('isPrimary'); this.#installCallback('isPrimary');
this.installCallback('syncRequest'); this.#installCallback('syncRequest');
// Getters only. These are set by the primary device // Getters only. These are set by the primary device
this.installSetting('blockedCount', { setter: false }); this.#installSetting('blockedCount', { setter: false });
this.installSetting('linkPreviewSetting', { setter: false }); this.#installSetting('linkPreviewSetting', { setter: false });
this.installSetting('readReceiptSetting', { setter: false }); this.#installSetting('readReceiptSetting', { setter: false });
this.installSetting('typingIndicatorSetting', { setter: false }); this.#installSetting('typingIndicatorSetting', { setter: false });
this.installSetting('hideMenuBar'); this.#installSetting('hideMenuBar');
this.installSetting('notificationSetting'); this.#installSetting('notificationSetting');
this.installSetting('notificationDrawAttention'); this.#installSetting('notificationDrawAttention');
this.installSetting('audioMessage'); this.#installSetting('audioMessage');
this.installSetting('audioNotification'); this.#installSetting('audioNotification');
this.installSetting('countMutedConversations'); this.#installSetting('countMutedConversations');
this.installSetting('sentMediaQualitySetting'); this.#installSetting('sentMediaQualitySetting');
this.installSetting('textFormatting'); this.#installSetting('textFormatting');
this.installSetting('autoConvertEmoji'); this.#installSetting('autoConvertEmoji');
this.installSetting('autoDownloadUpdate'); this.#installSetting('autoDownloadUpdate');
this.installSetting('autoLaunch'); this.#installSetting('autoLaunch');
this.installSetting('alwaysRelayCalls'); this.#installSetting('alwaysRelayCalls');
this.installSetting('callRingtoneNotification'); this.#installSetting('callRingtoneNotification');
this.installSetting('callSystemNotification'); this.#installSetting('callSystemNotification');
this.installSetting('incomingCallNotification'); this.#installSetting('incomingCallNotification');
// Media settings // Media settings
this.installSetting('preferredAudioInputDevice'); this.#installSetting('preferredAudioInputDevice');
this.installSetting('preferredAudioOutputDevice'); this.#installSetting('preferredAudioOutputDevice');
this.installSetting('preferredVideoInputDevice'); this.#installSetting('preferredVideoInputDevice');
this.installSetting('lastSyncTime'); this.#installSetting('lastSyncTime');
this.installSetting('universalExpireTimer'); this.#installSetting('universalExpireTimer');
this.installSetting('hasStoriesDisabled'); this.#installSetting('hasStoriesDisabled');
this.installSetting('zoomFactor'); this.#installSetting('zoomFactor');
this.installSetting('phoneNumberDiscoverabilitySetting'); this.#installSetting('phoneNumberDiscoverabilitySetting');
this.installSetting('phoneNumberSharingSetting'); this.#installSetting('phoneNumberSharingSetting');
this.installEphemeralSetting('themeSetting'); this.#installEphemeralSetting('themeSetting');
this.installEphemeralSetting('systemTraySetting'); this.#installEphemeralSetting('systemTraySetting');
this.installEphemeralSetting('localeOverride'); this.#installEphemeralSetting('localeOverride');
this.installEphemeralSetting('spellCheck'); this.#installEphemeralSetting('spellCheck');
installPermissionsHandler({ session: session.defaultSession, userConfig }); installPermissionsHandler({ session: session.defaultSession, userConfig });
@ -142,8 +140,8 @@ export class SettingsChannel extends EventEmitter {
}); });
ipc.on('settings:response', (_event, seq, error, value) => { ipc.on('settings:response', (_event, seq, error, value) => {
const entry = this.responseQueue.get(seq); const entry = this.#responseQueue.get(seq);
this.responseQueue.delete(seq); this.#responseQueue.delete(seq);
if (!entry) { if (!entry) {
return; return;
} }
@ -157,15 +155,15 @@ export class SettingsChannel extends EventEmitter {
}); });
} }
private waitForResponse<Value>(): { promise: Promise<Value>; seq: number } { #waitForResponse<Value>(): { promise: Promise<Value>; seq: number } {
const seq = this.responseSeq; const seq = this.#responseSeq;
// eslint-disable-next-line no-bitwise // eslint-disable-next-line no-bitwise
this.responseSeq = (this.responseSeq + 1) & 0x7fffffff; this.#responseSeq = (this.#responseSeq + 1) & 0x7fffffff;
const { promise, resolve, reject } = explodePromise<Value>(); const { promise, resolve, reject } = explodePromise<Value>();
this.responseQueue.set(seq, { resolve, reject }); this.#responseQueue.set(seq, { resolve, reject });
return { seq, promise }; return { seq, promise };
} }
@ -173,12 +171,12 @@ export class SettingsChannel extends EventEmitter {
public getSettingFromMainWindow<Name extends keyof IPCEventsValuesType>( public getSettingFromMainWindow<Name extends keyof IPCEventsValuesType>(
name: Name name: Name
): Promise<IPCEventsValuesType[Name]> { ): Promise<IPCEventsValuesType[Name]> {
const { mainWindow } = this; const mainWindow = this.#mainWindow;
if (!mainWindow || !mainWindow.webContents) { if (!mainWindow || !mainWindow.webContents) {
throw new Error('No main window'); throw new Error('No main window');
} }
const { seq, promise } = this.waitForResponse<IPCEventsValuesType[Name]>(); const { seq, promise } = this.#waitForResponse<IPCEventsValuesType[Name]>();
mainWindow.webContents.send(`settings:get:${name}`, { seq }); mainWindow.webContents.send(`settings:get:${name}`, { seq });
@ -189,12 +187,12 @@ export class SettingsChannel extends EventEmitter {
name: Name, name: Name,
value: IPCEventsValuesType[Name] value: IPCEventsValuesType[Name]
): Promise<void> { ): Promise<void> {
const { mainWindow } = this; const mainWindow = this.#mainWindow;
if (!mainWindow || !mainWindow.webContents) { if (!mainWindow || !mainWindow.webContents) {
throw new Error('No main window'); throw new Error('No main window');
} }
const { seq, promise } = this.waitForResponse<void>(); const { seq, promise } = this.#waitForResponse<void>();
mainWindow.webContents.send(`settings:set:${name}`, { seq, value }); mainWindow.webContents.send(`settings:set:${name}`, { seq, value });
@ -205,19 +203,19 @@ export class SettingsChannel extends EventEmitter {
name: Name, name: Name,
args: ReadonlyArray<unknown> args: ReadonlyArray<unknown>
): Promise<unknown> { ): Promise<unknown> {
const { mainWindow } = this; const mainWindow = this.#mainWindow;
if (!mainWindow || !mainWindow.webContents) { if (!mainWindow || !mainWindow.webContents) {
throw new Error('Main window not found'); throw new Error('Main window not found');
} }
const { seq, promise } = this.waitForResponse<unknown>(); const { seq, promise } = this.#waitForResponse<unknown>();
mainWindow.webContents.send(`settings:call:${name}`, { seq, args }); mainWindow.webContents.send(`settings:call:${name}`, { seq, args });
return promise; return promise;
} }
private installCallback<Name extends keyof IPCEventsCallbacksType>( #installCallback<Name extends keyof IPCEventsCallbacksType>(
name: Name name: Name
): void { ): void {
ipc.handle(`settings:call:${name}`, async (_event, args) => { ipc.handle(`settings:call:${name}`, async (_event, args) => {
@ -225,7 +223,7 @@ export class SettingsChannel extends EventEmitter {
}); });
} }
private installSetting<Name extends keyof IPCEventsValuesType>( #installSetting<Name extends keyof IPCEventsValuesType>(
name: Name, name: Name,
{ {
getter = true, getter = true,
@ -249,7 +247,7 @@ export class SettingsChannel extends EventEmitter {
}); });
} }
private installEphemeralSetting<Name extends keyof EphemeralSettings>( #installEphemeralSetting<Name extends keyof EphemeralSettings>(
name: Name name: Name
): void { ): void {
ipc.handle(`settings:get:${name}`, async () => { ipc.handle(`settings:get:${name}`, async () => {
@ -276,7 +274,7 @@ export class SettingsChannel extends EventEmitter {
// to main the event 'preferences-changed'. // to main the event 'preferences-changed'.
this.emit('ephemeral-setting-changed'); this.emit('ephemeral-setting-changed');
const { mainWindow } = this; const mainWindow = this.#mainWindow;
if (!mainWindow || !mainWindow.webContents) { if (!mainWindow || !mainWindow.webContents) {
return; return;
} }

View file

@ -15,11 +15,11 @@ export class MediaEditorFabricCropRect extends fabric.Rect {
...(options || {}), ...(options || {}),
}); });
this.on('scaling', this.containBounds); this.on('scaling', this.#containBounds);
this.on('moving', this.containBounds); this.on('moving', this.#containBounds);
} }
private containBounds = () => { #containBounds = () => {
if (!this.canvas) { if (!this.canvas) {
return; return;
} }

View file

@ -295,21 +295,16 @@ export class ConversationModel extends window.Backbone
throttledUpdateSharedGroups?: () => Promise<void>; throttledUpdateSharedGroups?: () => Promise<void>;
private cachedIdenticon?: CachedIdenticon; #cachedIdenticon?: CachedIdenticon;
public isFetchingUUID?: boolean; public isFetchingUUID?: boolean;
private lastIsTyping?: boolean; #lastIsTyping?: boolean;
#muteTimer?: NodeJS.Timeout;
private muteTimer?: NodeJS.Timeout; #isInReduxBatch = false;
#privVerifiedEnum?: typeof window.textsecure.storage.protocol.VerifiedStatus;
private isInReduxBatch = false; #isShuttingDown = false;
#savePromises = new Set<Promise<void>>();
private privVerifiedEnum?: typeof window.textsecure.storage.protocol.VerifiedStatus;
private isShuttingDown = false;
private savePromises = new Set<Promise<void>>();
override defaults(): Partial<ConversationAttributesType> { override defaults(): Partial<ConversationAttributesType> {
return { return {
@ -364,7 +359,7 @@ export class ConversationModel extends window.Backbone
this.storeName = 'conversations'; this.storeName = 'conversations';
this.privVerifiedEnum = window.textsecure.storage.protocol.VerifiedStatus; this.#privVerifiedEnum = window.textsecure.storage.protocol.VerifiedStatus;
// This may be overridden by window.ConversationController.getOrCreate, and signify // This may be overridden by window.ConversationController.getOrCreate, and signify
// our first save to the database. Or first fetch from the database. // our first save to the database. Or first fetch from the database.
@ -409,7 +404,7 @@ export class ConversationModel extends window.Backbone
this.unset('tokens'); this.unset('tokens');
this.on('change:members change:membersV2', this.fetchContacts); this.on('change:members change:membersV2', this.fetchContacts);
this.on('change:active_at', this.onActiveAtChange); this.on('change:active_at', this.#onActiveAtChange);
this.typingRefreshTimer = null; this.typingRefreshTimer = null;
this.typingPauseTimer = null; this.typingPauseTimer = null;
@ -436,7 +431,7 @@ export class ConversationModel extends window.Backbone
this.oldCachedProps = this.cachedProps; this.oldCachedProps = this.cachedProps;
} }
this.cachedProps = null; this.cachedProps = null;
this.trigger('props-change', this, this.isInReduxBatch); this.trigger('props-change', this, this.#isInReduxBatch);
} }
); );
@ -477,13 +472,13 @@ export class ConversationModel extends window.Backbone
} }
addSavePromise(promise: Promise<void>): void { addSavePromise(promise: Promise<void>): void {
this.savePromises.add(promise); this.#savePromises.add(promise);
} }
removeSavePromise(promise: Promise<void>): void { removeSavePromise(promise: Promise<void>): void {
this.savePromises.delete(promise); this.#savePromises.delete(promise);
} }
getSavePromises(): Array<Promise<void>> { getSavePromises(): Array<Promise<void>> {
return Array.from(this.savePromises); return Array.from(this.#savePromises);
} }
toSenderKeyTarget(): SenderKeyTargetType { toSenderKeyTarget(): SenderKeyTargetType {
@ -503,12 +498,12 @@ export class ConversationModel extends window.Backbone
}; };
} }
private get verifiedEnum(): typeof window.textsecure.storage.protocol.VerifiedStatus { get #verifiedEnum(): typeof window.textsecure.storage.protocol.VerifiedStatus {
strictAssert(this.privVerifiedEnum, 'ConversationModel not initialize'); strictAssert(this.#privVerifiedEnum, 'ConversationModel not initialize');
return this.privVerifiedEnum; return this.#privVerifiedEnum;
} }
private isMemberRequestingToJoin(serviceId: ServiceIdString): boolean { #isMemberRequestingToJoin(serviceId: ServiceIdString): boolean {
return isMemberRequestingToJoin(this.attributes, serviceId); return isMemberRequestingToJoin(this.attributes, serviceId);
} }
@ -544,7 +539,7 @@ export class ConversationModel extends window.Backbone
}); });
} }
private async promotePendingMember( async #promotePendingMember(
serviceIdKind: ServiceIdKind serviceIdKind: ServiceIdKind
): Promise<Proto.GroupChange.Actions | undefined> { ): Promise<Proto.GroupChange.Actions | undefined> {
const idLog = this.idForLogging(); const idLog = this.idForLogging();
@ -594,7 +589,7 @@ export class ConversationModel extends window.Backbone
}); });
} }
private async denyPendingApprovalRequest( async #denyPendingApprovalRequest(
aci: AciString aci: AciString
): Promise<Proto.GroupChange.Actions | undefined> { ): Promise<Proto.GroupChange.Actions | undefined> {
const idLog = this.idForLogging(); const idLog = this.idForLogging();
@ -602,7 +597,7 @@ export class ConversationModel extends window.Backbone
// This user's pending state may have changed in the time between the user's // This user's pending state may have changed in the time between the user's
// button press and when we get here. It's especially important to check here // button press and when we get here. It's especially important to check here
// in conflict/retry cases. // in conflict/retry cases.
if (!this.isMemberRequestingToJoin(aci)) { if (!this.#isMemberRequestingToJoin(aci)) {
log.warn( log.warn(
`denyPendingApprovalRequest/${idLog}: ${aci} is not requesting ` + `denyPendingApprovalRequest/${idLog}: ${aci} is not requesting ` +
'to join the group. Returning early.' 'to join the group. Returning early.'
@ -718,13 +713,13 @@ export class ConversationModel extends window.Backbone
}); });
} }
private async removePendingMember( async #removePendingMember(
serviceIds: ReadonlyArray<ServiceIdString> serviceIds: ReadonlyArray<ServiceIdString>
): Promise<Proto.GroupChange.Actions | undefined> { ): Promise<Proto.GroupChange.Actions | undefined> {
return removePendingMember(this.attributes, serviceIds); return removePendingMember(this.attributes, serviceIds);
} }
private async removeMember( async #removeMember(
serviceId: ServiceIdString serviceId: ServiceIdString
): Promise<Proto.GroupChange.Actions | undefined> { ): Promise<Proto.GroupChange.Actions | undefined> {
const idLog = this.idForLogging(); const idLog = this.idForLogging();
@ -748,7 +743,7 @@ export class ConversationModel extends window.Backbone
}); });
} }
private async toggleAdminChange( async #toggleAdminChange(
serviceId: ServiceIdString serviceId: ServiceIdString
): Promise<Proto.GroupChange.Actions | undefined> { ): Promise<Proto.GroupChange.Actions | undefined> {
if (!isGroupV2(this.attributes)) { if (!isGroupV2(this.attributes)) {
@ -1355,7 +1350,7 @@ export class ConversationModel extends window.Backbone
// `sendTypingMessage`. The first 'sendTypingMessage' job to run will // `sendTypingMessage`. The first 'sendTypingMessage' job to run will
// pick it and reset it back to `undefined` so that later jobs will // pick it and reset it back to `undefined` so that later jobs will
// in effect be ignored. // in effect be ignored.
this.lastIsTyping = isTyping; this.#lastIsTyping = isTyping;
// If captchas are active, then we should drop typing messages because // If captchas are active, then we should drop typing messages because
// they're less important and could overwhelm the queue. // they're less important and could overwhelm the queue.
@ -1377,7 +1372,7 @@ export class ConversationModel extends window.Backbone
return; return;
} }
if (this.lastIsTyping === undefined) { if (this.#lastIsTyping === undefined) {
log.info(`sendTypingMessage(${this.idForLogging()}): ignoring`); log.info(`sendTypingMessage(${this.idForLogging()}): ignoring`);
return; return;
} }
@ -1392,10 +1387,10 @@ export class ConversationModel extends window.Backbone
recipientId, recipientId,
groupId, groupId,
groupMembers, groupMembers,
isTyping: this.lastIsTyping, isTyping: this.#lastIsTyping,
timestamp, timestamp,
}; };
this.lastIsTyping = undefined; this.#lastIsTyping = undefined;
log.info( log.info(
`sendTypingMessage(${this.idForLogging()}): sending ${content.isTyping}` `sendTypingMessage(${this.idForLogging()}): sending ${content.isTyping}`
@ -1491,14 +1486,12 @@ export class ConversationModel extends window.Backbone
message: MessageAttributesType, message: MessageAttributesType,
{ isJustSent }: { isJustSent: boolean } = { isJustSent: false } { isJustSent }: { isJustSent: boolean } = { isJustSent: false }
): Promise<void> { ): Promise<void> {
await this.beforeAddSingleMessage(message); await this.#beforeAddSingleMessage(message);
this.doAddSingleMessage(message, { isJustSent }); this.#doAddSingleMessage(message, { isJustSent });
this.debouncedUpdateLastMessage(); this.debouncedUpdateLastMessage();
} }
private async beforeAddSingleMessage( async #beforeAddSingleMessage(message: MessageAttributesType): Promise<void> {
message: MessageAttributesType
): Promise<void> {
await hydrateStoryContext(message.id, undefined, { shouldSave: true }); await hydrateStoryContext(message.id, undefined, { shouldSave: true });
if (!this.newMessageQueue) { if (!this.newMessageQueue) {
@ -1514,7 +1507,7 @@ export class ConversationModel extends window.Backbone
}); });
} }
private doAddSingleMessage( #doAddSingleMessage(
message: MessageAttributesType, message: MessageAttributesType,
{ isJustSent }: { isJustSent: boolean } { isJustSent }: { isJustSent: boolean }
): void { ): void {
@ -1551,7 +1544,7 @@ export class ConversationModel extends window.Backbone
} }
} }
private async setInProgressFetch(): Promise<() => void> { async #setInProgressFetch(): Promise<() => void> {
const logId = `setInProgressFetch(${this.idForLogging()})`; const logId = `setInProgressFetch(${this.idForLogging()})`;
while (this.inProgressFetch != null) { while (this.inProgressFetch != null) {
log.warn(`${logId}: blocked, waiting`); log.warn(`${logId}: blocked, waiting`);
@ -1598,7 +1591,7 @@ export class ConversationModel extends window.Backbone
return; return;
} }
const finish = await this.setInProgressFetch(); const finish = await this.#setInProgressFetch();
log.info(`${logId}: starting`); log.info(`${logId}: starting`);
try { try {
let metrics = await getMessageMetricsForConversation({ let metrics = await getMessageMetricsForConversation({
@ -1676,7 +1669,7 @@ export class ConversationModel extends window.Backbone
conversationId, conversationId,
TimelineMessageLoadingState.DoingInitialLoad TimelineMessageLoadingState.DoingInitialLoad
); );
let finish: undefined | (() => void) = await this.setInProgressFetch(); let finish: undefined | (() => void) = await this.#setInProgressFetch();
const preloadedId = getPreloadedConversationId( const preloadedId = getPreloadedConversationId(
window.reduxStore.getState() window.reduxStore.getState()
@ -1797,7 +1790,7 @@ export class ConversationModel extends window.Backbone
conversationId, conversationId,
TimelineMessageLoadingState.LoadingOlderMessages TimelineMessageLoadingState.LoadingOlderMessages
); );
const finish = await this.setInProgressFetch(); const finish = await this.#setInProgressFetch();
try { try {
const message = await getMessageById(oldestMessageId); const message = await getMessageById(oldestMessageId);
@ -1854,7 +1847,7 @@ export class ConversationModel extends window.Backbone
conversationId, conversationId,
TimelineMessageLoadingState.LoadingNewerMessages TimelineMessageLoadingState.LoadingNewerMessages
); );
const finish = await this.setInProgressFetch(); const finish = await this.#setInProgressFetch();
try { try {
const message = await getMessageById(newestMessageId); const message = await getMessageById(newestMessageId);
@ -1911,7 +1904,7 @@ export class ConversationModel extends window.Backbone
); );
let { onFinish: finish } = options; let { onFinish: finish } = options;
if (!finish) { if (!finish) {
finish = await this.setInProgressFetch(); finish = await this.#setInProgressFetch();
} }
try { try {
@ -2528,7 +2521,7 @@ export class ConversationModel extends window.Backbone
name: 'promotePendingMember', name: 'promotePendingMember',
usingCredentialsFrom: [ourConversation], usingCredentialsFrom: [ourConversation],
createGroupChange: () => createGroupChange: () =>
this.promotePendingMember(ServiceIdKind.ACI), this.#promotePendingMember(ServiceIdKind.ACI),
}); });
} else if ( } else if (
ourPni && ourPni &&
@ -2539,7 +2532,7 @@ export class ConversationModel extends window.Backbone
name: 'promotePendingMember', name: 'promotePendingMember',
usingCredentialsFrom: [ourConversation], usingCredentialsFrom: [ourConversation],
createGroupChange: () => createGroupChange: () =>
this.promotePendingMember(ServiceIdKind.PNI), this.#promotePendingMember(ServiceIdKind.PNI),
}); });
} else if (isGroupV2(this.attributes) && this.isMember(ourAci)) { } else if (isGroupV2(this.attributes) && this.isMember(ourAci)) {
log.info( log.info(
@ -2662,7 +2655,7 @@ export class ConversationModel extends window.Backbone
name: 'cancelJoinRequest', name: 'cancelJoinRequest',
usingCredentialsFrom: [], usingCredentialsFrom: [],
inviteLinkPassword, inviteLinkPassword,
createGroupChange: () => this.denyPendingApprovalRequest(ourAci), createGroupChange: () => this.#denyPendingApprovalRequest(ourAci),
}); });
} }
@ -2680,20 +2673,20 @@ export class ConversationModel extends window.Backbone
await this.modifyGroupV2({ await this.modifyGroupV2({
name: 'delete', name: 'delete',
usingCredentialsFrom: [], usingCredentialsFrom: [],
createGroupChange: () => this.removePendingMember([ourAci]), createGroupChange: () => this.#removePendingMember([ourAci]),
}); });
} else if (this.isMember(ourAci)) { } else if (this.isMember(ourAci)) {
await this.modifyGroupV2({ await this.modifyGroupV2({
name: 'delete', name: 'delete',
usingCredentialsFrom: [ourConversation], usingCredentialsFrom: [ourConversation],
createGroupChange: () => this.removeMember(ourAci), createGroupChange: () => this.#removeMember(ourAci),
}); });
// Keep PNI in pending if ACI was a member. // Keep PNI in pending if ACI was a member.
} else if (ourPni && this.isMemberPending(ourPni)) { } else if (ourPni && this.isMemberPending(ourPni)) {
await this.modifyGroupV2({ await this.modifyGroupV2({
name: 'delete', name: 'delete',
usingCredentialsFrom: [], usingCredentialsFrom: [],
createGroupChange: () => this.removePendingMember([ourPni]), createGroupChange: () => this.#removePendingMember([ourPni]),
syncMessageOnly: true, syncMessageOnly: true,
}); });
} else { } else {
@ -2765,7 +2758,7 @@ export class ConversationModel extends window.Backbone
await this.modifyGroupV2({ await this.modifyGroupV2({
name: 'toggleAdmin', name: 'toggleAdmin',
usingCredentialsFrom: [member], usingCredentialsFrom: [member],
createGroupChange: () => this.toggleAdminChange(serviceId), createGroupChange: () => this.#toggleAdminChange(serviceId),
}); });
} }
@ -2786,26 +2779,26 @@ export class ConversationModel extends window.Backbone
`removeFromGroupV2/${logId}` `removeFromGroupV2/${logId}`
); );
if (this.isMemberRequestingToJoin(serviceId)) { if (this.#isMemberRequestingToJoin(serviceId)) {
strictAssert(isAciString(serviceId), 'Requesting member is not ACI'); strictAssert(isAciString(serviceId), 'Requesting member is not ACI');
await this.modifyGroupV2({ await this.modifyGroupV2({
name: 'denyPendingApprovalRequest', name: 'denyPendingApprovalRequest',
usingCredentialsFrom: [], usingCredentialsFrom: [],
createGroupChange: () => this.denyPendingApprovalRequest(serviceId), createGroupChange: () => this.#denyPendingApprovalRequest(serviceId),
extraConversationsForSend: [conversationId], extraConversationsForSend: [conversationId],
}); });
} else if (this.isMemberPending(serviceId)) { } else if (this.isMemberPending(serviceId)) {
await this.modifyGroupV2({ await this.modifyGroupV2({
name: 'removePendingMember', name: 'removePendingMember',
usingCredentialsFrom: [], usingCredentialsFrom: [],
createGroupChange: () => this.removePendingMember([serviceId]), createGroupChange: () => this.#removePendingMember([serviceId]),
extraConversationsForSend: [conversationId], extraConversationsForSend: [conversationId],
}); });
} else if (this.isMember(serviceId)) { } else if (this.isMember(serviceId)) {
await this.modifyGroupV2({ await this.modifyGroupV2({
name: 'removeFromGroup', name: 'removeFromGroup',
usingCredentialsFrom: [pendingMember], usingCredentialsFrom: [pendingMember],
createGroupChange: () => this.removeMember(serviceId), createGroupChange: () => this.#removeMember(serviceId),
extraConversationsForSend: [conversationId], extraConversationsForSend: [conversationId],
}); });
} else { } else {
@ -2818,13 +2811,13 @@ export class ConversationModel extends window.Backbone
async safeGetVerified(): Promise<number> { async safeGetVerified(): Promise<number> {
const serviceId = this.getServiceId(); const serviceId = this.getServiceId();
if (!serviceId) { if (!serviceId) {
return this.verifiedEnum.DEFAULT; return this.#verifiedEnum.DEFAULT;
} }
try { try {
return await window.textsecure.storage.protocol.getVerified(serviceId); return await window.textsecure.storage.protocol.getVerified(serviceId);
} catch { } catch {
return this.verifiedEnum.DEFAULT; return this.#verifiedEnum.DEFAULT;
} }
} }
@ -2856,24 +2849,24 @@ export class ConversationModel extends window.Backbone
} }
setVerifiedDefault(): Promise<boolean> { setVerifiedDefault(): Promise<boolean> {
const { DEFAULT } = this.verifiedEnum; const { DEFAULT } = this.#verifiedEnum;
return this.queueJob('setVerifiedDefault', () => return this.queueJob('setVerifiedDefault', () =>
this._setVerified(DEFAULT) this.#_setVerified(DEFAULT)
); );
} }
setVerified(): Promise<boolean> { setVerified(): Promise<boolean> {
const { VERIFIED } = this.verifiedEnum; const { VERIFIED } = this.#verifiedEnum;
return this.queueJob('setVerified', () => this._setVerified(VERIFIED)); return this.queueJob('setVerified', () => this.#_setVerified(VERIFIED));
} }
setUnverified(): Promise<boolean> { setUnverified(): Promise<boolean> {
const { UNVERIFIED } = this.verifiedEnum; const { UNVERIFIED } = this.#verifiedEnum;
return this.queueJob('setUnverified', () => this._setVerified(UNVERIFIED)); return this.queueJob('setUnverified', () => this.#_setVerified(UNVERIFIED));
} }
private async _setVerified(verified: number): Promise<boolean> { async #_setVerified(verified: number): Promise<boolean> {
const { VERIFIED, DEFAULT } = this.verifiedEnum; const { VERIFIED, DEFAULT } = this.#verifiedEnum;
if (!isDirectConversation(this.attributes)) { if (!isDirectConversation(this.attributes)) {
throw new Error( throw new Error(
@ -2886,7 +2879,7 @@ export class ConversationModel extends window.Backbone
const beginningVerified = this.get('verified') ?? DEFAULT; const beginningVerified = this.get('verified') ?? DEFAULT;
const keyChange = false; const keyChange = false;
if (aci) { if (aci) {
if (verified === this.verifiedEnum.DEFAULT) { if (verified === this.#verifiedEnum.DEFAULT) {
await window.textsecure.storage.protocol.setVerified(aci, verified); await window.textsecure.storage.protocol.setVerified(aci, verified);
} else { } else {
await window.textsecure.storage.protocol.setVerified(aci, verified, { await window.textsecure.storage.protocol.setVerified(aci, verified, {
@ -2963,7 +2956,7 @@ export class ConversationModel extends window.Backbone
isVerified(): boolean { isVerified(): boolean {
if (isDirectConversation(this.attributes)) { if (isDirectConversation(this.attributes)) {
return this.get('verified') === this.verifiedEnum.VERIFIED; return this.get('verified') === this.#verifiedEnum.VERIFIED;
} }
const contacts = this.contactCollection; const contacts = this.contactCollection;
@ -2988,8 +2981,8 @@ export class ConversationModel extends window.Backbone
if (isDirectConversation(this.attributes)) { if (isDirectConversation(this.attributes)) {
const verified = this.get('verified'); const verified = this.get('verified');
return ( return (
verified !== this.verifiedEnum.VERIFIED && verified !== this.#verifiedEnum.VERIFIED &&
verified !== this.verifiedEnum.DEFAULT verified !== this.#verifiedEnum.DEFAULT
); );
} }
@ -3659,7 +3652,7 @@ export class ConversationModel extends window.Backbone
): Promise<T> { ): Promise<T> {
const logId = `conversation.queueJob(${this.idForLogging()}, ${name})`; const logId = `conversation.queueJob(${this.idForLogging()}, ${name})`;
if (this.isShuttingDown) { if (this.#isShuttingDown) {
log.warn(`${logId}: shutting down, can't accept more work`); log.warn(`${logId}: shutting down, can't accept more work`);
throw new Error(`${logId}: shutting down, can't accept more work`); throw new Error(`${logId}: shutting down, can't accept more work`);
} }
@ -3898,13 +3891,13 @@ export class ConversationModel extends window.Backbone
} }
batchReduxChanges(callback: () => void): void { batchReduxChanges(callback: () => void): void {
strictAssert(!this.isInReduxBatch, 'Nested redux batching is not allowed'); strictAssert(!this.#isInReduxBatch, 'Nested redux batching is not allowed');
this.isInReduxBatch = true; this.#isInReduxBatch = true;
batchDispatch(() => { batchDispatch(() => {
try { try {
callback(); callback();
} finally { } finally {
this.isInReduxBatch = false; this.#isInReduxBatch = false;
} }
}); });
} }
@ -3935,7 +3928,7 @@ export class ConversationModel extends window.Backbone
); );
if (!dontAddMessage) { if (!dontAddMessage) {
this.doAddSingleMessage(message, { isJustSent: true }); this.#doAddSingleMessage(message, { isJustSent: true });
} }
const notificationData = getNotificationDataForMessage(message); const notificationData = getNotificationDataForMessage(message);
const draftProperties = dontClearDraft const draftProperties = dontClearDraft
@ -4167,7 +4160,7 @@ export class ConversationModel extends window.Backbone
const renderStart = Date.now(); const renderStart = Date.now();
// Perform asynchronous tasks before entering the batching mode // Perform asynchronous tasks before entering the batching mode
await this.beforeAddSingleMessage(model.attributes); await this.#beforeAddSingleMessage(model.attributes);
if (sticker) { if (sticker) {
await addStickerPackReference(model.id, sticker.packId); await addStickerPackReference(model.id, sticker.packId);
@ -4425,7 +4418,7 @@ export class ConversationModel extends window.Backbone
} }
} }
private async onActiveAtChange(): Promise<void> { async #onActiveAtChange(): Promise<void> {
if (this.get('active_at') && this.get('messagesDeleted')) { if (this.get('active_at') && this.get('messagesDeleted')) {
this.set('messagesDeleted', false); this.set('messagesDeleted', false);
await DataWriter.updateConversation(this.attributes); await DataWriter.updateConversation(this.attributes);
@ -5382,8 +5375,8 @@ export class ConversationModel extends window.Backbone
} }
startMuteTimer({ viaStorageServiceSync = false } = {}): void { startMuteTimer({ viaStorageServiceSync = false } = {}): void {
clearTimeoutIfNecessary(this.muteTimer); clearTimeoutIfNecessary(this.#muteTimer);
this.muteTimer = undefined; this.#muteTimer = undefined;
const muteExpiresAt = this.get('muteExpiresAt'); const muteExpiresAt = this.get('muteExpiresAt');
if (isNumber(muteExpiresAt) && muteExpiresAt < Number.MAX_SAFE_INTEGER) { if (isNumber(muteExpiresAt) && muteExpiresAt < Number.MAX_SAFE_INTEGER) {
@ -5393,7 +5386,7 @@ export class ConversationModel extends window.Backbone
return; return;
} }
this.muteTimer = setTimeout(() => this.setMuteExpiration(0), delay); this.#muteTimer = setTimeout(() => this.setMuteExpiration(0), delay);
} }
} }
@ -5520,12 +5513,12 @@ export class ConversationModel extends window.Backbone
return { return {
url: avatarUrl, url: avatarUrl,
absolutePath: saveToDisk absolutePath: saveToDisk
? await this.getTemporaryAvatarPath() ? await this.#getTemporaryAvatarPath()
: undefined, : undefined,
}; };
} }
const { url, path } = await this.getIdenticon({ const { url, path } = await this.#getIdenticon({
saveToDisk, saveToDisk,
}); });
return { return {
@ -5534,7 +5527,7 @@ export class ConversationModel extends window.Backbone
}; };
} }
private async getTemporaryAvatarPath(): Promise<string | undefined> { async #getTemporaryAvatarPath(): Promise<string | undefined> {
const avatar = getAvatar(this.attributes); const avatar = getAvatar(this.attributes);
if (avatar?.path == null) { if (avatar?.path == null) {
return undefined; return undefined;
@ -5574,9 +5567,7 @@ export class ConversationModel extends window.Backbone
} }
} }
private async getIdenticon({ async #getIdenticon({ saveToDisk }: { saveToDisk?: boolean } = {}): Promise<{
saveToDisk,
}: { saveToDisk?: boolean } = {}): Promise<{
url: string; url: string;
path?: string; path?: string;
}> { }> {
@ -5587,7 +5578,7 @@ export class ConversationModel extends window.Backbone
if (isContact) { if (isContact) {
const text = (title && getInitials(title)) || '#'; const text = (title && getInitials(title)) || '#';
const cached = this.cachedIdenticon; const cached = this.#cachedIdenticon;
if (cached && cached.text === text && cached.color === color) { if (cached && cached.text === text && cached.color === color) {
return { ...cached }; return { ...cached };
} }
@ -5603,11 +5594,11 @@ export class ConversationModel extends window.Backbone
} }
); );
this.cachedIdenticon = { text, color, url, path }; this.#cachedIdenticon = { text, color, url, path };
return { url, path }; return { url, path };
} }
const cached = this.cachedIdenticon; const cached = this.#cachedIdenticon;
if (cached && cached.color === color) { if (cached && cached.color === color) {
return { ...cached }; return { ...cached };
} }
@ -5620,7 +5611,7 @@ export class ConversationModel extends window.Backbone
} }
); );
this.cachedIdenticon = { color, url, path }; this.#cachedIdenticon = { color, url, path };
return { url, path }; return { url, path };
} }
@ -5820,10 +5811,10 @@ export class ConversationModel extends window.Backbone
return undefined; return undefined;
} }
return this.getGroupStorySendMode(); return this.#getGroupStorySendMode();
} }
private getGroupStorySendMode(): StorySendMode { #getGroupStorySendMode(): StorySendMode {
strictAssert( strictAssert(
!isDirectConversation(this.attributes), !isDirectConversation(this.attributes),
'Must be a group to have send story mode' 'Must be a group to have send story mode'
@ -5846,11 +5837,11 @@ export class ConversationModel extends window.Backbone
log.warn( log.warn(
`conversation ${this.idForLogging()} jobQueue stop accepting new work` `conversation ${this.idForLogging()} jobQueue stop accepting new work`
); );
this.isShuttingDown = true; this.#isShuttingDown = true;
}, 10 * SECOND); }, 10 * SECOND);
await this.jobQueue.onIdle(); await this.jobQueue.onIdle();
this.isShuttingDown = true; this.#isShuttingDown = true;
clearTimeout(to); clearTimeout(to);
log.info(`conversation ${this.idForLogging()} jobQueue shutdown complete`); log.info(`conversation ${this.idForLogging()} jobQueue shutdown complete`);

View file

@ -9,7 +9,7 @@ type StringKey<T> = keyof T & string;
export class MessageModel { export class MessageModel {
public get id(): string { public get id(): string {
return this._attributes.id; return this.#_attributes.id;
} }
public get<keyName extends StringKey<MessageAttributesType>>( public get<keyName extends StringKey<MessageAttributesType>>(
@ -21,7 +21,7 @@ export class MessageModel {
attributes: Partial<MessageAttributesType>, attributes: Partial<MessageAttributesType>,
{ noTrigger }: { noTrigger?: boolean } = {} { noTrigger }: { noTrigger?: boolean } = {}
): void { ): void {
this._attributes = { this.#_attributes = {
...this.attributes, ...this.attributes,
...attributes, ...attributes,
}; };
@ -34,12 +34,12 @@ export class MessageModel {
} }
public get attributes(): Readonly<MessageAttributesType> { public get attributes(): Readonly<MessageAttributesType> {
return this._attributes; return this.#_attributes;
} }
private _attributes: MessageAttributesType; #_attributes: MessageAttributesType;
constructor(attributes: MessageAttributesType) { constructor(attributes: MessageAttributesType) {
this._attributes = attributes; this.#_attributes = attributes;
this.set( this.set(
window.Signal.Types.Message.initializeSchemaVersion({ window.Signal.Types.Message.initializeSchemaVersion({

View file

@ -68,47 +68,46 @@ const FUSE_OPTIONS = {
}; };
export class MemberRepository { export class MemberRepository {
private members: ReadonlyArray<MemberType>; #members: ReadonlyArray<MemberType>;
private isFuseReady = false; #isFuseReady = false;
#fuse = new Fuse<MemberType>([], FUSE_OPTIONS);
private fuse = new Fuse<MemberType>([], FUSE_OPTIONS);
constructor(conversations: ReadonlyArray<ConversationType> = []) { constructor(conversations: ReadonlyArray<ConversationType> = []) {
this.members = _toMembers(conversations); this.#members = _toMembers(conversations);
} }
updateMembers(conversations: ReadonlyArray<ConversationType>): void { updateMembers(conversations: ReadonlyArray<ConversationType>): void {
this.members = _toMembers(conversations); this.#members = _toMembers(conversations);
this.isFuseReady = false; this.#isFuseReady = false;
} }
getMembers(omitId?: string): ReadonlyArray<MemberType> { getMembers(omitId?: string): ReadonlyArray<MemberType> {
if (omitId) { if (omitId) {
return this.members.filter(({ id }) => id !== omitId); return this.#members.filter(({ id }) => id !== omitId);
} }
return this.members; return this.#members;
} }
getMemberById(id?: string): MemberType | undefined { getMemberById(id?: string): MemberType | undefined {
return id return id
? this.members.find(({ id: memberId }) => memberId === id) ? this.#members.find(({ id: memberId }) => memberId === id)
: undefined; : undefined;
} }
getMemberByAci(aci?: AciString): MemberType | undefined { getMemberByAci(aci?: AciString): MemberType | undefined {
return aci return aci
? this.members.find(({ aci: memberAci }) => memberAci === aci) ? this.#members.find(({ aci: memberAci }) => memberAci === aci)
: undefined; : undefined;
} }
search(pattern: string, omitId?: string): ReadonlyArray<MemberType> { search(pattern: string, omitId?: string): ReadonlyArray<MemberType> {
if (!this.isFuseReady) { if (!this.#isFuseReady) {
this.fuse.setCollection(this.members); this.#fuse.setCollection(this.#members);
this.isFuseReady = true; this.#isFuseReady = true;
} }
const results = this.fuse const results = this.#fuse
.search(removeDiacritics(pattern)) .search(removeDiacritics(pattern))
.map(result => result.item); .map(result => result.item);

View file

@ -26,8 +26,8 @@ const MIN_REFRESH_DELAY = MINUTE;
let idCounter = 1; let idCounter = 1;
export class RoutineProfileRefresher { export class RoutineProfileRefresher {
private started = false; #started = false;
private id: number; #id: number;
constructor( constructor(
private readonly options: { private readonly options: {
@ -39,20 +39,20 @@ export class RoutineProfileRefresher {
// We keep track of how many of these classes we create, because we suspect that // We keep track of how many of these classes we create, because we suspect that
// there might be too many... // there might be too many...
idCounter += 1; idCounter += 1;
this.id = idCounter; this.#id = idCounter;
log.info( log.info(
`Creating new RoutineProfileRefresher instance with id ${this.id}` `Creating new RoutineProfileRefresher instance with id ${this.#id}`
); );
} }
public async start(): Promise<void> { public async start(): Promise<void> {
const logId = `RoutineProfileRefresher.start/${this.id}`; const logId = `RoutineProfileRefresher.start/${this.#id}`;
if (this.started) { if (this.#started) {
log.warn(`${logId}: already started!`); log.warn(`${logId}: already started!`);
return; return;
} }
this.started = true; this.#started = true;
const { storage, getAllConversations, getOurConversationId } = this.options; const { storage, getAllConversations, getOurConversationId } = this.options;
@ -81,7 +81,7 @@ export class RoutineProfileRefresher {
allConversations: getAllConversations(), allConversations: getAllConversations(),
ourConversationId, ourConversationId,
storage, storage,
id: this.id, id: this.#id,
}); });
} catch (error) { } catch (error) {
log.error(`${logId}: failure`, Errors.toLogFormat(error)); log.error(`${logId}: failure`, Errors.toLogFormat(error));

View file

@ -19,95 +19,91 @@ const ACTIVE_EVENTS = [
class ActiveWindowService { class ActiveWindowService {
// This starting value might be wrong but we should get an update from the main process // This starting value might be wrong but we should get an update from the main process
// soon. We'd rather report that the window is inactive so we can show notifications. // soon. We'd rather report that the window is inactive so we can show notifications.
private isInitialized = false; #isInitialized = false;
private isFocused = false; #isFocused = false;
#activeCallbacks: Array<() => void> = [];
private activeCallbacks: Array<() => void> = []; #changeCallbacks: Array<(isActive: boolean) => void> = [];
#lastActiveEventAt = -Infinity;
private changeCallbacks: Array<(isActive: boolean) => void> = []; #callActiveCallbacks: () => void;
private lastActiveEventAt = -Infinity;
private callActiveCallbacks: () => void;
constructor() { constructor() {
this.callActiveCallbacks = throttle(() => { this.#callActiveCallbacks = throttle(() => {
this.activeCallbacks.forEach(callback => callback()); this.#activeCallbacks.forEach(callback => callback());
}, LISTENER_THROTTLE_TIME); }, LISTENER_THROTTLE_TIME);
} }
// These types aren't perfectly accurate, but they make this class easier to test. // These types aren't perfectly accurate, but they make this class easier to test.
initialize(document: EventTarget, ipc: NodeJS.EventEmitter): void { initialize(document: EventTarget, ipc: NodeJS.EventEmitter): void {
if (this.isInitialized) { if (this.#isInitialized) {
throw new Error( throw new Error(
'Active window service should not be initialized multiple times' 'Active window service should not be initialized multiple times'
); );
} }
this.isInitialized = true; this.#isInitialized = true;
this.lastActiveEventAt = Date.now(); this.#lastActiveEventAt = Date.now();
const onActiveEvent = this.onActiveEvent.bind(this); const onActiveEvent = this.#onActiveEvent.bind(this);
ACTIVE_EVENTS.forEach((eventName: string) => { ACTIVE_EVENTS.forEach((eventName: string) => {
document.addEventListener(eventName, onActiveEvent, true); document.addEventListener(eventName, onActiveEvent, true);
}); });
// We don't know for sure that we'll get the right data over IPC so we use `unknown`. // We don't know for sure that we'll get the right data over IPC so we use `unknown`.
ipc.on('set-window-focus', (_event: unknown, isFocused: unknown) => { ipc.on('set-window-focus', (_event: unknown, isFocused: unknown) => {
this.setWindowFocus(Boolean(isFocused)); this.#setWindowFocus(Boolean(isFocused));
}); });
} }
isActive(): boolean { isActive(): boolean {
return ( return (
this.isFocused && Date.now() < this.lastActiveEventAt + ACTIVE_TIMEOUT this.#isFocused && Date.now() < this.#lastActiveEventAt + ACTIVE_TIMEOUT
); );
} }
registerForActive(callback: () => void): void { registerForActive(callback: () => void): void {
this.activeCallbacks.push(callback); this.#activeCallbacks.push(callback);
} }
unregisterForActive(callback: () => void): void { unregisterForActive(callback: () => void): void {
this.activeCallbacks = this.activeCallbacks.filter( this.#activeCallbacks = this.#activeCallbacks.filter(
item => item !== callback item => item !== callback
); );
} }
registerForChange(callback: (isActive: boolean) => void): void { registerForChange(callback: (isActive: boolean) => void): void {
this.changeCallbacks.push(callback); this.#changeCallbacks.push(callback);
} }
unregisterForChange(callback: (isActive: boolean) => void): void { unregisterForChange(callback: (isActive: boolean) => void): void {
this.changeCallbacks = this.changeCallbacks.filter( this.#changeCallbacks = this.#changeCallbacks.filter(
item => item !== callback item => item !== callback
); );
} }
private onActiveEvent(): void { #onActiveEvent(): void {
this.updateState(() => { this.#updateState(() => {
this.lastActiveEventAt = Date.now(); this.#lastActiveEventAt = Date.now();
}); });
} }
private setWindowFocus(isFocused: boolean): void { #setWindowFocus(isFocused: boolean): void {
this.updateState(() => { this.#updateState(() => {
this.isFocused = isFocused; this.#isFocused = isFocused;
}); });
} }
private updateState(fn: () => void): void { #updateState(fn: () => void): void {
const wasActiveBefore = this.isActive(); const wasActiveBefore = this.isActive();
fn(); fn();
const isActiveNow = this.isActive(); const isActiveNow = this.isActive();
if (!wasActiveBefore && isActiveNow) { if (!wasActiveBefore && isActiveNow) {
this.callActiveCallbacks(); this.#callActiveCallbacks();
} }
if (wasActiveBefore !== isActiveNow) { if (wasActiveBefore !== isActiveNow) {
for (const callback of this.changeCallbacks) { for (const callback of this.#changeCallbacks) {
callback(isActiveNow); callback(isActiveNow);
} }
} }

View file

@ -26,7 +26,7 @@ export class MessageCache {
return instance; return instance;
} }
private state = { #state = {
messages: new Map<string, MessageModel>(), messages: new Map<string, MessageModel>(),
messageIdsBySender: new Map<string, string>(), messageIdsBySender: new Map<string, string>(),
messageIdsBySentAt: new Map<number, Array<string>>(), messageIdsBySentAt: new Map<number, Array<string>>(),
@ -60,14 +60,14 @@ export class MessageCache {
return existing; return existing;
} }
this.addMessageToCache(message); this.#addMessageToCache(message);
return message; return message;
} }
// Finds a message in the cache by sender identifier // Finds a message in the cache by sender identifier
public findBySender(senderIdentifier: string): MessageModel | undefined { public findBySender(senderIdentifier: string): MessageModel | undefined {
const id = this.state.messageIdsBySender.get(senderIdentifier); const id = this.#state.messageIdsBySender.get(senderIdentifier);
if (!id) { if (!id) {
return undefined; return undefined;
} }
@ -77,12 +77,12 @@ export class MessageCache {
// Finds a message in the cache by Id // Finds a message in the cache by Id
public getById(id: string): MessageModel | undefined { public getById(id: string): MessageModel | undefined {
const message = this.state.messages.get(id); const message = this.#state.messages.get(id);
if (!message) { if (!message) {
return undefined; return undefined;
} }
this.state.lastAccessedAt.set(id, Date.now()); this.#state.lastAccessedAt.set(id, Date.now());
return message; return message;
} }
@ -92,7 +92,7 @@ export class MessageCache {
sentAt: number, sentAt: number,
predicate: (model: MessageModel) => boolean predicate: (model: MessageModel) => boolean
): Promise<MessageModel | undefined> { ): Promise<MessageModel | undefined> {
const items = this.state.messageIdsBySentAt.get(sentAt) ?? []; const items = this.#state.messageIdsBySentAt.get(sentAt) ?? [];
const inMemory = items const inMemory = items
.map(id => this.getById(id)) .map(id => this.getById(id))
.filter(isNotNil) .filter(isNotNil)
@ -113,12 +113,12 @@ export class MessageCache {
// Deletes the message from our cache // Deletes the message from our cache
public unregister(id: string): void { public unregister(id: string): void {
const message = this.state.messages.get(id); const message = this.#state.messages.get(id);
if (!message) { if (!message) {
return; return;
} }
this.removeMessage(id); this.#removeMessage(id);
} }
// Evicts messages from the message cache if they have not been accessed past // Evicts messages from the message cache if they have not been accessed past
@ -126,8 +126,8 @@ export class MessageCache {
public deleteExpiredMessages(expiryTime: number): void { public deleteExpiredMessages(expiryTime: number): void {
const now = Date.now(); const now = Date.now();
for (const [messageId, message] of this.state.messages) { for (const [messageId, message] of this.#state.messages) {
const timeLastAccessed = this.state.lastAccessedAt.get(messageId) ?? 0; const timeLastAccessed = this.#state.lastAccessedAt.get(messageId) ?? 0;
const conversation = getMessageConversation(message.attributes); const conversation = getMessageConversation(message.attributes);
const state = window.reduxStore.getState(); const state = window.reduxStore.getState();
@ -177,7 +177,7 @@ export class MessageCache {
}; };
}; };
for (const [, message] of this.state.messages) { for (const [, message] of this.#state.messages) {
if (message.get('conversationId') !== obsoleteId) { if (message.get('conversationId') !== obsoleteId) {
continue; continue;
} }
@ -213,12 +213,12 @@ export class MessageCache {
return; return;
} }
this.state.messageIdsBySender.delete( this.#state.messageIdsBySender.delete(
getSenderIdentifier(message.attributes) getSenderIdentifier(message.attributes)
); );
const { id, sent_at: sentAt } = message.attributes; const { id, sent_at: sentAt } = message.attributes;
const previousIdsBySentAt = this.state.messageIdsBySentAt.get(sentAt); const previousIdsBySentAt = this.#state.messageIdsBySentAt.get(sentAt);
let nextIdsBySentAtSet: Set<string>; let nextIdsBySentAtSet: Set<string>;
if (previousIdsBySentAt) { if (previousIdsBySentAt) {
@ -228,29 +228,29 @@ export class MessageCache {
nextIdsBySentAtSet = new Set([id]); nextIdsBySentAtSet = new Set([id]);
} }
this.state.lastAccessedAt.set(id, Date.now()); this.#state.lastAccessedAt.set(id, Date.now());
this.state.messageIdsBySender.set( this.#state.messageIdsBySender.set(
getSenderIdentifier(message.attributes), getSenderIdentifier(message.attributes),
id id
); );
this.throttledUpdateRedux(message.attributes); this.#throttledUpdateRedux(message.attributes);
} }
// Helpers // Helpers
private addMessageToCache(message: MessageModel): void { #addMessageToCache(message: MessageModel): void {
if (!message.id) { if (!message.id) {
return; return;
} }
if (this.state.messages.has(message.id)) { if (this.#state.messages.has(message.id)) {
this.state.lastAccessedAt.set(message.id, Date.now()); this.#state.lastAccessedAt.set(message.id, Date.now());
return; return;
} }
const { id, sent_at: sentAt } = message.attributes; const { id, sent_at: sentAt } = message.attributes;
const previousIdsBySentAt = this.state.messageIdsBySentAt.get(sentAt); const previousIdsBySentAt = this.#state.messageIdsBySentAt.get(sentAt);
let nextIdsBySentAtSet: Set<string>; let nextIdsBySentAtSet: Set<string>;
if (previousIdsBySentAt) { if (previousIdsBySentAt) {
@ -260,41 +260,44 @@ export class MessageCache {
nextIdsBySentAtSet = new Set([id]); nextIdsBySentAtSet = new Set([id]);
} }
this.state.messages.set(message.id, message); this.#state.messages.set(message.id, message);
this.state.lastAccessedAt.set(message.id, Date.now()); this.#state.lastAccessedAt.set(message.id, Date.now());
this.state.messageIdsBySentAt.set(sentAt, Array.from(nextIdsBySentAtSet)); this.#state.messageIdsBySentAt.set(sentAt, Array.from(nextIdsBySentAtSet));
this.state.messageIdsBySender.set( this.#state.messageIdsBySender.set(
getSenderIdentifier(message.attributes), getSenderIdentifier(message.attributes),
id id
); );
} }
private removeMessage(messageId: string): void { #removeMessage(messageId: string): void {
const message = this.state.messages.get(messageId); const message = this.#state.messages.get(messageId);
if (!message) { if (!message) {
return; return;
} }
const { id, sent_at: sentAt } = message.attributes; const { id, sent_at: sentAt } = message.attributes;
const nextIdsBySentAtSet = const nextIdsBySentAtSet =
new Set(this.state.messageIdsBySentAt.get(sentAt)) || new Set(); new Set(this.#state.messageIdsBySentAt.get(sentAt)) || new Set();
nextIdsBySentAtSet.delete(id); nextIdsBySentAtSet.delete(id);
if (nextIdsBySentAtSet.size) { if (nextIdsBySentAtSet.size) {
this.state.messageIdsBySentAt.set(sentAt, Array.from(nextIdsBySentAtSet)); this.#state.messageIdsBySentAt.set(
sentAt,
Array.from(nextIdsBySentAtSet)
);
} else { } else {
this.state.messageIdsBySentAt.delete(sentAt); this.#state.messageIdsBySentAt.delete(sentAt);
} }
this.state.messages.delete(messageId); this.#state.messages.delete(messageId);
this.state.lastAccessedAt.delete(messageId); this.#state.lastAccessedAt.delete(messageId);
this.state.messageIdsBySender.delete( this.#state.messageIdsBySender.delete(
getSenderIdentifier(message.attributes) getSenderIdentifier(message.attributes)
); );
} }
private updateRedux(attributes: MessageAttributesType) { #updateRedux(attributes: MessageAttributesType) {
if (!window.reduxActions) { if (!window.reduxActions) {
return; return;
} }
@ -320,21 +323,21 @@ export class MessageCache {
); );
} }
private throttledReduxUpdaters = new LRUCache< #throttledReduxUpdaters = new LRUCache<
string, string,
typeof this.updateRedux (attributes: MessageAttributesType) => void
>({ >({
max: MAX_THROTTLED_REDUX_UPDATERS, max: MAX_THROTTLED_REDUX_UPDATERS,
}); });
private throttledUpdateRedux(attributes: MessageAttributesType) { #throttledUpdateRedux(attributes: MessageAttributesType) {
let updater = this.throttledReduxUpdaters.get(attributes.id); let updater = this.#throttledReduxUpdaters.get(attributes.id);
if (!updater) { if (!updater) {
updater = throttle(this.updateRedux.bind(this), 200, { updater = throttle(this.#updateRedux.bind(this), 200, {
leading: true, leading: true,
trailing: true, trailing: true,
}); });
this.throttledReduxUpdaters.set(attributes.id, updater); this.#throttledReduxUpdaters.set(attributes.id, updater);
} }
updater(attributes); updater(attributes);

View file

@ -8,13 +8,13 @@ import { waitForOnline } from '../util/waitForOnline';
// This is only exported for testing. // This is only exported for testing.
export class AreWeASubscriberService { export class AreWeASubscriberService {
private readonly queue = new LatestQueue(); readonly #queue = new LatestQueue();
update( update(
storage: Pick<StorageInterface, 'get' | 'put' | 'onready'>, storage: Pick<StorageInterface, 'get' | 'put' | 'onready'>,
server: Pick<WebAPIType, 'getHasSubscription' | 'isOnline'> server: Pick<WebAPIType, 'getHasSubscription' | 'isOnline'>
): void { ): void {
this.queue.add(async () => { this.#queue.add(async () => {
await new Promise<void>(resolve => storage.onready(resolve)); await new Promise<void>(resolve => storage.onready(resolve));
const subscriberId = storage.get('subscriberId'); const subscriberId = storage.get('subscriberId');

View file

@ -7,40 +7,40 @@ import { requestMicrophonePermissions } from '../util/requestMicrophonePermissio
import { WebAudioRecorder } from '../WebAudioRecorder'; import { WebAudioRecorder } from '../WebAudioRecorder';
export class RecorderClass { export class RecorderClass {
private context?: AudioContext; #context?: AudioContext;
private input?: GainNode; #input?: GainNode;
private recorder?: WebAudioRecorder; #recorder?: WebAudioRecorder;
private source?: MediaStreamAudioSourceNode; #source?: MediaStreamAudioSourceNode;
private stream?: MediaStream; #stream?: MediaStream;
private blob?: Blob; #blob?: Blob;
private resolve?: (blob: Blob) => void; #resolve?: (blob: Blob) => void;
clear(): void { clear(): void {
this.blob = undefined; this.#blob = undefined;
this.resolve = undefined; this.#resolve = undefined;
if (this.source) { if (this.#source) {
this.source.disconnect(); this.#source.disconnect();
this.source = undefined; this.#source = undefined;
} }
if (this.recorder) { if (this.#recorder) {
if (this.recorder.isRecording()) { if (this.#recorder.isRecording()) {
this.recorder.cancelRecording(); this.#recorder.cancelRecording();
} }
// Reach in and terminate the web worker used by WebAudioRecorder, otherwise // Reach in and terminate the web worker used by WebAudioRecorder, otherwise
// it gets leaked due to a reference cycle with its onmessage listener // it gets leaked due to a reference cycle with its onmessage listener
this.recorder.worker?.terminate(); this.#recorder.worker?.terminate();
this.recorder = undefined; this.#recorder = undefined;
} }
this.input = undefined; this.#input = undefined;
this.stream = undefined; this.#stream = undefined;
if (this.context) { if (this.#context) {
void this.context.close(); void this.#context.close();
this.context = undefined; this.#context = undefined;
} }
} }
@ -55,11 +55,11 @@ export class RecorderClass {
this.clear(); this.clear();
this.context = new AudioContext(); this.#context = new AudioContext();
this.input = this.context.createGain(); this.#input = this.#context.createGain();
this.recorder = new WebAudioRecorder( this.#recorder = new WebAudioRecorder(
this.input, this.#input,
{ {
timeLimit: 60 + 3600, // one minute more than our UI-imposed limit timeLimit: 60 + 3600, // one minute more than our UI-imposed limit
}, },
@ -76,24 +76,24 @@ export class RecorderClass {
audio: { mandatory: { googAutoGainControl: false } } as any, audio: { mandatory: { googAutoGainControl: false } } as any,
}); });
if (!this.context || !this.input) { if (!this.#context || !this.#input) {
const err = new Error( const err = new Error(
'Recorder/getUserMedia/stream: Missing context or input!' 'Recorder/getUserMedia/stream: Missing context or input!'
); );
this.onError(this.recorder, String(err)); this.onError(this.#recorder, String(err));
throw err; throw err;
} }
this.source = this.context.createMediaStreamSource(stream); this.#source = this.#context.createMediaStreamSource(stream);
this.source.connect(this.input); this.#source.connect(this.#input);
this.stream = stream; this.#stream = stream;
} catch (err) { } catch (err) {
log.error('Recorder.onGetUserMediaError:', Errors.toLogFormat(err)); log.error('Recorder.onGetUserMediaError:', Errors.toLogFormat(err));
this.clear(); this.clear();
throw err; throw err;
} }
if (this.recorder) { if (this.#recorder) {
this.recorder.startRecording(); this.#recorder.startRecording();
return true; return true;
} }
@ -101,34 +101,34 @@ export class RecorderClass {
} }
async stop(): Promise<Blob | undefined> { async stop(): Promise<Blob | undefined> {
if (!this.recorder) { if (!this.#recorder) {
return; return;
} }
if (this.stream) { if (this.#stream) {
this.stream.getTracks().forEach(track => track.stop()); this.#stream.getTracks().forEach(track => track.stop());
} }
if (this.blob) { if (this.#blob) {
return this.blob; return this.#blob;
} }
const promise = new Promise<Blob>(resolve => { const promise = new Promise<Blob>(resolve => {
this.resolve = resolve; this.#resolve = resolve;
}); });
this.recorder.finishRecording(); this.#recorder.finishRecording();
return promise; return promise;
} }
onComplete(_recorder: WebAudioRecorder, blob: Blob): void { onComplete(_recorder: WebAudioRecorder, blob: Blob): void {
this.blob = blob; this.#blob = blob;
this.resolve?.(blob); this.#resolve?.(blob);
} }
onError(_recorder: WebAudioRecorder, error: string): void { onError(_recorder: WebAudioRecorder, error: string): void {
if (!this.recorder) { if (!this.#recorder) {
log.warn('Recorder/onError: Called with no recorder'); log.warn('Recorder/onError: Called with no recorder');
return; return;
} }
@ -139,11 +139,11 @@ export class RecorderClass {
} }
getBlob(): Blob { getBlob(): Blob {
if (!this.blob) { if (!this.#blob) {
throw new Error('no blob found'); throw new Error('no blob found');
} }
return this.blob; return this.#blob;
} }
} }

View file

@ -25,7 +25,7 @@ export type DownloadOptionsType = Readonly<{
}>; }>;
export class BackupAPI { export class BackupAPI {
private cachedBackupInfo = new Map< #cachedBackupInfo = new Map<
BackupCredentialType, BackupCredentialType,
GetBackupInfoResponseType GetBackupInfoResponseType
>(); >();
@ -38,23 +38,23 @@ export class BackupAPI {
this.credentials.getHeadersForToday(type) this.credentials.getHeadersForToday(type)
) )
); );
await Promise.all(headers.map(h => this.server.refreshBackup(h))); await Promise.all(headers.map(h => this.#server.refreshBackup(h)));
} }
public async getInfo( public async getInfo(
credentialType: BackupCredentialType credentialType: BackupCredentialType
): Promise<GetBackupInfoResponseType> { ): Promise<GetBackupInfoResponseType> {
const backupInfo = await this.server.getBackupInfo( const backupInfo = await this.#server.getBackupInfo(
await this.credentials.getHeadersForToday(credentialType) await this.credentials.getHeadersForToday(credentialType)
); );
this.cachedBackupInfo.set(credentialType, backupInfo); this.#cachedBackupInfo.set(credentialType, backupInfo);
return backupInfo; return backupInfo;
} }
private async getCachedInfo( async #getCachedInfo(
credentialType: BackupCredentialType credentialType: BackupCredentialType
): Promise<GetBackupInfoResponseType> { ): Promise<GetBackupInfoResponseType> {
const cached = this.cachedBackupInfo.get(credentialType); const cached = this.#cachedBackupInfo.get(credentialType);
if (cached) { if (cached) {
return cached; return cached;
} }
@ -63,15 +63,15 @@ export class BackupAPI {
} }
public async getMediaDir(): Promise<string> { public async getMediaDir(): Promise<string> {
return (await this.getCachedInfo(BackupCredentialType.Media)).mediaDir; return (await this.#getCachedInfo(BackupCredentialType.Media)).mediaDir;
} }
public async getBackupDir(): Promise<string> { public async getBackupDir(): Promise<string> {
return (await this.getCachedInfo(BackupCredentialType.Media))?.backupDir; return (await this.#getCachedInfo(BackupCredentialType.Media))?.backupDir;
} }
public async upload(filePath: string, fileSize: number): Promise<void> { public async upload(filePath: string, fileSize: number): Promise<void> {
const form = await this.server.getBackupUploadForm( const form = await this.#server.getBackupUploadForm(
await this.credentials.getHeadersForToday(BackupCredentialType.Messages) await this.credentials.getHeadersForToday(BackupCredentialType.Messages)
); );
@ -95,7 +95,7 @@ export class BackupAPI {
BackupCredentialType.Messages BackupCredentialType.Messages
); );
return this.server.getBackupStream({ return this.#server.getBackupStream({
cdn, cdn,
backupDir, backupDir,
backupName, backupName,
@ -111,7 +111,7 @@ export class BackupAPI {
onProgress, onProgress,
abortSignal, abortSignal,
}: DownloadOptionsType): Promise<Readable> { }: DownloadOptionsType): Promise<Readable> {
const response = await this.server.getTransferArchive({ const response = await this.#server.getTransferArchive({
abortSignal, abortSignal,
}); });
@ -128,7 +128,7 @@ export class BackupAPI {
const { cdn, key } = response; const { cdn, key } = response;
return this.server.getEphemeralBackupStream({ return this.#server.getEphemeralBackupStream({
cdn, cdn,
key, key,
downloadOffset, downloadOffset,
@ -138,7 +138,7 @@ export class BackupAPI {
} }
public async getMediaUploadForm(): Promise<AttachmentUploadFormResponseType> { public async getMediaUploadForm(): Promise<AttachmentUploadFormResponseType> {
return this.server.getBackupMediaUploadForm( return this.#server.getBackupMediaUploadForm(
await this.credentials.getHeadersForToday(BackupCredentialType.Media) await this.credentials.getHeadersForToday(BackupCredentialType.Media)
); );
} }
@ -146,7 +146,7 @@ export class BackupAPI {
public async backupMediaBatch( public async backupMediaBatch(
items: ReadonlyArray<BackupMediaItemType> items: ReadonlyArray<BackupMediaItemType>
): Promise<BackupMediaBatchResponseType> { ): Promise<BackupMediaBatchResponseType> {
return this.server.backupMediaBatch({ return this.#server.backupMediaBatch({
headers: await this.credentials.getHeadersForToday( headers: await this.credentials.getHeadersForToday(
BackupCredentialType.Media BackupCredentialType.Media
), ),
@ -161,7 +161,7 @@ export class BackupAPI {
cursor?: string; cursor?: string;
limit: number; limit: number;
}): Promise<BackupListMediaResponseType> { }): Promise<BackupListMediaResponseType> {
return this.server.backupListMedia({ return this.#server.backupListMedia({
headers: await this.credentials.getHeadersForToday( headers: await this.credentials.getHeadersForToday(
BackupCredentialType.Media BackupCredentialType.Media
), ),
@ -171,10 +171,10 @@ export class BackupAPI {
} }
public clearCache(): void { public clearCache(): void {
this.cachedBackupInfo.clear(); this.#cachedBackupInfo.clear();
} }
private get server(): WebAPIType { get #server(): WebAPIType {
const { server } = window.textsecure; const { server } = window.textsecure;
strictAssert(server, 'server not available'); strictAssert(server, 'server not available');

View file

@ -44,15 +44,14 @@ const FETCH_INTERVAL = 3 * DAY;
const BACKUP_CDN_READ_CREDENTIALS_VALID_DURATION = 12 * HOUR; const BACKUP_CDN_READ_CREDENTIALS_VALID_DURATION = 12 * HOUR;
export class BackupCredentials { export class BackupCredentials {
private activeFetch: ReturnType<typeof this.fetch> | undefined; #activeFetch: Promise<ReadonlyArray<BackupCredentialWrapperType>> | undefined;
private cachedCdnReadCredentials: Record<
number, #cachedCdnReadCredentials: Record<number, BackupCdnReadCredentialType> = {};
BackupCdnReadCredentialType
> = {}; readonly #fetchBackoff = new BackOff(FIBONACCI_TIMEOUTS);
private readonly fetchBackoff = new BackOff(FIBONACCI_TIMEOUTS);
public start(): void { public start(): void {
this.scheduleFetch(); this.#scheduleFetch();
} }
public async getForToday( public async getForToday(
@ -73,7 +72,7 @@ export class BackupCredentials {
} }
// Start with cache // Start with cache
let credentials = this.getFromCache(); let credentials = this.#getFromCache();
let result = credentials.find(({ type, redemptionTimeMs }) => { let result = credentials.find(({ type, redemptionTimeMs }) => {
return type === credentialType && redemptionTimeMs === now; return type === credentialType && redemptionTimeMs === now;
@ -81,7 +80,7 @@ export class BackupCredentials {
if (result === undefined) { if (result === undefined) {
log.info(`BackupCredentials: cache miss for ${now}`); log.info(`BackupCredentials: cache miss for ${now}`);
credentials = await this.fetch(); credentials = await this.#fetch();
result = credentials.find(({ type, redemptionTimeMs }) => { result = credentials.find(({ type, redemptionTimeMs }) => {
return type === credentialType && redemptionTimeMs === now; return type === credentialType && redemptionTimeMs === now;
}); });
@ -143,7 +142,7 @@ export class BackupCredentials {
// Backup CDN read credentials are short-lived; we'll just cache them in memory so // Backup CDN read credentials are short-lived; we'll just cache them in memory so
// that they get invalidated for any reason, we'll fetch new ones on app restart // that they get invalidated for any reason, we'll fetch new ones on app restart
const cachedCredentialsForThisCdn = this.cachedCdnReadCredentials[cdn]; const cachedCredentialsForThisCdn = this.#cachedCdnReadCredentials[cdn];
if ( if (
cachedCredentialsForThisCdn && cachedCredentialsForThisCdn &&
@ -163,7 +162,7 @@ export class BackupCredentials {
cdn, cdn,
}); });
this.cachedCdnReadCredentials[cdn] = { this.#cachedCdnReadCredentials[cdn] = {
credentials: newCredentials, credentials: newCredentials,
cdnNumber: cdn, cdnNumber: cdn,
retrievedAtMs, retrievedAtMs,
@ -172,7 +171,7 @@ export class BackupCredentials {
return newCredentials; return newCredentials;
} }
private scheduleFetch(): void { #scheduleFetch(): void {
const lastFetchAt = window.storage.get( const lastFetchAt = window.storage.get(
'backupCombinedCredentialsLastRequestTime', 'backupCombinedCredentialsLastRequestTime',
0 0
@ -181,45 +180,45 @@ export class BackupCredentials {
const delay = Math.max(0, nextFetchAt - Date.now()); const delay = Math.max(0, nextFetchAt - Date.now());
log.info(`BackupCredentials: scheduling fetch in ${delay}ms`); log.info(`BackupCredentials: scheduling fetch in ${delay}ms`);
setTimeout(() => drop(this.runPeriodicFetch()), delay); setTimeout(() => drop(this.#runPeriodicFetch()), delay);
} }
private async runPeriodicFetch(): Promise<void> { async #runPeriodicFetch(): Promise<void> {
try { try {
log.info('BackupCredentials: run periodic fetch'); log.info('BackupCredentials: run periodic fetch');
await this.fetch(); await this.#fetch();
const now = Date.now(); const now = Date.now();
await window.storage.put('backupCombinedCredentialsLastRequestTime', now); await window.storage.put('backupCombinedCredentialsLastRequestTime', now);
this.fetchBackoff.reset(); this.#fetchBackoff.reset();
this.scheduleFetch(); this.#scheduleFetch();
} catch (error) { } catch (error) {
const delay = this.fetchBackoff.getAndIncrement(); const delay = this.#fetchBackoff.getAndIncrement();
log.error( log.error(
'BackupCredentials: periodic fetch failed with ' + 'BackupCredentials: periodic fetch failed with ' +
`error: ${toLogFormat(error)}, retrying in ${delay}ms` `error: ${toLogFormat(error)}, retrying in ${delay}ms`
); );
setTimeout(() => this.scheduleFetch(), delay); setTimeout(() => this.#scheduleFetch(), delay);
} }
} }
private async fetch(): Promise<ReadonlyArray<BackupCredentialWrapperType>> { async #fetch(): Promise<ReadonlyArray<BackupCredentialWrapperType>> {
if (this.activeFetch) { if (this.#activeFetch) {
return this.activeFetch; return this.#activeFetch;
} }
const promise = this.doFetch(); const promise = this.#doFetch();
this.activeFetch = promise; this.#activeFetch = promise;
try { try {
return await promise; return await promise;
} finally { } finally {
this.activeFetch = undefined; this.#activeFetch = undefined;
} }
} }
private async doFetch(): Promise<ReadonlyArray<BackupCredentialWrapperType>> { async #doFetch(): Promise<ReadonlyArray<BackupCredentialWrapperType>> {
log.info('BackupCredentials: fetching'); log.info('BackupCredentials: fetching');
const now = Date.now(); const now = Date.now();
@ -227,8 +226,8 @@ export class BackupCredentials {
const endDayInMs = toDayMillis(now + 6 * DAY); const endDayInMs = toDayMillis(now + 6 * DAY);
// And fetch missing credentials // And fetch missing credentials
const messagesCtx = this.getAuthContext(BackupCredentialType.Messages); const messagesCtx = this.#getAuthContext(BackupCredentialType.Messages);
const mediaCtx = this.getAuthContext(BackupCredentialType.Media); const mediaCtx = this.#getAuthContext(BackupCredentialType.Media);
const { server } = window.textsecure; const { server } = window.textsecure;
strictAssert(server, 'server not available'); strictAssert(server, 'server not available');
@ -333,7 +332,7 @@ export class BackupCredentials {
// Add cached credentials that are still in the date range, and not in // Add cached credentials that are still in the date range, and not in
// the response. // the response.
for (const cached of this.getFromCache()) { for (const cached of this.#getFromCache()) {
const { type, redemptionTimeMs } = cached; const { type, redemptionTimeMs } = cached;
if ( if (
!(startDayInMs <= redemptionTimeMs && redemptionTimeMs <= endDayInMs) !(startDayInMs <= redemptionTimeMs && redemptionTimeMs <= endDayInMs)
@ -348,7 +347,7 @@ export class BackupCredentials {
} }
result.sort((a, b) => a.redemptionTimeMs - b.redemptionTimeMs); result.sort((a, b) => a.redemptionTimeMs - b.redemptionTimeMs);
await this.updateCache(result); await this.#updateCache(result);
const startMs = result[0].redemptionTimeMs; const startMs = result[0].redemptionTimeMs;
const endMs = result[result.length - 1].redemptionTimeMs; const endMs = result[result.length - 1].redemptionTimeMs;
@ -359,7 +358,7 @@ export class BackupCredentials {
return result; return result;
} }
private getAuthContext( #getAuthContext(
credentialType: BackupCredentialType credentialType: BackupCredentialType
): BackupAuthCredentialRequestContext { ): BackupAuthCredentialRequestContext {
let key: BackupKey; let key: BackupKey;
@ -376,11 +375,11 @@ export class BackupCredentials {
); );
} }
private getFromCache(): ReadonlyArray<BackupCredentialWrapperType> { #getFromCache(): ReadonlyArray<BackupCredentialWrapperType> {
return window.storage.get('backupCombinedCredentials', []); return window.storage.get('backupCombinedCredentials', []);
} }
private async updateCache( async #updateCache(
values: ReadonlyArray<BackupCredentialWrapperType> values: ReadonlyArray<BackupCredentialWrapperType>
): Promise<void> { ): Promise<void> {
await window.storage.put('backupCombinedCredentials', values); await window.storage.put('backupCombinedCredentials', values);
@ -394,7 +393,7 @@ export class BackupCredentials {
// Called when backup tier changes or when userChanged event // Called when backup tier changes or when userChanged event
public async clearCache(): Promise<void> { public async clearCache(): Promise<void> {
this.cachedCdnReadCredentials = {}; this.#cachedCdnReadCredentials = {};
await this.updateCache([]); await this.#updateCache([]);
} }
} }

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -91,10 +91,11 @@ export type ImportOptionsType = Readonly<{
}>; }>;
export class BackupsService { export class BackupsService {
private isStarted = false; #isStarted = false;
private isRunning: 'import' | 'export' | false = false; #isRunning: 'import' | 'export' | false = false;
private downloadController: AbortController | undefined; #downloadController: AbortController | undefined;
private downloadRetryPromise:
#downloadRetryPromise:
| ExplodePromiseResultType<RetryBackupImportValue> | ExplodePromiseResultType<RetryBackupImportValue>
| undefined; | undefined;
@ -102,19 +103,19 @@ export class BackupsService {
public readonly api = new BackupAPI(this.credentials); public readonly api = new BackupAPI(this.credentials);
public start(): void { public start(): void {
if (this.isStarted) { if (this.#isStarted) {
log.warn('BackupsService: already started'); log.warn('BackupsService: already started');
return; return;
} }
this.isStarted = true; this.#isStarted = true;
log.info('BackupsService: starting...'); log.info('BackupsService: starting...');
setInterval(() => { setInterval(() => {
drop(this.runPeriodicRefresh()); drop(this.#runPeriodicRefresh());
}, BACKUP_REFRESH_INTERVAL); }, BACKUP_REFRESH_INTERVAL);
drop(this.runPeriodicRefresh()); drop(this.#runPeriodicRefresh());
this.credentials.start(); this.credentials.start();
window.Whisper.events.on('userChanged', () => { window.Whisper.events.on('userChanged', () => {
@ -142,13 +143,13 @@ export class BackupsService {
while (true) { while (true) {
try { try {
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
hasBackup = await this.doDownloadAndImport({ hasBackup = await this.#doDownloadAndImport({
downloadPath: absoluteDownloadPath, downloadPath: absoluteDownloadPath,
onProgress: options.onProgress, onProgress: options.onProgress,
ephemeralKey, ephemeralKey,
}); });
} catch (error) { } catch (error) {
this.downloadRetryPromise = explodePromise<RetryBackupImportValue>(); this.#downloadRetryPromise = explodePromise<RetryBackupImportValue>();
let installerError: InstallScreenBackupError; let installerError: InstallScreenBackupError;
if (error instanceof RelinkRequestedError) { if (error instanceof RelinkRequestedError) {
@ -158,7 +159,7 @@ export class BackupsService {
Errors.toLogFormat(error) Errors.toLogFormat(error)
); );
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
await this.unlinkAndDeleteAllData(); await this.#unlinkAndDeleteAllData();
} else if (error instanceof UnsupportedBackupVersion) { } else if (error instanceof UnsupportedBackupVersion) {
installerError = InstallScreenBackupError.UnsupportedVersion; installerError = InstallScreenBackupError.UnsupportedVersion;
log.error( log.error(
@ -178,7 +179,7 @@ export class BackupsService {
Errors.toLogFormat(error) Errors.toLogFormat(error)
); );
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
await this.unlinkAndDeleteAllData(); await this.#unlinkAndDeleteAllData();
} else { } else {
log.error( log.error(
'backups.downloadAndImport: unknown error, prompting user to retry' 'backups.downloadAndImport: unknown error, prompting user to retry'
@ -191,7 +192,7 @@ export class BackupsService {
}); });
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
const nextStep = await this.downloadRetryPromise.promise; const nextStep = await this.#downloadRetryPromise.promise;
if (nextStep === 'retry') { if (nextStep === 'retry') {
continue; continue;
} }
@ -218,11 +219,11 @@ export class BackupsService {
} }
public retryDownload(): void { public retryDownload(): void {
if (!this.downloadRetryPromise) { if (!this.#downloadRetryPromise) {
return; return;
} }
this.downloadRetryPromise.resolve('retry'); this.#downloadRetryPromise.resolve('retry');
} }
public async upload(): Promise<void> { public async upload(): Promise<void> {
@ -274,7 +275,7 @@ export class BackupsService {
const chunks = new Array<Uint8Array>(); const chunks = new Array<Uint8Array>();
sink.on('data', chunk => chunks.push(chunk)); sink.on('data', chunk => chunks.push(chunk));
await this.exportBackup(sink, backupLevel, backupType); await this.#exportBackup(sink, backupLevel, backupType);
return Bytes.concatenate(chunks); return Bytes.concatenate(chunks);
} }
@ -285,7 +286,7 @@ export class BackupsService {
backupLevel: BackupLevel = BackupLevel.Free, backupLevel: BackupLevel = BackupLevel.Free,
backupType = BackupType.Ciphertext backupType = BackupType.Ciphertext
): Promise<number> { ): Promise<number> {
const size = await this.exportBackup( const size = await this.#exportBackup(
createWriteStream(path), createWriteStream(path),
backupLevel, backupLevel,
backupType backupType
@ -318,12 +319,12 @@ export class BackupsService {
} }
public cancelDownload(): void { public cancelDownload(): void {
if (this.downloadController) { if (this.#downloadController) {
log.warn('importBackup: canceling download'); log.warn('importBackup: canceling download');
this.downloadController.abort(); this.#downloadController.abort();
this.downloadController = undefined; this.#downloadController = undefined;
if (this.downloadRetryPromise) { if (this.#downloadRetryPromise) {
this.downloadRetryPromise.resolve('cancel'); this.#downloadRetryPromise.resolve('cancel');
} }
} else { } else {
log.error('importBackup: not canceling download, not running'); log.error('importBackup: not canceling download, not running');
@ -338,12 +339,12 @@ export class BackupsService {
onProgress, onProgress,
}: ImportOptionsType = {} }: ImportOptionsType = {}
): Promise<void> { ): Promise<void> {
strictAssert(!this.isRunning, 'BackupService is already running'); strictAssert(!this.#isRunning, 'BackupService is already running');
window.IPC.startTrackingQueryStats(); window.IPC.startTrackingQueryStats();
log.info(`importBackup: starting ${backupType}...`); log.info(`importBackup: starting ${backupType}...`);
this.isRunning = 'import'; this.#isRunning = 'import';
const importStart = Date.now(); const importStart = Date.now();
await DataWriter.disableMessageInsertTriggers(); await DataWriter.disableMessageInsertTriggers();
@ -439,7 +440,7 @@ export class BackupsService {
throw error; throw error;
} finally { } finally {
this.isRunning = false; this.#isRunning = false;
await DataWriter.enableMessageInsertTriggersAndBackfill(); await DataWriter.enableMessageInsertTriggersAndBackfill();
window.IPC.stopTrackingQueryStats({ epochName: 'Backup Import' }); window.IPC.stopTrackingQueryStats({ epochName: 'Backup Import' });
@ -494,7 +495,7 @@ export class BackupsService {
return { isInBackupTier: true, cdnNumber: storedInfo.cdnNumber }; return { isInBackupTier: true, cdnNumber: storedInfo.cdnNumber };
} }
private async doDownloadAndImport({ async #doDownloadAndImport({
downloadPath, downloadPath,
ephemeralKey, ephemeralKey,
onProgress, onProgress,
@ -502,8 +503,8 @@ export class BackupsService {
const controller = new AbortController(); const controller = new AbortController();
// Abort previous download // Abort previous download
this.downloadController?.abort(); this.#downloadController?.abort();
this.downloadController = controller; this.#downloadController = controller;
let downloadOffset = 0; let downloadOffset = 0;
try { try {
@ -591,7 +592,7 @@ export class BackupsService {
return false; return false;
} }
this.downloadController = undefined; this.#downloadController = undefined;
try { try {
// Too late to cancel now, make sure we are unlinked if the process // Too late to cancel now, make sure we are unlinked if the process
@ -633,15 +634,15 @@ export class BackupsService {
return true; return true;
} }
private async exportBackup( async #exportBackup(
sink: Writable, sink: Writable,
backupLevel: BackupLevel = BackupLevel.Free, backupLevel: BackupLevel = BackupLevel.Free,
backupType = BackupType.Ciphertext backupType = BackupType.Ciphertext
): Promise<number> { ): Promise<number> {
strictAssert(!this.isRunning, 'BackupService is already running'); strictAssert(!this.#isRunning, 'BackupService is already running');
log.info('exportBackup: starting...'); log.info('exportBackup: starting...');
this.isRunning = 'export'; this.#isRunning = 'export';
try { try {
// TODO (DESKTOP-7168): Update mock-server to support this endpoint // TODO (DESKTOP-7168): Update mock-server to support this endpoint
@ -694,11 +695,11 @@ export class BackupsService {
return totalBytes; return totalBytes;
} finally { } finally {
log.info('exportBackup: finished...'); log.info('exportBackup: finished...');
this.isRunning = false; this.#isRunning = false;
} }
} }
private async runPeriodicRefresh(): Promise<void> { async #runPeriodicRefresh(): Promise<void> {
try { try {
await this.api.refresh(); await this.api.refresh();
log.info('Backup: refreshed'); log.info('Backup: refreshed');
@ -707,7 +708,7 @@ export class BackupsService {
} }
} }
private async unlinkAndDeleteAllData() { async #unlinkAndDeleteAllData() {
try { try {
await window.textsecure.server?.unlink(); await window.textsecure.server?.unlink();
} catch (e) { } catch (e) {
@ -730,10 +731,10 @@ export class BackupsService {
} }
public isImportRunning(): boolean { public isImportRunning(): boolean {
return this.isRunning === 'import'; return this.#isRunning === 'import';
} }
public isExportRunning(): boolean { public isExportRunning(): boolean {
return this.isRunning === 'export'; return this.#isRunning === 'export';
} }
} }

View file

@ -6,49 +6,49 @@ import { Buffer } from 'node:buffer';
import { InputStream } from '@signalapp/libsignal-client/dist/io'; import { InputStream } from '@signalapp/libsignal-client/dist/io';
export class FileStream extends InputStream { export class FileStream extends InputStream {
private file: FileHandle | undefined; #file: FileHandle | undefined;
private position = 0; #position = 0;
private buffer = Buffer.alloc(16 * 1024); #buffer = Buffer.alloc(16 * 1024);
private initPromise: Promise<unknown> | undefined; #initPromise: Promise<unknown> | undefined;
constructor(private readonly filePath: string) { constructor(private readonly filePath: string) {
super(); super();
} }
public async close(): Promise<void> { public async close(): Promise<void> {
await this.initPromise; await this.#initPromise;
await this.file?.close(); await this.#file?.close();
} }
async read(amount: number): Promise<Buffer> { async read(amount: number): Promise<Buffer> {
const file = await this.lazyOpen(); const file = await this.#lazyOpen();
if (this.buffer.length < amount) { if (this.#buffer.length < amount) {
this.buffer = Buffer.alloc(amount); this.#buffer = Buffer.alloc(amount);
} }
const { bytesRead } = await file.read( const { bytesRead } = await file.read(
this.buffer, this.#buffer,
0, 0,
amount, amount,
this.position this.#position
); );
this.position += bytesRead; this.#position += bytesRead;
return this.buffer.slice(0, bytesRead); return this.#buffer.slice(0, bytesRead);
} }
async skip(amount: number): Promise<void> { async skip(amount: number): Promise<void> {
this.position += amount; this.#position += amount;
} }
private async lazyOpen(): Promise<FileHandle> { async #lazyOpen(): Promise<FileHandle> {
await this.initPromise; await this.#initPromise;
if (this.file) { if (this.#file) {
return this.file; return this.#file;
} }
const filePromise = open(this.filePath); const filePromise = open(this.filePath);
this.initPromise = filePromise; this.#initPromise = filePromise;
this.file = await filePromise; this.#file = await filePromise;
return this.file; return this.#file;
} }
} }

File diff suppressed because it is too large Load diff

View file

@ -14,15 +14,15 @@ import { MessageModel } from '../models/messages';
import { cleanupMessages } from '../util/cleanup'; import { cleanupMessages } from '../util/cleanup';
class ExpiringMessagesDeletionService { class ExpiringMessagesDeletionService {
public update: typeof this.checkExpiringMessages; public update: () => void;
private timeout?: ReturnType<typeof setTimeout>; #timeout?: ReturnType<typeof setTimeout>;
constructor() { constructor() {
this.update = debounce(this.checkExpiringMessages, 1000); this.update = debounce(this.#checkExpiringMessages, 1000);
} }
private async destroyExpiredMessages() { async #destroyExpiredMessages() {
try { try {
window.SignalContext.log.info( window.SignalContext.log.info(
'destroyExpiredMessages: Loading messages...' 'destroyExpiredMessages: Loading messages...'
@ -74,7 +74,7 @@ class ExpiringMessagesDeletionService {
void this.update(); void this.update();
} }
private async checkExpiringMessages() { async #checkExpiringMessages() {
window.SignalContext.log.info( window.SignalContext.log.info(
'checkExpiringMessages: checking for expiring messages' 'checkExpiringMessages: checking for expiring messages'
); );
@ -105,8 +105,8 @@ class ExpiringMessagesDeletionService {
).toISOString()}; waiting ${wait} ms before clearing` ).toISOString()}; waiting ${wait} ms before clearing`
); );
clearTimeoutIfNecessary(this.timeout); clearTimeoutIfNecessary(this.#timeout);
this.timeout = setTimeout(this.destroyExpiredMessages.bind(this), wait); this.#timeout = setTimeout(this.#destroyExpiredMessages.bind(this), wait);
} }
} }

View file

@ -79,27 +79,25 @@ export const FALLBACK_NOTIFICATION_TITLE = 'Signal';
// [0]: https://github.com/electron/electron/issues/15364 // [0]: https://github.com/electron/electron/issues/15364
// [1]: https://github.com/electron/electron/issues/21646 // [1]: https://github.com/electron/electron/issues/21646
class NotificationService extends EventEmitter { class NotificationService extends EventEmitter {
private i18n?: LocalizerType; #i18n?: LocalizerType;
#storage?: StorageInterface;
private storage?: StorageInterface;
public isEnabled = false; public isEnabled = false;
private lastNotification: null | Notification = null; #lastNotification: null | Notification = null;
#notificationData: null | NotificationDataType = null;
private notificationData: null | NotificationDataType = null;
// Testing indicated that trying to create/destroy notifications too quickly // Testing indicated that trying to create/destroy notifications too quickly
// resulted in notifications that stuck around forever, requiring the user // resulted in notifications that stuck around forever, requiring the user
// to manually close them. This introduces a minimum amount of time between calls, // to manually close them. This introduces a minimum amount of time between calls,
// and batches up the quick successive update() calls we get from an incoming // and batches up the quick successive update() calls we get from an incoming
// read sync, which might have a number of messages referenced inside of it. // read sync, which might have a number of messages referenced inside of it.
private update: () => unknown; #update: () => unknown;
constructor() { constructor() {
super(); super();
this.update = debounce(this.fastUpdate.bind(this), 1000); this.#update = debounce(this.#fastUpdate.bind(this), 1000);
} }
public initialize({ public initialize({
@ -107,13 +105,13 @@ class NotificationService extends EventEmitter {
storage, storage,
}: Readonly<{ i18n: LocalizerType; storage: StorageInterface }>): void { }: Readonly<{ i18n: LocalizerType; storage: StorageInterface }>): void {
log.info('NotificationService initialized'); log.info('NotificationService initialized');
this.i18n = i18n; this.#i18n = i18n;
this.storage = storage; this.#storage = storage;
} }
private getStorage(): StorageInterface { #getStorage(): StorageInterface {
if (this.storage) { if (this.#storage) {
return this.storage; return this.#storage;
} }
log.error( log.error(
@ -122,9 +120,9 @@ class NotificationService extends EventEmitter {
return window.storage; return window.storage;
} }
private getI18n(): LocalizerType { #getI18n(): LocalizerType {
if (this.i18n) { if (this.#i18n) {
return this.i18n; return this.#i18n;
} }
log.error( log.error(
@ -141,8 +139,8 @@ class NotificationService extends EventEmitter {
log.info( log.info(
'NotificationService: adding a notification and requesting an update' 'NotificationService: adding a notification and requesting an update'
); );
this.notificationData = notificationData; this.#notificationData = notificationData;
this.update(); this.#update();
} }
/** /**
@ -190,7 +188,7 @@ class NotificationService extends EventEmitter {
}) })
); );
} else { } else {
this.lastNotification?.close(); this.#lastNotification?.close();
const notification = new window.Notification(title, { const notification = new window.Notification(title, {
body: OS.isLinux() ? filterNotificationText(message) : message, body: OS.isLinux() ? filterNotificationText(message) : message,
@ -226,7 +224,7 @@ class NotificationService extends EventEmitter {
} }
}; };
this.lastNotification = notification; this.#lastNotification = notification;
} }
if (!silent) { if (!silent) {
@ -254,7 +252,7 @@ class NotificationService extends EventEmitter {
targetAuthorAci?: string; targetAuthorAci?: string;
targetTimestamp?: number; targetTimestamp?: number;
}>): void { }>): void {
if (!this.notificationData) { if (!this.#notificationData) {
log.info('NotificationService#removeBy: no notification data'); log.info('NotificationService#removeBy: no notification data');
return; return;
} }
@ -262,12 +260,12 @@ class NotificationService extends EventEmitter {
let shouldClear = false; let shouldClear = false;
if ( if (
conversationId && conversationId &&
this.notificationData.conversationId === conversationId this.#notificationData.conversationId === conversationId
) { ) {
log.info('NotificationService#removeBy: conversation ID matches'); log.info('NotificationService#removeBy: conversation ID matches');
shouldClear = true; shouldClear = true;
} }
if (messageId && this.notificationData.messageId === messageId) { if (messageId && this.#notificationData.messageId === messageId) {
log.info('NotificationService#removeBy: message ID matches'); log.info('NotificationService#removeBy: message ID matches');
shouldClear = true; shouldClear = true;
} }
@ -276,7 +274,7 @@ class NotificationService extends EventEmitter {
return; return;
} }
const { reaction } = this.notificationData; const { reaction } = this.#notificationData;
if ( if (
reaction && reaction &&
emoji && emoji &&
@ -290,13 +288,13 @@ class NotificationService extends EventEmitter {
} }
this.clear(); this.clear();
this.update(); this.#update();
} }
private fastUpdate(): void { #fastUpdate(): void {
const storage = this.getStorage(); const storage = this.#getStorage();
const i18n = this.getI18n(); const i18n = this.#getI18n();
const { notificationData } = this; const notificationData = this.#notificationData;
const isAppFocused = window.SignalContext.activeWindowService.isActive(); const isAppFocused = window.SignalContext.activeWindowService.isActive();
const userSetting = this.getNotificationSetting(); const userSetting = this.getNotificationSetting();
@ -308,9 +306,9 @@ class NotificationService extends EventEmitter {
if (!notificationData) { if (!notificationData) {
drop(window.IPC.clearAllWindowsNotifications()); drop(window.IPC.clearAllWindowsNotifications());
} }
} else if (this.lastNotification) { } else if (this.#lastNotification) {
this.lastNotification.close(); this.#lastNotification.close();
this.lastNotification = null; this.#lastNotification = null;
} }
// This isn't a boolean because TypeScript isn't smart enough to know that, if // This isn't a boolean because TypeScript isn't smart enough to know that, if
@ -326,7 +324,7 @@ class NotificationService extends EventEmitter {
}notification data` }notification data`
); );
if (isAppFocused) { if (isAppFocused) {
this.notificationData = null; this.#notificationData = null;
} }
return; return;
} }
@ -422,7 +420,7 @@ class NotificationService extends EventEmitter {
log.info('NotificationService: requesting a notification to be shown'); log.info('NotificationService: requesting a notification to be shown');
this.notificationData = { this.#notificationData = {
...notificationData, ...notificationData,
wasShown: true, wasShown: true,
}; };
@ -444,7 +442,7 @@ class NotificationService extends EventEmitter {
public getNotificationSetting(): NotificationSetting { public getNotificationSetting(): NotificationSetting {
return parseNotificationSetting( return parseNotificationSetting(
this.getStorage().get('notification-setting') this.#getStorage().get('notification-setting')
); );
} }
@ -452,8 +450,8 @@ class NotificationService extends EventEmitter {
log.info( log.info(
'NotificationService: clearing notification and requesting an update' 'NotificationService: clearing notification and requesting an update'
); );
this.notificationData = null; this.#notificationData = null;
this.update(); this.#update();
} }
// We don't usually call this, but when the process is shutting down, we should at // We don't usually call this, but when the process is shutting down, we should at
@ -461,8 +459,8 @@ class NotificationService extends EventEmitter {
// normal debounce. // normal debounce.
public fastClear(): void { public fastClear(): void {
log.info('NotificationService: clearing notification and updating'); log.info('NotificationService: clearing notification and updating');
this.notificationData = null; this.#notificationData = null;
this.fastUpdate(); this.#fastUpdate();
} }
public enable(): void { public enable(): void {
@ -470,7 +468,7 @@ class NotificationService extends EventEmitter {
const needUpdate = !this.isEnabled; const needUpdate = !this.isEnabled;
this.isEnabled = true; this.isEnabled = true;
if (needUpdate) { if (needUpdate) {
this.update(); this.#update();
} }
} }

View file

@ -9,9 +9,8 @@ import type { StorageInterface } from '../types/Storage.d';
export class OurProfileKeyService { export class OurProfileKeyService {
private getPromise: undefined | Promise<undefined | Uint8Array>; private getPromise: undefined | Promise<undefined | Uint8Array>;
private promisesBlockingGet: Array<Promise<unknown>> = []; #promisesBlockingGet: Array<Promise<unknown>> = [];
#storage?: StorageInterface;
private storage?: StorageInterface;
initialize(storage: StorageInterface): void { initialize(storage: StorageInterface): void {
log.info('Our profile key service: initializing'); log.info('Our profile key service: initializing');
@ -22,9 +21,9 @@ export class OurProfileKeyService {
}); });
}); });
// eslint-disable-next-line @typescript-eslint/no-floating-promises // eslint-disable-next-line @typescript-eslint/no-floating-promises
this.promisesBlockingGet = [storageReadyPromise]; this.#promisesBlockingGet = [storageReadyPromise];
this.storage = storage; this.#storage = storage;
} }
get(): Promise<undefined | Uint8Array> { get(): Promise<undefined | Uint8Array> {
@ -34,44 +33,44 @@ export class OurProfileKeyService {
); );
} else { } else {
log.info('Our profile key service: kicking off a new fetch'); log.info('Our profile key service: kicking off a new fetch');
this.getPromise = this.doGet(); this.getPromise = this.#doGet();
} }
return this.getPromise; return this.getPromise;
} }
async set(newValue: undefined | Uint8Array): Promise<void> { async set(newValue: undefined | Uint8Array): Promise<void> {
assertDev(this.storage, 'OurProfileKeyService was not initialized'); assertDev(this.#storage, 'OurProfileKeyService was not initialized');
if (newValue != null) { if (newValue != null) {
strictAssert( strictAssert(
newValue.byteLength > 0, newValue.byteLength > 0,
'Our profile key service: Profile key cannot be empty' 'Our profile key service: Profile key cannot be empty'
); );
log.info('Our profile key service: updating profile key'); log.info('Our profile key service: updating profile key');
await this.storage.put('profileKey', newValue); await this.#storage.put('profileKey', newValue);
} else { } else {
log.info('Our profile key service: removing profile key'); log.info('Our profile key service: removing profile key');
await this.storage.remove('profileKey'); await this.#storage.remove('profileKey');
} }
} }
blockGetWithPromise(promise: Promise<unknown>): void { blockGetWithPromise(promise: Promise<unknown>): void {
this.promisesBlockingGet.push(promise); this.#promisesBlockingGet.push(promise);
} }
private async doGet(): Promise<undefined | Uint8Array> { async #doGet(): Promise<undefined | Uint8Array> {
log.info( log.info(
`Our profile key service: waiting for ${this.promisesBlockingGet.length} promises before fetching` `Our profile key service: waiting for ${this.#promisesBlockingGet.length} promises before fetching`
); );
await Promise.allSettled(this.promisesBlockingGet); await Promise.allSettled(this.#promisesBlockingGet);
this.promisesBlockingGet = []; this.#promisesBlockingGet = [];
delete this.getPromise; delete this.getPromise;
assertDev(this.storage, 'OurProfileKeyService was not initialized'); assertDev(this.#storage, 'OurProfileKeyService was not initialized');
log.info('Our profile key service: fetching profile key from storage'); log.info('Our profile key service: fetching profile key from storage');
const result = this.storage.get('profileKey'); const result = this.#storage.get('profileKey');
if (result === undefined || result instanceof Uint8Array) { if (result === undefined || result instanceof Uint8Array) {
return result; return result;
} }

View file

@ -73,15 +73,13 @@ const OBSERVED_CAPABILITY_KEYS = Object.keys({
} satisfies CapabilitiesType) as ReadonlyArray<keyof CapabilitiesType>; } satisfies CapabilitiesType) as ReadonlyArray<keyof CapabilitiesType>;
export class ProfileService { export class ProfileService {
private jobQueue: PQueue; #jobQueue: PQueue;
#jobsByConversationId: Map<string, JobType> = new Map();
private jobsByConversationId: Map<string, JobType> = new Map(); #isPaused = false;
private isPaused = false;
constructor(private fetchProfile = doGetProfile) { constructor(private fetchProfile = doGetProfile) {
this.jobQueue = new PQueue({ concurrency: 3, timeout: MINUTE * 2 }); this.#jobQueue = new PQueue({ concurrency: 3, timeout: MINUTE * 2 });
this.jobsByConversationId = new Map(); this.#jobsByConversationId = new Map();
log.info('Profile Service initialized'); log.info('Profile Service initialized');
} }
@ -102,13 +100,13 @@ export class ProfileService {
return; return;
} }
if (this.isPaused) { if (this.#isPaused) {
throw new Error( throw new Error(
`ProfileService.get: Cannot add job to paused queue for conversation ${preCheckConversation.idForLogging()}` `ProfileService.get: Cannot add job to paused queue for conversation ${preCheckConversation.idForLogging()}`
); );
} }
const existing = this.jobsByConversationId.get(conversationId); const existing = this.#jobsByConversationId.get(conversationId);
if (existing) { if (existing) {
return existing.promise; return existing.promise;
} }
@ -135,7 +133,7 @@ export class ProfileService {
} catch (error) { } catch (error) {
reject(error); reject(error);
if (this.isPaused) { if (this.#isPaused) {
return; return;
} }
@ -149,7 +147,7 @@ export class ProfileService {
} }
} }
} finally { } finally {
this.jobsByConversationId.delete(conversationId); this.#jobsByConversationId.delete(conversationId);
const now = Date.now(); const now = Date.now();
const delta = now - jobData.startTime; const delta = now - jobData.startTime;
@ -158,7 +156,7 @@ export class ProfileService {
`ProfileServices.get: Job for ${conversation.idForLogging()} finished ${delta}ms after queue` `ProfileServices.get: Job for ${conversation.idForLogging()} finished ${delta}ms after queue`
); );
} }
const remainingItems = this.jobQueue.size; const remainingItems = this.#jobQueue.size;
if (remainingItems && remainingItems % 10 === 0) { if (remainingItems && remainingItems % 10 === 0) {
log.info( log.info(
`ProfileServices.get: ${remainingItems} jobs remaining in the queue` `ProfileServices.get: ${remainingItems} jobs remaining in the queue`
@ -167,14 +165,14 @@ export class ProfileService {
} }
}; };
this.jobsByConversationId.set(conversationId, jobData); this.#jobsByConversationId.set(conversationId, jobData);
drop(this.jobQueue.add(job)); drop(this.#jobQueue.add(job));
return promise; return promise;
} }
public clearAll(reason: string): void { public clearAll(reason: string): void {
if (this.isPaused) { if (this.#isPaused) {
log.warn( log.warn(
`ProfileService.clearAll: Already paused; not clearing; reason: '${reason}'` `ProfileService.clearAll: Already paused; not clearing; reason: '${reason}'`
); );
@ -184,10 +182,10 @@ export class ProfileService {
log.info(`ProfileService.clearAll: Clearing; reason: '${reason}'`); log.info(`ProfileService.clearAll: Clearing; reason: '${reason}'`);
try { try {
this.isPaused = true; this.#isPaused = true;
this.jobQueue.pause(); this.#jobQueue.pause();
this.jobsByConversationId.forEach(job => { this.#jobsByConversationId.forEach(job => {
job.reject( job.reject(
new Error( new Error(
`ProfileService.clearAll: job cancelled because '${reason}'` `ProfileService.clearAll: job cancelled because '${reason}'`
@ -195,33 +193,33 @@ export class ProfileService {
); );
}); });
this.jobsByConversationId.clear(); this.#jobsByConversationId.clear();
this.jobQueue.clear(); this.#jobQueue.clear();
this.jobQueue.start(); this.#jobQueue.start();
} finally { } finally {
this.isPaused = false; this.#isPaused = false;
log.info('ProfileService.clearAll: Done clearing'); log.info('ProfileService.clearAll: Done clearing');
} }
} }
public async pause(timeInMS: number): Promise<void> { public async pause(timeInMS: number): Promise<void> {
if (this.isPaused) { if (this.#isPaused) {
log.warn('ProfileService.pause: Already paused, not pausing again.'); log.warn('ProfileService.pause: Already paused, not pausing again.');
return; return;
} }
log.info(`ProfileService.pause: Pausing queue for ${timeInMS}ms`); log.info(`ProfileService.pause: Pausing queue for ${timeInMS}ms`);
this.isPaused = true; this.#isPaused = true;
this.jobQueue.pause(); this.#jobQueue.pause();
try { try {
await sleep(timeInMS); await sleep(timeInMS);
} finally { } finally {
log.info('ProfileService.pause: Restarting queue'); log.info('ProfileService.pause: Restarting queue');
this.jobQueue.start(); this.#jobQueue.start();
this.isPaused = false; this.#isPaused = false;
} }
} }
} }

View file

@ -49,8 +49,8 @@ export type ReleaseNoteType = ReleaseNoteResponseType &
let initComplete = false; let initComplete = false;
export class ReleaseNotesFetcher { export class ReleaseNotesFetcher {
private timeout: NodeJS.Timeout | undefined; #timeout: NodeJS.Timeout | undefined;
private isRunning = false; #isRunning = false;
protected async scheduleUpdateForNow(): Promise<void> { protected async scheduleUpdateForNow(): Promise<void> {
const now = Date.now(); const now = Date.now();
@ -74,11 +74,11 @@ export class ReleaseNotesFetcher {
waitTime = 0; waitTime = 0;
} }
clearTimeoutIfNecessary(this.timeout); clearTimeoutIfNecessary(this.#timeout);
this.timeout = setTimeout(() => this.runWhenOnline(), waitTime); this.#timeout = setTimeout(() => this.#runWhenOnline(), waitTime);
} }
private getOrInitializeVersionWatermark(): string { #getOrInitializeVersionWatermark(): string {
const versionWatermark = window.textsecure.storage.get( const versionWatermark = window.textsecure.storage.get(
VERSION_WATERMARK_STORAGE_KEY VERSION_WATERMARK_STORAGE_KEY
); );
@ -99,7 +99,7 @@ export class ReleaseNotesFetcher {
return currentVersion; return currentVersion;
} }
private async getReleaseNote( async #getReleaseNote(
note: ManifestReleaseNoteType note: ManifestReleaseNoteType
): Promise<ReleaseNoteType | undefined> { ): Promise<ReleaseNoteType | undefined> {
if (!window.textsecure.server) { if (!window.textsecure.server) {
@ -154,7 +154,7 @@ export class ReleaseNotesFetcher {
); );
} }
private async processReleaseNotes( async #processReleaseNotes(
notes: ReadonlyArray<ManifestReleaseNoteType> notes: ReadonlyArray<ManifestReleaseNoteType>
): Promise<void> { ): Promise<void> {
const sortedNotes = [...notes].sort( const sortedNotes = [...notes].sort(
@ -164,7 +164,7 @@ export class ReleaseNotesFetcher {
const hydratedNotes = []; const hydratedNotes = [];
for (const note of sortedNotes) { for (const note of sortedNotes) {
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
hydratedNotes.push(await this.getReleaseNote(note)); hydratedNotes.push(await this.#getReleaseNote(note));
} }
if (!hydratedNotes.length) { if (!hydratedNotes.length) {
log.warn('ReleaseNotesFetcher: No hydrated notes available, stopping'); log.warn('ReleaseNotesFetcher: No hydrated notes available, stopping');
@ -232,22 +232,22 @@ export class ReleaseNotesFetcher {
); );
} }
private async scheduleForNextRun(): Promise<void> { async #scheduleForNextRun(): Promise<void> {
const now = Date.now(); const now = Date.now();
const nextTime = now + FETCH_INTERVAL; const nextTime = now + FETCH_INTERVAL;
await window.textsecure.storage.put(NEXT_FETCH_TIME_STORAGE_KEY, nextTime); await window.textsecure.storage.put(NEXT_FETCH_TIME_STORAGE_KEY, nextTime);
} }
private async run(): Promise<void> { async #run(): Promise<void> {
if (this.isRunning) { if (this.#isRunning) {
log.warn('ReleaseNotesFetcher: Already running, preventing reentrancy'); log.warn('ReleaseNotesFetcher: Already running, preventing reentrancy');
return; return;
} }
this.isRunning = true; this.#isRunning = true;
log.info('ReleaseNotesFetcher: Starting'); log.info('ReleaseNotesFetcher: Starting');
try { try {
const versionWatermark = this.getOrInitializeVersionWatermark(); const versionWatermark = this.#getOrInitializeVersionWatermark();
log.info(`ReleaseNotesFetcher: Version watermark is ${versionWatermark}`); log.info(`ReleaseNotesFetcher: Version watermark is ${versionWatermark}`);
if (!window.textsecure.server) { if (!window.textsecure.server) {
@ -276,7 +276,7 @@ export class ReleaseNotesFetcher {
log.info( log.info(
`ReleaseNotesFetcher: Processing ${validNotes.length} new release notes` `ReleaseNotesFetcher: Processing ${validNotes.length} new release notes`
); );
drop(this.processReleaseNotes(validNotes)); drop(this.#processReleaseNotes(validNotes));
} else { } else {
log.info('ReleaseNotesFetcher: No new release notes'); log.info('ReleaseNotesFetcher: No new release notes');
} }
@ -291,7 +291,7 @@ export class ReleaseNotesFetcher {
log.info('ReleaseNotesFetcher: Manifest hash unchanged'); log.info('ReleaseNotesFetcher: Manifest hash unchanged');
} }
await this.scheduleForNextRun(); await this.#scheduleForNextRun();
this.setTimeoutForNextRun(); this.setTimeoutForNextRun();
} catch (error) { } catch (error) {
const errorString = const errorString =
@ -303,13 +303,13 @@ export class ReleaseNotesFetcher {
); );
setTimeout(() => this.setTimeoutForNextRun(), ERROR_RETRY_DELAY); setTimeout(() => this.setTimeoutForNextRun(), ERROR_RETRY_DELAY);
} finally { } finally {
this.isRunning = false; this.#isRunning = false;
} }
} }
private runWhenOnline() { #runWhenOnline() {
if (window.textsecure.server?.isOnline()) { if (window.textsecure.server?.isOnline()) {
drop(this.run()); drop(this.#run());
} else { } else {
log.info( log.info(
'ReleaseNotesFetcher: We are offline; will fetch when we are next online' 'ReleaseNotesFetcher: We are offline; will fetch when we are next online'

View file

@ -28,16 +28,15 @@ const CLOCK_SKEW_THRESHOLD = 15 * 60 * 1000;
// This is exported for testing. // This is exported for testing.
export class SenderCertificateService { export class SenderCertificateService {
private server?: WebAPIType; #server?: WebAPIType;
private fetchPromises: Map< #fetchPromises: Map<
SenderCertificateMode, SenderCertificateMode,
Promise<undefined | SerializedCertificateType> Promise<undefined | SerializedCertificateType>
> = new Map(); > = new Map();
private events?: Pick<typeof window.Whisper.events, 'on' | 'off'>; #events?: Pick<typeof window.Whisper.events, 'on' | 'off'>;
#storage?: StorageInterface;
private storage?: StorageInterface;
initialize({ initialize({
server, server,
@ -50,15 +49,15 @@ export class SenderCertificateService {
}): void { }): void {
log.info('Sender certificate service initialized'); log.info('Sender certificate service initialized');
this.server = server; this.#server = server;
this.events = events; this.#events = events;
this.storage = storage; this.#storage = storage;
} }
async get( async get(
mode: SenderCertificateMode mode: SenderCertificateMode
): Promise<undefined | SerializedCertificateType> { ): Promise<undefined | SerializedCertificateType> {
const storedCertificate = this.getStoredCertificate(mode); const storedCertificate = this.#getStoredCertificate(mode);
if (storedCertificate) { if (storedCertificate) {
log.info( log.info(
`Sender certificate service found a valid ${modeToLogString( `Sender certificate service found a valid ${modeToLogString(
@ -68,7 +67,7 @@ export class SenderCertificateService {
return storedCertificate; return storedCertificate;
} }
return this.fetchCertificate(mode); return this.#fetchCertificate(mode);
} }
// This is intended to be called when our credentials have been deleted, so any fetches // This is intended to be called when our credentials have been deleted, so any fetches
@ -78,9 +77,9 @@ export class SenderCertificateService {
'Sender certificate service: Clearing in-progress fetches and ' + 'Sender certificate service: Clearing in-progress fetches and ' +
'deleting cached certificates' 'deleting cached certificates'
); );
await Promise.all(this.fetchPromises.values()); await Promise.all(this.#fetchPromises.values());
const { storage } = this; const storage = this.#storage;
assertDev( assertDev(
storage, storage,
'Sender certificate service method was called before it was initialized' 'Sender certificate service method was called before it was initialized'
@ -89,10 +88,10 @@ export class SenderCertificateService {
await storage.remove('senderCertificateNoE164'); await storage.remove('senderCertificateNoE164');
} }
private getStoredCertificate( #getStoredCertificate(
mode: SenderCertificateMode mode: SenderCertificateMode
): undefined | SerializedCertificateType { ): undefined | SerializedCertificateType {
const { storage } = this; const storage = this.#storage;
assertDev( assertDev(
storage, storage,
'Sender certificate service method was called before it was initialized' 'Sender certificate service method was called before it was initialized'
@ -109,11 +108,11 @@ export class SenderCertificateService {
return undefined; return undefined;
} }
private fetchCertificate( #fetchCertificate(
mode: SenderCertificateMode mode: SenderCertificateMode
): Promise<undefined | SerializedCertificateType> { ): Promise<undefined | SerializedCertificateType> {
// This prevents multiple concurrent fetches. // This prevents multiple concurrent fetches.
const existingPromise = this.fetchPromises.get(mode); const existingPromise = this.#fetchPromises.get(mode);
if (existingPromise) { if (existingPromise) {
log.info( log.info(
`Sender certificate service was already fetching a ${modeToLogString( `Sender certificate service was already fetching a ${modeToLogString(
@ -125,28 +124,30 @@ export class SenderCertificateService {
let promise: Promise<undefined | SerializedCertificateType>; let promise: Promise<undefined | SerializedCertificateType>;
const doFetch = async () => { const doFetch = async () => {
const result = await this.fetchAndSaveCertificate(mode); const result = await this.#fetchAndSaveCertificate(mode);
assertDev( assertDev(
this.fetchPromises.get(mode) === promise, this.#fetchPromises.get(mode) === promise,
'Sender certificate service was deleting a different promise than expected' 'Sender certificate service was deleting a different promise than expected'
); );
this.fetchPromises.delete(mode); this.#fetchPromises.delete(mode);
return result; return result;
}; };
promise = doFetch(); promise = doFetch();
assertDev( assertDev(
!this.fetchPromises.has(mode), !this.#fetchPromises.has(mode),
'Sender certificate service somehow already had a promise for this mode' 'Sender certificate service somehow already had a promise for this mode'
); );
this.fetchPromises.set(mode, promise); this.#fetchPromises.set(mode, promise);
return promise; return promise;
} }
private async fetchAndSaveCertificate( async #fetchAndSaveCertificate(
mode: SenderCertificateMode mode: SenderCertificateMode
): Promise<undefined | SerializedCertificateType> { ): Promise<undefined | SerializedCertificateType> {
const { storage, server, events } = this; const storage = this.#storage;
const events = this.#events;
const server = this.#server;
assertDev( assertDev(
storage && server && events, storage && server && events,
'Sender certificate service method was called before it was initialized' 'Sender certificate service method was called before it was initialized'
@ -162,7 +163,7 @@ export class SenderCertificateService {
let certificateString: string; let certificateString: string;
try { try {
certificateString = await this.requestSenderCertificate(mode); certificateString = await this.#requestSenderCertificate(mode);
} catch (err) { } catch (err) {
log.warn( log.warn(
`Sender certificate service could not fetch a ${modeToLogString( `Sender certificate service could not fetch a ${modeToLogString(
@ -198,10 +199,10 @@ export class SenderCertificateService {
return serializedCertificate; return serializedCertificate;
} }
private async requestSenderCertificate( async #requestSenderCertificate(
mode: SenderCertificateMode mode: SenderCertificateMode
): Promise<string> { ): Promise<string> {
const { server } = this; const server = this.#server;
assertDev( assertDev(
server, server,
'Sender certificate service method was called before it was initialized' 'Sender certificate service method was called before it was initialized'

View file

@ -53,15 +53,15 @@ async function eraseTapToViewMessages() {
} }
class TapToViewMessagesDeletionService { class TapToViewMessagesDeletionService {
public update: typeof this.checkTapToViewMessages; public update: () => Promise<void>;
private timeout?: ReturnType<typeof setTimeout>; #timeout?: ReturnType<typeof setTimeout>;
constructor() { constructor() {
this.update = debounce(this.checkTapToViewMessages, 1000); this.update = debounce(this.#checkTapToViewMessages, 1000);
} }
private async checkTapToViewMessages() { async #checkTapToViewMessages() {
const receivedAtMsForOldestTapToViewMessage = const receivedAtMsForOldestTapToViewMessage =
await DataReader.getNextTapToViewMessageTimestampToAgeOut(); await DataReader.getNextTapToViewMessageTimestampToAgeOut();
if (!receivedAtMsForOldestTapToViewMessage) { if (!receivedAtMsForOldestTapToViewMessage) {
@ -87,8 +87,8 @@ class TapToViewMessagesDeletionService {
wait = 2147483647; wait = 2147483647;
} }
clearTimeoutIfNecessary(this.timeout); clearTimeoutIfNecessary(this.#timeout);
this.timeout = setTimeout(async () => { this.#timeout = setTimeout(async () => {
await eraseTapToViewMessages(); await eraseTapToViewMessages();
void this.update(); void this.update();
}, wait); }, wait);

View file

@ -25,20 +25,20 @@ const CHECK_INTERVAL = DAY;
const STORAGE_SERVICE_TIMEOUT = 30 * MINUTE; const STORAGE_SERVICE_TIMEOUT = 30 * MINUTE;
class UsernameIntegrityService { class UsernameIntegrityService {
private isStarted = false; #isStarted = false;
private readonly backOff = new BackOff(FIBONACCI_TIMEOUTS); readonly #backOff = new BackOff(FIBONACCI_TIMEOUTS);
async start(): Promise<void> { async start(): Promise<void> {
if (this.isStarted) { if (this.#isStarted) {
return; return;
} }
this.isStarted = true; this.#isStarted = true;
this.scheduleCheck(); this.#scheduleCheck();
} }
private scheduleCheck(): void { #scheduleCheck(): void {
const lastCheckTimestamp = window.storage.get( const lastCheckTimestamp = window.storage.get(
'usernameLastIntegrityCheck', 'usernameLastIntegrityCheck',
0 0
@ -46,40 +46,40 @@ class UsernameIntegrityService {
const delay = Math.max(0, lastCheckTimestamp + CHECK_INTERVAL - Date.now()); const delay = Math.max(0, lastCheckTimestamp + CHECK_INTERVAL - Date.now());
if (delay === 0) { if (delay === 0) {
log.info('usernameIntegrity: running the check immediately'); log.info('usernameIntegrity: running the check immediately');
drop(this.safeCheck()); drop(this.#safeCheck());
} else { } else {
log.info(`usernameIntegrity: running the check in ${delay}ms`); log.info(`usernameIntegrity: running the check in ${delay}ms`);
setTimeout(() => drop(this.safeCheck()), delay); setTimeout(() => drop(this.#safeCheck()), delay);
} }
} }
private async safeCheck(): Promise<void> { async #safeCheck(): Promise<void> {
try { try {
await storageJobQueue(() => this.check()); await storageJobQueue(() => this.#check());
this.backOff.reset(); this.#backOff.reset();
await window.storage.put('usernameLastIntegrityCheck', Date.now()); await window.storage.put('usernameLastIntegrityCheck', Date.now());
this.scheduleCheck(); this.#scheduleCheck();
} catch (error) { } catch (error) {
const delay = this.backOff.getAndIncrement(); const delay = this.#backOff.getAndIncrement();
log.error( log.error(
'usernameIntegrity: check failed with ' + 'usernameIntegrity: check failed with ' +
`error: ${Errors.toLogFormat(error)} retrying in ${delay}ms` `error: ${Errors.toLogFormat(error)} retrying in ${delay}ms`
); );
setTimeout(() => drop(this.safeCheck()), delay); setTimeout(() => drop(this.#safeCheck()), delay);
} }
} }
private async check(): Promise<void> { async #check(): Promise<void> {
if (!isRegistrationDone()) { if (!isRegistrationDone()) {
return; return;
} }
await this.checkUsername(); await this.#checkUsername();
await this.checkPhoneNumberSharing(); await this.#checkPhoneNumberSharing();
} }
private async checkUsername(): Promise<void> { async #checkUsername(): Promise<void> {
const me = window.ConversationController.getOurConversationOrThrow(); const me = window.ConversationController.getOurConversationOrThrow();
const username = me.get('username'); const username = me.get('username');
if (!username) { if (!username) {
@ -124,7 +124,7 @@ class UsernameIntegrityService {
} }
} }
private async checkPhoneNumberSharing(): Promise<void> { async #checkPhoneNumberSharing(): Promise<void> {
const me = window.ConversationController.getOurConversationOrThrow(); const me = window.ConversationController.getOurConversationOrThrow();
await getProfile({ await getProfile({
@ -150,10 +150,10 @@ class UsernameIntegrityService {
// Since we already run on storage service job queue - don't await the // Since we already run on storage service job queue - don't await the
// promise below (otherwise deadlock will happen). // promise below (otherwise deadlock will happen).
drop(this.fixProfile()); drop(this.#fixProfile());
} }
private async fixProfile(): Promise<void> { async #fixProfile(): Promise<void> {
const { promise: once, resolve } = explodePromise<void>(); const { promise: once, resolve } = explodePromise<void>();
window.Whisper.events.once('storageService:syncComplete', () => resolve()); window.Whisper.events.once('storageService:syncComplete', () => resolve());

View file

@ -116,31 +116,25 @@ export type QueryStatsOptions = {
}; };
export class MainSQL { export class MainSQL {
private readonly pool = new Array<PoolEntry>(); readonly #pool = new Array<PoolEntry>();
#pauseWaiters: Array<() => void> | undefined;
private pauseWaiters: Array<() => void> | undefined; #isReady = false;
#onReady: Promise<void> | undefined;
private isReady = false; readonly #onExit: Promise<unknown>;
private onReady: Promise<void> | undefined;
private readonly onExit: Promise<unknown>;
// Promise resolve callbacks for corruption and readonly errors. // Promise resolve callbacks for corruption and readonly errors.
private errorResolvers = new Array<KnownErrorResolverType>(); #errorResolvers = new Array<KnownErrorResolverType>();
private seq = 0; #seq = 0;
#logger?: LoggerType;
private logger?: LoggerType;
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
private onResponse = new Map<number, PromisePair<any>>(); #onResponse = new Map<number, PromisePair<any>>();
private shouldTimeQueries = false; #shouldTimeQueries = false;
#shouldTrackQueryStats = false;
private shouldTrackQueryStats = false; #queryStats?: {
private queryStats?: {
start: number; start: number;
statsByQuery: Map<string, QueryStatsType>; statsByQuery: Map<string, QueryStatsType>;
}; };
@ -148,12 +142,12 @@ export class MainSQL {
constructor() { constructor() {
const exitPromises = new Array<Promise<void>>(); const exitPromises = new Array<Promise<void>>();
for (let i = 0; i < WORKER_COUNT; i += 1) { for (let i = 0; i < WORKER_COUNT; i += 1) {
const { worker, onExit } = this.createWorker(); const { worker, onExit } = this.#createWorker();
this.pool.push({ worker, load: 0 }); this.#pool.push({ worker, load: 0 });
exitPromises.push(onExit); exitPromises.push(onExit);
} }
this.onExit = Promise.all(exitPromises); this.#onExit = Promise.all(exitPromises);
} }
public async initialize({ public async initialize({
@ -162,19 +156,19 @@ export class MainSQL {
key, key,
logger, logger,
}: InitializeOptions): Promise<void> { }: InitializeOptions): Promise<void> {
if (this.isReady || this.onReady) { if (this.#isReady || this.#onReady) {
throw new Error('Already initialized'); throw new Error('Already initialized');
} }
this.shouldTimeQueries = Boolean(process.env.TIME_QUERIES); this.#shouldTimeQueries = Boolean(process.env.TIME_QUERIES);
this.logger = logger; this.#logger = logger;
this.onReady = (async () => { this.#onReady = (async () => {
const primary = this.pool[0]; const primary = this.#pool[0];
const rest = this.pool.slice(1); const rest = this.#pool.slice(1);
await this.send(primary, { await this.#send(primary, {
type: 'init', type: 'init',
options: { appVersion, configDir, key }, options: { appVersion, configDir, key },
isPrimary: true, isPrimary: true,
@ -182,7 +176,7 @@ export class MainSQL {
await Promise.all( await Promise.all(
rest.map(worker => rest.map(worker =>
this.send(worker, { this.#send(worker, {
type: 'init', type: 'init',
options: { appVersion, configDir, key }, options: { appVersion, configDir, key },
isPrimary: false, isPrimary: false,
@ -191,22 +185,22 @@ export class MainSQL {
); );
})(); })();
await this.onReady; await this.#onReady;
this.onReady = undefined; this.#onReady = undefined;
this.isReady = true; this.#isReady = true;
} }
public pauseWriteAccess(): void { public pauseWriteAccess(): void {
strictAssert(this.pauseWaiters == null, 'Already paused'); strictAssert(this.#pauseWaiters == null, 'Already paused');
this.pauseWaiters = []; this.#pauseWaiters = [];
} }
public resumeWriteAccess(): void { public resumeWriteAccess(): void {
const { pauseWaiters } = this; const pauseWaiters = this.#pauseWaiters;
strictAssert(pauseWaiters != null, 'Not paused'); strictAssert(pauseWaiters != null, 'Not paused');
this.pauseWaiters = undefined; this.#pauseWaiters = undefined;
for (const waiter of pauseWaiters) { for (const waiter of pauseWaiters) {
waiter(); waiter();
@ -215,37 +209,39 @@ export class MainSQL {
public whenCorrupted(): Promise<Error> { public whenCorrupted(): Promise<Error> {
const { promise, resolve } = explodePromise<Error>(); const { promise, resolve } = explodePromise<Error>();
this.errorResolvers.push({ kind: SqliteErrorKind.Corrupted, resolve }); this.#errorResolvers.push({ kind: SqliteErrorKind.Corrupted, resolve });
return promise; return promise;
} }
public whenReadonly(): Promise<Error> { public whenReadonly(): Promise<Error> {
const { promise, resolve } = explodePromise<Error>(); const { promise, resolve } = explodePromise<Error>();
this.errorResolvers.push({ kind: SqliteErrorKind.Readonly, resolve }); this.#errorResolvers.push({ kind: SqliteErrorKind.Readonly, resolve });
return promise; return promise;
} }
public async close(): Promise<void> { public async close(): Promise<void> {
if (this.onReady) { if (this.#onReady) {
try { try {
await this.onReady; await this.#onReady;
} catch (err) { } catch (err) {
this.logger?.error(`MainSQL close, failed: ${Errors.toLogFormat(err)}`); this.#logger?.error(
`MainSQL close, failed: ${Errors.toLogFormat(err)}`
);
// Init failed // Init failed
return; return;
} }
} }
if (!this.isReady) { if (!this.#isReady) {
throw new Error('Not initialized'); throw new Error('Not initialized');
} }
await this.terminate({ type: 'close' }); await this.#terminate({ type: 'close' });
await this.onExit; await this.#onExit;
} }
public async removeDB(): Promise<void> { public async removeDB(): Promise<void> {
await this.terminate({ type: 'removeDB' }); await this.#terminate({ type: 'removeDB' });
} }
public async sqlRead<Method extends keyof ServerReadableDirectInterface>( public async sqlRead<Method extends keyof ServerReadableDirectInterface>(
@ -261,16 +257,16 @@ export class MainSQL {
// the same temporary table. // the same temporary table.
const isPaging = PAGING_QUERIES.has(method); const isPaging = PAGING_QUERIES.has(method);
const entry = isPaging ? this.pool.at(-1) : this.getWorker(); const entry = isPaging ? this.#pool.at(-1) : this.#getWorker();
strictAssert(entry != null, 'Must have a pool entry'); strictAssert(entry != null, 'Must have a pool entry');
const { result, duration } = await this.send<SqlCallResult>(entry, { const { result, duration } = await this.#send<SqlCallResult>(entry, {
type: 'sqlCall:read', type: 'sqlCall:read',
method, method,
args, args,
}); });
this.traceDuration(method, duration); this.#traceDuration(method, duration);
return result; return result;
} }
@ -285,63 +281,63 @@ export class MainSQL {
duration: number; duration: number;
}>; }>;
while (this.pauseWaiters != null) { while (this.#pauseWaiters != null) {
const { promise, resolve } = explodePromise<void>(); const { promise, resolve } = explodePromise<void>();
this.pauseWaiters.push(resolve); this.#pauseWaiters.push(resolve);
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
await promise; await promise;
} }
const primary = this.pool[0]; const primary = this.#pool[0];
const { result, duration } = await this.send<SqlCallResult>(primary, { const { result, duration } = await this.#send<SqlCallResult>(primary, {
type: 'sqlCall:write', type: 'sqlCall:write',
method, method,
args, args,
}); });
this.traceDuration(method, duration); this.#traceDuration(method, duration);
return result; return result;
} }
public startTrackingQueryStats(): void { public startTrackingQueryStats(): void {
if (this.shouldTrackQueryStats) { if (this.#shouldTrackQueryStats) {
this.logQueryStats({}); this.#logQueryStats({});
this.logger?.info('Resetting query stats'); this.#logger?.info('Resetting query stats');
} }
this.resetQueryStats(); this.#resetQueryStats();
this.shouldTrackQueryStats = true; this.#shouldTrackQueryStats = true;
} }
public stopTrackingQueryStats(options: QueryStatsOptions): void { public stopTrackingQueryStats(options: QueryStatsOptions): void {
if (this.shouldTrackQueryStats) { if (this.#shouldTrackQueryStats) {
this.logQueryStats(options); this.#logQueryStats(options);
} }
this.queryStats = undefined; this.#queryStats = undefined;
this.shouldTrackQueryStats = false; this.#shouldTrackQueryStats = false;
} }
private async send<Response>( async #send<Response>(
entry: PoolEntry, entry: PoolEntry,
request: WorkerRequest request: WorkerRequest
): Promise<Response> { ): Promise<Response> {
if (request.type === 'sqlCall:read' || request.type === 'sqlCall:write') { if (request.type === 'sqlCall:read' || request.type === 'sqlCall:write') {
if (this.onReady) { if (this.#onReady) {
await this.onReady; await this.#onReady;
} }
if (!this.isReady) { if (!this.#isReady) {
throw new Error('Not initialized'); throw new Error('Not initialized');
} }
} }
const { seq } = this; const seq = this.#seq;
// eslint-disable-next-line no-bitwise // eslint-disable-next-line no-bitwise
this.seq = (this.seq + 1) >>> 0; this.#seq = (this.#seq + 1) >>> 0;
const { promise: result, resolve, reject } = explodePromise<Response>(); const { promise: result, resolve, reject } = explodePromise<Response>();
this.onResponse.set(seq, { resolve, reject }); this.#onResponse.set(seq, { resolve, reject });
const wrappedRequest: WrappedWorkerRequest = { const wrappedRequest: WrappedWorkerRequest = {
seq, seq,
@ -359,24 +355,24 @@ export class MainSQL {
} }
} }
private async terminate(request: WorkerRequest): Promise<void> { async #terminate(request: WorkerRequest): Promise<void> {
const primary = this.pool[0]; const primary = this.#pool[0];
const rest = this.pool.slice(1); const rest = this.#pool.slice(1);
// Terminate non-primary workers first // Terminate non-primary workers first
await Promise.all(rest.map(worker => this.send(worker, request))); await Promise.all(rest.map(worker => this.#send(worker, request)));
// Primary last // Primary last
await this.send(primary, request); await this.#send(primary, request);
} }
private onError(errorKind: SqliteErrorKind, error: Error): void { #onError(errorKind: SqliteErrorKind, error: Error): void {
if (errorKind === SqliteErrorKind.Unknown) { if (errorKind === SqliteErrorKind.Unknown) {
return; return;
} }
const resolvers = new Array<(error: Error) => void>(); const resolvers = new Array<(error: Error) => void>();
this.errorResolvers = this.errorResolvers.filter(entry => { this.#errorResolvers = this.#errorResolvers.filter(entry => {
if (entry.kind === errorKind) { if (entry.kind === errorKind) {
resolvers.push(entry.resolve); resolvers.push(entry.resolve);
return false; return false;
@ -389,76 +385,73 @@ export class MainSQL {
} }
} }
private resetQueryStats() { #resetQueryStats() {
this.queryStats = { start: Date.now(), statsByQuery: new Map() }; this.#queryStats = { start: Date.now(), statsByQuery: new Map() };
} }
private roundDuration(duration: number): number { #roundDuration(duration: number): number {
return Math.round(100 * duration) / 100; return Math.round(100 * duration) / 100;
} }
private logQueryStats({ #logQueryStats({ maxQueriesToLog = 10, epochName }: QueryStatsOptions) {
maxQueriesToLog = 10, if (!this.#queryStats) {
epochName,
}: QueryStatsOptions) {
if (!this.queryStats) {
return; return;
} }
const epochDuration = Date.now() - this.queryStats.start; const epochDuration = Date.now() - this.#queryStats.start;
const sortedByCumulativeDuration = [ const sortedByCumulativeDuration = [
...this.queryStats.statsByQuery.values(), ...this.#queryStats.statsByQuery.values(),
].sort((a, b) => (b.cumulative ?? 0) - (a.cumulative ?? 0)); ].sort((a, b) => (b.cumulative ?? 0) - (a.cumulative ?? 0));
const cumulativeDuration = sortedByCumulativeDuration.reduce( const cumulativeDuration = sortedByCumulativeDuration.reduce(
(sum, stats) => sum + stats.cumulative, (sum, stats) => sum + stats.cumulative,
0 0
); );
this.logger?.info( this.#logger?.info(
`Top ${maxQueriesToLog} queries by cumulative duration (ms) over last ${epochDuration}ms` + `Top ${maxQueriesToLog} queries by cumulative duration (ms) over last ${epochDuration}ms` +
`${epochName ? ` during '${epochName}'` : ''}: ` + `${epochName ? ` during '${epochName}'` : ''}: ` +
`${sortedByCumulativeDuration `${sortedByCumulativeDuration
.slice(0, maxQueriesToLog) .slice(0, maxQueriesToLog)
.map(stats => { .map(stats => {
return ( return (
`${stats.queryName}: cumulative ${this.roundDuration(stats.cumulative)} | ` + `${stats.queryName}: cumulative ${this.#roundDuration(stats.cumulative)} | ` +
`average: ${this.roundDuration(stats.cumulative / (stats.count || 1))} | ` + `average: ${this.#roundDuration(stats.cumulative / (stats.count || 1))} | ` +
`max: ${this.roundDuration(stats.max)} | ` + `max: ${this.#roundDuration(stats.max)} | ` +
`count: ${stats.count}` `count: ${stats.count}`
); );
}) })
.join(' ||| ')}` + .join(' ||| ')}` +
`; Total cumulative duration of all SQL queries during this epoch: ${this.roundDuration(cumulativeDuration)}ms` `; Total cumulative duration of all SQL queries during this epoch: ${this.#roundDuration(cumulativeDuration)}ms`
); );
} }
private traceDuration(method: string, duration: number): void { #traceDuration(method: string, duration: number): void {
if (this.shouldTrackQueryStats) { if (this.#shouldTrackQueryStats) {
if (!this.queryStats) { if (!this.#queryStats) {
this.resetQueryStats(); this.#resetQueryStats();
} }
strictAssert(this.queryStats, 'has been initialized'); strictAssert(this.#queryStats, 'has been initialized');
let currentStats = this.queryStats.statsByQuery.get(method); let currentStats = this.#queryStats.statsByQuery.get(method);
if (!currentStats) { if (!currentStats) {
currentStats = { count: 0, cumulative: 0, queryName: method, max: 0 }; currentStats = { count: 0, cumulative: 0, queryName: method, max: 0 };
this.queryStats.statsByQuery.set(method, currentStats); this.#queryStats.statsByQuery.set(method, currentStats);
} }
currentStats.count += 1; currentStats.count += 1;
currentStats.cumulative += duration; currentStats.cumulative += duration;
currentStats.max = Math.max(currentStats.max, duration); currentStats.max = Math.max(currentStats.max, duration);
} }
if (this.shouldTimeQueries && !app.isPackaged) { if (this.#shouldTimeQueries && !app.isPackaged) {
const twoDecimals = this.roundDuration(duration); const twoDecimals = this.#roundDuration(duration);
this.logger?.info(`MainSQL query: ${method}, duration=${twoDecimals}ms`); this.#logger?.info(`MainSQL query: ${method}, duration=${twoDecimals}ms`);
} }
if (duration > MIN_TRACE_DURATION) { if (duration > MIN_TRACE_DURATION) {
strictAssert(this.logger !== undefined, 'Logger not initialized'); strictAssert(this.#logger !== undefined, 'Logger not initialized');
this.logger.info( this.#logger.info(
`MainSQL: slow query ${method} duration=${Math.round(duration)}ms` `MainSQL: slow query ${method} duration=${Math.round(duration)}ms`
); );
} }
} }
private createWorker(): CreateWorkerResultType { #createWorker(): CreateWorkerResultType {
const scriptPath = join(app.getAppPath(), 'ts', 'sql', 'mainWorker.js'); const scriptPath = join(app.getAppPath(), 'ts', 'sql', 'mainWorker.js');
const worker = new Worker(scriptPath); const worker = new Worker(scriptPath);
@ -466,15 +459,15 @@ export class MainSQL {
worker.on('message', (wrappedResponse: WrappedWorkerResponse) => { worker.on('message', (wrappedResponse: WrappedWorkerResponse) => {
if (wrappedResponse.type === 'log') { if (wrappedResponse.type === 'log') {
const { level, args } = wrappedResponse; const { level, args } = wrappedResponse;
strictAssert(this.logger !== undefined, 'Logger not initialized'); strictAssert(this.#logger !== undefined, 'Logger not initialized');
this.logger[level](`MainSQL: ${format(...args)}`); this.#logger[level](`MainSQL: ${format(...args)}`);
return; return;
} }
const { seq, error, errorKind, response } = wrappedResponse; const { seq, error, errorKind, response } = wrappedResponse;
const pair = this.onResponse.get(seq); const pair = this.#onResponse.get(seq);
this.onResponse.delete(seq); this.#onResponse.delete(seq);
if (!pair) { if (!pair) {
throw new Error(`Unexpected worker response with seq: ${seq}`); throw new Error(`Unexpected worker response with seq: ${seq}`);
} }
@ -483,7 +476,7 @@ export class MainSQL {
const errorObj = new Error(error.message); const errorObj = new Error(error.message);
errorObj.stack = error.stack; errorObj.stack = error.stack;
errorObj.name = error.name; errorObj.name = error.name;
this.onError(errorKind ?? SqliteErrorKind.Unknown, errorObj); this.#onError(errorKind ?? SqliteErrorKind.Unknown, errorObj);
pair.reject(errorObj); pair.reject(errorObj);
} else { } else {
@ -498,9 +491,9 @@ export class MainSQL {
} }
// Find first pool entry with minimal load // Find first pool entry with minimal load
private getWorker(): PoolEntry { #getWorker(): PoolEntry {
let min = this.pool[0]; let min = this.#pool[0];
for (const entry of this.pool) { for (const entry of this.#pool) {
if (min && min.load < entry.load) { if (min && min.load < entry.load) {
continue; continue;
} }

View file

@ -14,22 +14,21 @@ const COLORS: Array<[number, number, number]> = [
]; ];
class FakeGroupCallVideoFrameSource implements VideoFrameSource { class FakeGroupCallVideoFrameSource implements VideoFrameSource {
private readonly sourceArray: Uint8Array; readonly #sourceArray: Uint8Array;
readonly #dimensions: [number, number];
private readonly dimensions: [number, number];
constructor(width: number, height: number, r: number, g: number, b: number) { constructor(width: number, height: number, r: number, g: number, b: number) {
const length = width * height * 4; const length = width * height * 4;
this.sourceArray = new Uint8Array(length); this.#sourceArray = new Uint8Array(length);
for (let i = 0; i < length; i += 4) { for (let i = 0; i < length; i += 4) {
this.sourceArray[i] = r; this.#sourceArray[i] = r;
this.sourceArray[i + 1] = g; this.#sourceArray[i + 1] = g;
this.sourceArray[i + 2] = b; this.#sourceArray[i + 2] = b;
this.sourceArray[i + 3] = 255; this.#sourceArray[i + 3] = 255;
} }
this.dimensions = [width, height]; this.#dimensions = [width, height];
} }
receiveVideoFrame( receiveVideoFrame(
@ -42,8 +41,8 @@ class FakeGroupCallVideoFrameSource implements VideoFrameSource {
return undefined; return undefined;
} }
destinationBuffer.set(this.sourceArray); destinationBuffer.set(this.#sourceArray);
return this.dimensions; return this.#dimensions;
} }
} }

View file

@ -22,20 +22,20 @@ import { DataWriter } from '../../sql/Client';
const { BACKUP_INTEGRATION_DIR } = process.env; const { BACKUP_INTEGRATION_DIR } = process.env;
class MemoryStream extends InputStream { class MemoryStream extends InputStream {
private offset = 0; #offset = 0;
constructor(private readonly buffer: Buffer) { constructor(private readonly buffer: Buffer) {
super(); super();
} }
public override async read(amount: number): Promise<Buffer> { public override async read(amount: number): Promise<Buffer> {
const result = this.buffer.slice(this.offset, this.offset + amount); const result = this.buffer.slice(this.#offset, this.#offset + amount);
this.offset += amount; this.#offset += amount;
return result; return result;
} }
public override async skip(amount: number): Promise<void> { public override async skip(amount: number): Promise<void> {
this.offset += amount; this.#offset += amount;
} }
} }

View file

@ -171,26 +171,29 @@ export class Bootstrap {
public readonly server: Server; public readonly server: Server;
public readonly cdn3Path: string; public readonly cdn3Path: string;
private readonly options: BootstrapInternalOptions; readonly #options: BootstrapInternalOptions;
private privContacts?: ReadonlyArray<PrimaryDevice>; #privContacts?: ReadonlyArray<PrimaryDevice>;
private privContactsWithoutProfileKey?: ReadonlyArray<PrimaryDevice>; #privContactsWithoutProfileKey?: ReadonlyArray<PrimaryDevice>;
private privUnknownContacts?: ReadonlyArray<PrimaryDevice>; #privUnknownContacts?: ReadonlyArray<PrimaryDevice>;
private privPhone?: PrimaryDevice; #privPhone?: PrimaryDevice;
private privDesktop?: Device; #privDesktop?: Device;
private storagePath?: string; #storagePath?: string;
private timestamp: number = Date.now() - durations.WEEK; #timestamp: number = Date.now() - durations.WEEK;
private lastApp?: App; #lastApp?: App;
private readonly randomId = crypto.randomBytes(8).toString('hex'); readonly #randomId = crypto.randomBytes(8).toString('hex');
constructor(options: BootstrapOptions = {}) { constructor(options: BootstrapOptions = {}) {
this.cdn3Path = path.join(os.tmpdir(), `mock-signal-cdn3-${this.randomId}`); this.cdn3Path = path.join(
os.tmpdir(),
`mock-signal-cdn3-${this.#randomId}`
);
this.server = new Server({ this.server = new Server({
// Limit number of storage read keys for easier testing // Limit number of storage read keys for easier testing
maxStorageReadKeys: MAX_STORAGE_READ_KEYS, maxStorageReadKeys: MAX_STORAGE_READ_KEYS,
cdn3Path: this.cdn3Path, cdn3Path: this.cdn3Path,
}); });
this.options = { this.#options = {
linkedDevices: 5, linkedDevices: 5,
contactCount: CONTACT_COUNT, contactCount: CONTACT_COUNT,
contactsWithoutProfileKey: 0, contactsWithoutProfileKey: 0,
@ -202,10 +205,10 @@ export class Bootstrap {
}; };
const totalContactCount = const totalContactCount =
this.options.contactCount + this.#options.contactCount +
this.options.contactsWithoutProfileKey + this.#options.contactsWithoutProfileKey +
this.options.unknownContactCount; this.#options.unknownContactCount;
assert(totalContactCount <= this.options.contactNames.length); assert(totalContactCount <= this.#options.contactNames.length);
assert(totalContactCount <= MAX_CONTACTS); assert(totalContactCount <= MAX_CONTACTS);
} }
@ -218,19 +221,19 @@ export class Bootstrap {
debug('started server on port=%d', port); debug('started server on port=%d', port);
const totalContactCount = const totalContactCount =
this.options.contactCount + this.#options.contactCount +
this.options.contactsWithoutProfileKey + this.#options.contactsWithoutProfileKey +
this.options.unknownContactCount; this.#options.unknownContactCount;
const allContacts = await Promise.all( const allContacts = await Promise.all(
this.options.contactNames this.#options.contactNames
.slice(0, totalContactCount) .slice(0, totalContactCount)
.map(async profileName => { .map(async profileName => {
const primary = await this.server.createPrimaryDevice({ const primary = await this.server.createPrimaryDevice({
profileName, profileName,
}); });
for (let i = 0; i < this.options.linkedDevices; i += 1) { for (let i = 0; i < this.#options.linkedDevices; i += 1) {
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
await this.server.createSecondaryDevice(primary); await this.server.createSecondaryDevice(primary);
} }
@ -239,28 +242,30 @@ export class Bootstrap {
}) })
); );
this.privContacts = allContacts.splice(0, this.options.contactCount); this.#privContacts = allContacts.splice(0, this.#options.contactCount);
this.privContactsWithoutProfileKey = allContacts.splice( this.#privContactsWithoutProfileKey = allContacts.splice(
0, 0,
this.options.contactsWithoutProfileKey this.#options.contactsWithoutProfileKey
); );
this.privUnknownContacts = allContacts.splice( this.#privUnknownContacts = allContacts.splice(
0, 0,
this.options.unknownContactCount this.#options.unknownContactCount
); );
this.privPhone = await this.server.createPrimaryDevice({ this.#privPhone = await this.server.createPrimaryDevice({
profileName: 'Myself', profileName: 'Myself',
contacts: this.contacts, contacts: this.contacts,
contactsWithoutProfileKey: this.contactsWithoutProfileKey, contactsWithoutProfileKey: this.contactsWithoutProfileKey,
}); });
if (this.options.useLegacyStorageEncryption) { if (this.#options.useLegacyStorageEncryption) {
this.privPhone.storageRecordIkm = undefined; this.#privPhone.storageRecordIkm = undefined;
} }
this.storagePath = await fs.mkdtemp(path.join(os.tmpdir(), 'mock-signal-')); this.#storagePath = await fs.mkdtemp(
path.join(os.tmpdir(), 'mock-signal-')
);
debug('setting storage path=%j', this.storagePath); debug('setting storage path=%j', this.#storagePath);
} }
public static benchmark( public static benchmark(
@ -272,34 +277,36 @@ export class Bootstrap {
public get logsDir(): string { public get logsDir(): string {
assert( assert(
this.storagePath !== undefined, this.#storagePath !== undefined,
'Bootstrap has to be initialized first, see: bootstrap.init()' 'Bootstrap has to be initialized first, see: bootstrap.init()'
); );
return path.join(this.storagePath, 'logs'); return path.join(this.#storagePath, 'logs');
} }
public get ephemeralConfigPath(): string { public get ephemeralConfigPath(): string {
assert( assert(
this.storagePath !== undefined, this.#storagePath !== undefined,
'Bootstrap has to be initialized first, see: bootstrap.init()' 'Bootstrap has to be initialized first, see: bootstrap.init()'
); );
return path.join(this.storagePath, 'ephemeral.json'); return path.join(this.#storagePath, 'ephemeral.json');
} }
public eraseStorage(): Promise<void> { public eraseStorage(): Promise<void> {
return this.resetAppStorage(); return this.#resetAppStorage();
} }
private async resetAppStorage(): Promise<void> { async #resetAppStorage(): Promise<void> {
assert( assert(
this.storagePath !== undefined, this.#storagePath !== undefined,
'Bootstrap has to be initialized first, see: bootstrap.init()' 'Bootstrap has to be initialized first, see: bootstrap.init()'
); );
await fs.rm(this.storagePath, { recursive: true }); await fs.rm(this.#storagePath, { recursive: true });
this.storagePath = await fs.mkdtemp(path.join(os.tmpdir(), 'mock-signal-')); this.#storagePath = await fs.mkdtemp(
path.join(os.tmpdir(), 'mock-signal-')
);
} }
public async teardown(): Promise<void> { public async teardown(): Promise<void> {
@ -307,11 +314,11 @@ export class Bootstrap {
await Promise.race([ await Promise.race([
Promise.all([ Promise.all([
...[this.storagePath, this.cdn3Path].map(tmpPath => ...[this.#storagePath, this.cdn3Path].map(tmpPath =>
tmpPath ? fs.rm(tmpPath, { recursive: true }) : Promise.resolve() tmpPath ? fs.rm(tmpPath, { recursive: true }) : Promise.resolve()
), ),
this.server.close(), this.server.close(),
this.lastApp?.close(), this.#lastApp?.close(),
]), ]),
new Promise(resolve => setTimeout(resolve, CLOSE_TIMEOUT).unref()), new Promise(resolve => setTimeout(resolve, CLOSE_TIMEOUT).unref()),
]); ]);
@ -346,7 +353,7 @@ export class Bootstrap {
const provisionURL = await app.waitForProvisionURL(); const provisionURL = await app.waitForProvisionURL();
debug('completing provision'); debug('completing provision');
this.privDesktop = await provision.complete({ this.#privDesktop = await provision.complete({
provisionURL, provisionURL,
primaryDevice: this.phone, primaryDevice: this.phone,
}); });
@ -388,7 +395,7 @@ export class Bootstrap {
extraConfig?: Partial<RendererConfigType> extraConfig?: Partial<RendererConfigType>
): Promise<App> { ): Promise<App> {
assert( assert(
this.storagePath !== undefined, this.#storagePath !== undefined,
'Bootstrap has to be initialized first, see: bootstrap.init()' 'Bootstrap has to be initialized first, see: bootstrap.init()'
); );
@ -408,7 +415,7 @@ export class Bootstrap {
} }
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
const config = await this.generateConfig(port, family, extraConfig); const config = await this.#generateConfig(port, family, extraConfig);
const startedApp = new App({ const startedApp = new App({
main: ELECTRON, main: ELECTRON,
@ -427,14 +434,14 @@ export class Bootstrap {
); );
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
await this.resetAppStorage(); await this.#resetAppStorage();
continue; continue;
} }
this.lastApp = startedApp; this.#lastApp = startedApp;
startedApp.on('close', () => { startedApp.on('close', () => {
if (this.lastApp === startedApp) { if (this.#lastApp === startedApp) {
this.lastApp = undefined; this.#lastApp = undefined;
} }
}); });
@ -445,14 +452,14 @@ export class Bootstrap {
} }
public getTimestamp(): number { public getTimestamp(): number {
const result = this.timestamp; const result = this.#timestamp;
this.timestamp += 1; this.#timestamp += 1;
return result; return result;
} }
public async maybeSaveLogs( public async maybeSaveLogs(
test?: Mocha.Runnable, test?: Mocha.Runnable,
app: App | undefined = this.lastApp app: App | undefined = this.#lastApp
): Promise<void> { ): Promise<void> {
const { FORCE_ARTIFACT_SAVE } = process.env; const { FORCE_ARTIFACT_SAVE } = process.env;
if (test?.state !== 'passed' || FORCE_ARTIFACT_SAVE) { if (test?.state !== 'passed' || FORCE_ARTIFACT_SAVE) {
@ -461,10 +468,10 @@ export class Bootstrap {
} }
public async saveLogs( public async saveLogs(
app: App | undefined = this.lastApp, app: App | undefined = this.#lastApp,
testName?: string testName?: string
): Promise<void> { ): Promise<void> {
const outDir = await this.getArtifactsDir(testName); const outDir = await this.#getArtifactsDir(testName);
if (outDir == null) { if (outDir == null) {
return; return;
} }
@ -544,7 +551,7 @@ export class Bootstrap {
`screenshot difference for ${name}: ${numPixels}/${width * height}` `screenshot difference for ${name}: ${numPixels}/${width * height}`
); );
const outDir = await this.getArtifactsDir(test?.fullTitle()); const outDir = await this.#getArtifactsDir(test?.fullTitle());
if (outDir != null) { if (outDir != null) {
debug('saving screenshots and diff'); debug('saving screenshots and diff');
const prefix = `${index}-${sanitizePathComponent(name)}`; const prefix = `${index}-${sanitizePathComponent(name)}`;
@ -565,8 +572,8 @@ export class Bootstrap {
} }
public getAbsoluteAttachmentPath(relativePath: string): string { public getAbsoluteAttachmentPath(relativePath: string): string {
strictAssert(this.storagePath, 'storagePath must exist'); strictAssert(this.#storagePath, 'storagePath must exist');
return join(this.storagePath, 'attachments.noindex', relativePath); return join(this.#storagePath, 'attachments.noindex', relativePath);
} }
public async storeAttachmentOnCDN( public async storeAttachmentOnCDN(
@ -607,41 +614,41 @@ export class Bootstrap {
public get phone(): PrimaryDevice { public get phone(): PrimaryDevice {
assert( assert(
this.privPhone, this.#privPhone,
'Bootstrap has to be initialized first, see: bootstrap.init()' 'Bootstrap has to be initialized first, see: bootstrap.init()'
); );
return this.privPhone; return this.#privPhone;
} }
public get desktop(): Device { public get desktop(): Device {
assert( assert(
this.privDesktop, this.#privDesktop,
'Bootstrap has to be linked first, see: bootstrap.link()' 'Bootstrap has to be linked first, see: bootstrap.link()'
); );
return this.privDesktop; return this.#privDesktop;
} }
public get contacts(): ReadonlyArray<PrimaryDevice> { public get contacts(): ReadonlyArray<PrimaryDevice> {
assert( assert(
this.privContacts, this.#privContacts,
'Bootstrap has to be initialized first, see: bootstrap.init()' 'Bootstrap has to be initialized first, see: bootstrap.init()'
); );
return this.privContacts; return this.#privContacts;
} }
public get contactsWithoutProfileKey(): ReadonlyArray<PrimaryDevice> { public get contactsWithoutProfileKey(): ReadonlyArray<PrimaryDevice> {
assert( assert(
this.privContactsWithoutProfileKey, this.#privContactsWithoutProfileKey,
'Bootstrap has to be initialized first, see: bootstrap.init()' 'Bootstrap has to be initialized first, see: bootstrap.init()'
); );
return this.privContactsWithoutProfileKey; return this.#privContactsWithoutProfileKey;
} }
public get unknownContacts(): ReadonlyArray<PrimaryDevice> { public get unknownContacts(): ReadonlyArray<PrimaryDevice> {
assert( assert(
this.privUnknownContacts, this.#privUnknownContacts,
'Bootstrap has to be initialized first, see: bootstrap.init()' 'Bootstrap has to be initialized first, see: bootstrap.init()'
); );
return this.privUnknownContacts; return this.#privUnknownContacts;
} }
public get allContacts(): ReadonlyArray<PrimaryDevice> { public get allContacts(): ReadonlyArray<PrimaryDevice> {
@ -656,9 +663,7 @@ export class Bootstrap {
// Private // Private
// //
private async getArtifactsDir( async #getArtifactsDir(testName?: string): Promise<string | undefined> {
testName?: string
): Promise<string | undefined> {
const { ARTIFACTS_DIR } = process.env; const { ARTIFACTS_DIR } = process.env;
if (!ARTIFACTS_DIR) { if (!ARTIFACTS_DIR) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
@ -669,8 +674,8 @@ export class Bootstrap {
} }
const normalizedPath = testName const normalizedPath = testName
? `${this.randomId}-${sanitizePathComponent(testName)}` ? `${this.#randomId}-${sanitizePathComponent(testName)}`
: this.randomId; : this.#randomId;
const outDir = path.join(ARTIFACTS_DIR, normalizedPath); const outDir = path.join(ARTIFACTS_DIR, normalizedPath);
await fs.mkdir(outDir, { recursive: true }); await fs.mkdir(outDir, { recursive: true });
@ -701,7 +706,7 @@ export class Bootstrap {
} }
} }
private async generateConfig( async #generateConfig(
port: number, port: number,
family: string, family: string,
extraConfig?: Partial<RendererConfigType> extraConfig?: Partial<RendererConfigType>
@ -712,11 +717,11 @@ export class Bootstrap {
return JSON.stringify({ return JSON.stringify({
...(await loadCertificates()), ...(await loadCertificates()),
forcePreloadBundle: this.options.benchmark, forcePreloadBundle: this.#options.benchmark,
ciMode: 'full', ciMode: 'full',
buildExpiration: Date.now() + durations.MONTH, buildExpiration: Date.now() + durations.MONTH,
storagePath: this.storagePath, storagePath: this.#storagePath,
storageProfile: 'mock', storageProfile: 'mock',
serverUrl: url, serverUrl: url,
storageUrl: url, storageUrl: url,

View file

@ -47,7 +47,7 @@ export type AppOptionsType = Readonly<{
const WAIT_FOR_EVENT_TIMEOUT = 30 * SECOND; const WAIT_FOR_EVENT_TIMEOUT = 30 * SECOND;
export class App extends EventEmitter { export class App extends EventEmitter {
private privApp: ElectronApplication | undefined; #privApp: ElectronApplication | undefined;
constructor(private readonly options: AppOptionsType) { constructor(private readonly options: AppOptionsType) {
super(); super();
@ -56,7 +56,7 @@ export class App extends EventEmitter {
public async start(): Promise<void> { public async start(): Promise<void> {
try { try {
// launch the electron processs // launch the electron processs
this.privApp = await electron.launch({ this.#privApp = await electron.launch({
executablePath: this.options.main, executablePath: this.options.main,
args: this.options.args.slice(), args: this.options.args.slice(),
env: { env: {
@ -84,53 +84,53 @@ export class App extends EventEmitter {
20 * SECOND 20 * SECOND
); );
} catch (e) { } catch (e) {
this.privApp?.process().kill('SIGKILL'); this.#privApp?.process().kill('SIGKILL');
throw e; throw e;
} }
this.privApp.on('close', () => this.emit('close')); this.#privApp.on('close', () => this.emit('close'));
drop(this.printLoop()); drop(this.#printLoop());
} }
public async waitForProvisionURL(): Promise<string> { public async waitForProvisionURL(): Promise<string> {
return this.waitForEvent('provisioning-url'); return this.#waitForEvent('provisioning-url');
} }
public async waitForDbInitialized(): Promise<void> { public async waitForDbInitialized(): Promise<void> {
return this.waitForEvent('db-initialized'); return this.#waitForEvent('db-initialized');
} }
public async waitUntilLoaded(): Promise<AppLoadedInfoType> { public async waitUntilLoaded(): Promise<AppLoadedInfoType> {
return this.waitForEvent('app-loaded'); return this.#waitForEvent('app-loaded');
} }
public async waitForContactSync(): Promise<void> { public async waitForContactSync(): Promise<void> {
return this.waitForEvent('contactSync'); return this.#waitForEvent('contactSync');
} }
public async waitForBackupImportComplete(): Promise<{ duration: number }> { public async waitForBackupImportComplete(): Promise<{ duration: number }> {
return this.waitForEvent('backupImportComplete'); return this.#waitForEvent('backupImportComplete');
} }
public async waitForMessageSend(): Promise<MessageSendInfoType> { public async waitForMessageSend(): Promise<MessageSendInfoType> {
return this.waitForEvent('message:send-complete'); return this.#waitForEvent('message:send-complete');
} }
public async waitForConversationOpen(): Promise<ConversationOpenInfoType> { public async waitForConversationOpen(): Promise<ConversationOpenInfoType> {
return this.waitForEvent('conversation:open'); return this.#waitForEvent('conversation:open');
} }
public async waitForChallenge(): Promise<ChallengeRequestType> { public async waitForChallenge(): Promise<ChallengeRequestType> {
return this.waitForEvent('challenge'); return this.#waitForEvent('challenge');
} }
public async waitForReceipts(): Promise<ReceiptsInfoType> { public async waitForReceipts(): Promise<ReceiptsInfoType> {
return this.waitForEvent('receipts'); return this.#waitForEvent('receipts');
} }
public async waitForStorageService(): Promise<StorageServiceInfoType> { public async waitForStorageService(): Promise<StorageServiceInfoType> {
return this.waitForEvent('storageServiceComplete'); return this.#waitForEvent('storageServiceComplete');
} }
public async waitForManifestVersion(version: number): Promise<void> { public async waitForManifestVersion(version: number): Promise<void> {
@ -152,28 +152,28 @@ export class App extends EventEmitter {
); );
} }
private async checkForFatalTestErrors(): Promise<void> { async #checkForFatalTestErrors(): Promise<void> {
const count = await this.getPendingEventCount('fatalTestError'); const count = await this.getPendingEventCount('fatalTestError');
if (count === 0) { if (count === 0) {
return; return;
} }
for (let i = 0; i < count; i += 1) { for (let i = 0; i < count; i += 1) {
// eslint-disable-next-line no-await-in-loop, no-console // eslint-disable-next-line no-await-in-loop, no-console
console.error(await this.waitForEvent('fatalTestError')); console.error(await this.#waitForEvent('fatalTestError'));
} }
throw new Error('App had fatal test errors'); throw new Error('App had fatal test errors');
} }
public async close(): Promise<void> { public async close(): Promise<void> {
try { try {
await this.checkForFatalTestErrors(); await this.#checkForFatalTestErrors();
} finally { } finally {
await this.app.close(); await this.#app.close();
} }
} }
public async getWindow(): Promise<Page> { public async getWindow(): Promise<Page> {
return this.app.firstWindow(); return this.#app.firstWindow();
} }
public async openSignalRoute(url: URL | string): Promise<void> { public async openSignalRoute(url: URL | string): Promise<void> {
@ -206,11 +206,11 @@ export class App extends EventEmitter {
} }
public async waitForUnlink(): Promise<void> { public async waitForUnlink(): Promise<void> {
return this.waitForEvent('unlinkCleanupComplete'); return this.#waitForEvent('unlinkCleanupComplete');
} }
public async waitForConversationOpenComplete(): Promise<void> { public async waitForConversationOpenComplete(): Promise<void> {
return this.waitForEvent('conversationOpenComplete'); return this.#waitForEvent('conversationOpenComplete');
} }
// EventEmitter types // EventEmitter types
@ -245,7 +245,7 @@ export class App extends EventEmitter {
// Private // Private
// //
private async waitForEvent<T>( async #waitForEvent<T>(
event: string, event: string,
timeout = WAIT_FOR_EVENT_TIMEOUT timeout = WAIT_FOR_EVENT_TIMEOUT
): Promise<T> { ): Promise<T> {
@ -259,15 +259,15 @@ export class App extends EventEmitter {
return result as T; return result as T;
} }
private get app(): ElectronApplication { get #app(): ElectronApplication {
if (!this.privApp) { if (!this.#privApp) {
throw new Error('Call ElectronWrap.start() first'); throw new Error('Call ElectronWrap.start() first');
} }
return this.privApp; return this.#privApp;
} }
private async printLoop(): Promise<void> { async #printLoop(): Promise<void> {
const kClosed: unique symbol = Symbol('kClosed'); const kClosed: unique symbol = Symbol('kClosed');
const onClose = (async (): Promise<typeof kClosed> => { const onClose = (async (): Promise<typeof kClosed> => {
try { try {
@ -283,7 +283,7 @@ export class App extends EventEmitter {
try { try {
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
const value = await Promise.race([ const value = await Promise.race([
this.waitForEvent<string>('print', 0), this.#waitForEvent<string>('print', 0),
onClose, onClose,
]); ]);

View file

@ -9,31 +9,31 @@ import { PreventDisplaySleepService } from '../../../app/PreventDisplaySleepServ
describe('PreventDisplaySleepService', () => { describe('PreventDisplaySleepService', () => {
class FakePowerSaveBlocker implements PowerSaveBlocker { class FakePowerSaveBlocker implements PowerSaveBlocker {
private nextId = 0; #nextId = 0;
private idsStarted = new Set<number>(); #idsStarted = new Set<number>();
isStarted(id: number): boolean { isStarted(id: number): boolean {
return this.idsStarted.has(id); return this.#idsStarted.has(id);
} }
start(type: 'prevent-app-suspension' | 'prevent-display-sleep'): number { start(type: 'prevent-app-suspension' | 'prevent-display-sleep'): number {
assert.strictEqual(type, 'prevent-display-sleep'); assert.strictEqual(type, 'prevent-display-sleep');
const result = this.nextId; const result = this.#nextId;
this.nextId += 1; this.#nextId += 1;
this.idsStarted.add(result); this.#idsStarted.add(result);
return result; return result;
} }
stop(id: number): boolean { stop(id: number): boolean {
assert(this.idsStarted.has(id), `${id} was never started`); assert(this.#idsStarted.has(id), `${id} was never started`);
this.idsStarted.delete(id); this.#idsStarted.delete(id);
return false; return false;
} }
// This is only for testing. // This is only for testing.
_idCount(): number { _idCount(): number {
return this.idsStarted.size; return this.#idsStarted.size;
} }
} }

View file

@ -733,18 +733,18 @@ describe('JobQueue', () => {
}); });
class FakeStream implements AsyncIterable<StoredJob> { class FakeStream implements AsyncIterable<StoredJob> {
private eventEmitter = new EventEmitter(); #eventEmitter = new EventEmitter();
async *[Symbol.asyncIterator]() { async *[Symbol.asyncIterator]() {
while (true) { while (true) {
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
const [job] = await once(this.eventEmitter, 'drip'); const [job] = await once(this.#eventEmitter, 'drip');
yield parseUnknown(storedJobSchema, job as unknown); yield parseUnknown(storedJobSchema, job as unknown);
} }
} }
drip(job: Readonly<StoredJob>): void { drip(job: Readonly<StoredJob>): void {
this.eventEmitter.emit('drip', job); this.#eventEmitter.emit('drip', job);
} }
} }

View file

@ -13,9 +13,8 @@ import { drop } from '../../util/drop';
export class TestJobQueueStore implements JobQueueStore { export class TestJobQueueStore implements JobQueueStore {
events = new EventEmitter(); events = new EventEmitter();
private openStreams = new Set<string>(); #openStreams = new Set<string>();
#pipes = new Map<string, Pipe>();
private pipes = new Map<string, Pipe>();
storedJobs: Array<StoredJob> = []; storedJobs: Array<StoredJob> = [];
@ -41,7 +40,7 @@ export class TestJobQueueStore implements JobQueueStore {
this.storedJobs.push(job); this.storedJobs.push(job);
} }
this.getPipe(job.queueType).add(job); this.#getPipe(job.queueType).add(job);
this.events.emit('insert'); this.events.emit('insert');
} }
@ -55,78 +54,75 @@ export class TestJobQueueStore implements JobQueueStore {
} }
stream(queueType: string): Pipe { stream(queueType: string): Pipe {
if (this.openStreams.has(queueType)) { if (this.#openStreams.has(queueType)) {
throw new Error('Cannot stream the same queueType more than once'); throw new Error('Cannot stream the same queueType more than once');
} }
this.openStreams.add(queueType); this.#openStreams.add(queueType);
return this.getPipe(queueType); return this.#getPipe(queueType);
} }
pauseStream(queueType: string): void { pauseStream(queueType: string): void {
return this.getPipe(queueType).pause(); return this.#getPipe(queueType).pause();
} }
resumeStream(queueType: string): void { resumeStream(queueType: string): void {
return this.getPipe(queueType).resume(); return this.#getPipe(queueType).resume();
} }
private getPipe(queueType: string): Pipe { #getPipe(queueType: string): Pipe {
const existingPipe = this.pipes.get(queueType); const existingPipe = this.#pipes.get(queueType);
if (existingPipe) { if (existingPipe) {
return existingPipe; return existingPipe;
} }
const result = new Pipe(); const result = new Pipe();
this.pipes.set(queueType, result); this.#pipes.set(queueType, result);
return result; return result;
} }
} }
class Pipe implements AsyncIterable<StoredJob> { class Pipe implements AsyncIterable<StoredJob> {
private queue: Array<StoredJob> = []; #queue: Array<StoredJob> = [];
#eventEmitter = new EventEmitter();
private eventEmitter = new EventEmitter(); #isLocked = false;
#isPaused = false;
private isLocked = false;
private isPaused = false;
add(value: Readonly<StoredJob>) { add(value: Readonly<StoredJob>) {
this.queue.push(value); this.#queue.push(value);
this.eventEmitter.emit('add'); this.#eventEmitter.emit('add');
} }
async *[Symbol.asyncIterator]() { async *[Symbol.asyncIterator]() {
if (this.isLocked) { if (this.#isLocked) {
throw new Error('Cannot iterate over a pipe more than once'); throw new Error('Cannot iterate over a pipe more than once');
} }
this.isLocked = true; this.#isLocked = true;
while (true) { while (true) {
for (const value of this.queue) { for (const value of this.#queue) {
await this.waitForUnpaused(); await this.#waitForUnpaused();
yield value; yield value;
} }
this.queue = []; this.#queue = [];
// We do this because we want to yield values in series. // We do this because we want to yield values in series.
await once(this.eventEmitter, 'add'); await once(this.#eventEmitter, 'add');
} }
} }
pause(): void { pause(): void {
this.isPaused = true; this.#isPaused = true;
} }
resume(): void { resume(): void {
this.isPaused = false; this.#isPaused = false;
this.eventEmitter.emit('resume'); this.#eventEmitter.emit('resume');
} }
private async waitForUnpaused() { async #waitForUnpaused() {
if (this.isPaused) { if (this.#isPaused) {
await once(this.eventEmitter, 'resume'); await once(this.#eventEmitter, 'resume');
} }
} }
} }

View file

@ -242,7 +242,7 @@ export default class AccountManager extends EventTarget {
this.pending = Promise.resolve(); this.pending = Promise.resolve();
} }
private async queueTask<T>(task: () => Promise<T>): Promise<T> { async #queueTask<T>(task: () => Promise<T>): Promise<T> {
this.pendingQueue = this.pendingQueue || new PQueue({ concurrency: 1 }); this.pendingQueue = this.pendingQueue || new PQueue({ concurrency: 1 });
const taskWithTimeout = createTaskWithTimeout(task, 'AccountManager task'); const taskWithTimeout = createTaskWithTimeout(task, 'AccountManager task');
@ -328,7 +328,7 @@ export default class AccountManager extends EventTarget {
verificationCode: string, verificationCode: string,
sessionId: string sessionId: string
): Promise<void> { ): Promise<void> {
await this.queueTask(async () => { await this.#queueTask(async () => {
const aciKeyPair = generateKeyPair(); const aciKeyPair = generateKeyPair();
const pniKeyPair = generateKeyPair(); const pniKeyPair = generateKeyPair();
const profileKey = getRandomBytes(PROFILE_KEY_LENGTH); const profileKey = getRandomBytes(PROFILE_KEY_LENGTH);
@ -337,7 +337,7 @@ export default class AccountManager extends EventTarget {
const accountEntropyPool = AccountEntropyPool.generate(); const accountEntropyPool = AccountEntropyPool.generate();
const mediaRootBackupKey = BackupKey.generateRandom().serialize(); const mediaRootBackupKey = BackupKey.generateRandom().serialize();
await this.createAccount({ await this.#createAccount({
type: AccountType.Primary, type: AccountType.Primary,
number, number,
verificationCode, verificationCode,
@ -358,12 +358,12 @@ export default class AccountManager extends EventTarget {
async registerSecondDevice( async registerSecondDevice(
options: CreateLinkedDeviceOptionsType options: CreateLinkedDeviceOptionsType
): Promise<void> { ): Promise<void> {
await this.queueTask(async () => { await this.#queueTask(async () => {
await this.createAccount(options); await this.#createAccount(options);
}); });
} }
private getIdentityKeyOrThrow(ourServiceId: ServiceIdString): KeyPairType { #getIdentityKeyOrThrow(ourServiceId: ServiceIdString): KeyPairType {
const { storage } = window.textsecure; const { storage } = window.textsecure;
const store = storage.protocol; const store = storage.protocol;
let identityKey: KeyPairType | undefined; let identityKey: KeyPairType | undefined;
@ -383,7 +383,7 @@ export default class AccountManager extends EventTarget {
return identityKey; return identityKey;
} }
private async generateNewPreKeys( async #generateNewPreKeys(
serviceIdKind: ServiceIdKind, serviceIdKind: ServiceIdKind,
count = PRE_KEY_GEN_BATCH_SIZE count = PRE_KEY_GEN_BATCH_SIZE
): Promise<Array<UploadPreKeyType>> { ): Promise<Array<UploadPreKeyType>> {
@ -418,7 +418,7 @@ export default class AccountManager extends EventTarget {
})); }));
} }
private async generateNewKyberPreKeys( async #generateNewKyberPreKeys(
serviceIdKind: ServiceIdKind, serviceIdKind: ServiceIdKind,
count = PRE_KEY_GEN_BATCH_SIZE count = PRE_KEY_GEN_BATCH_SIZE
): Promise<Array<UploadKyberPreKeyType>> { ): Promise<Array<UploadKyberPreKeyType>> {
@ -436,7 +436,7 @@ export default class AccountManager extends EventTarget {
} }
const ourServiceId = storage.user.getCheckedServiceId(serviceIdKind); const ourServiceId = storage.user.getCheckedServiceId(serviceIdKind);
const identityKey = this.getIdentityKeyOrThrow(ourServiceId); const identityKey = this.#getIdentityKeyOrThrow(ourServiceId);
const toSave: Array<Omit<KyberPreKeyType, 'id'>> = []; const toSave: Array<Omit<KyberPreKeyType, 'id'>> = [];
const toUpload: Array<UploadKyberPreKeyType> = []; const toUpload: Array<UploadKyberPreKeyType> = [];
@ -471,13 +471,13 @@ export default class AccountManager extends EventTarget {
forceUpdate = false forceUpdate = false
): Promise<void> { ): Promise<void> {
const logId = `maybeUpdateKeys(${serviceIdKind})`; const logId = `maybeUpdateKeys(${serviceIdKind})`;
await this.queueTask(async () => { await this.#queueTask(async () => {
const { storage } = window.textsecure; const { storage } = window.textsecure;
let identityKey: KeyPairType; let identityKey: KeyPairType;
try { try {
const ourServiceId = storage.user.getCheckedServiceId(serviceIdKind); const ourServiceId = storage.user.getCheckedServiceId(serviceIdKind);
identityKey = this.getIdentityKeyOrThrow(ourServiceId); identityKey = this.#getIdentityKeyOrThrow(ourServiceId);
} catch (error) { } catch (error) {
if (serviceIdKind === ServiceIdKind.PNI) { if (serviceIdKind === ServiceIdKind.PNI) {
log.info( log.info(
@ -506,7 +506,7 @@ export default class AccountManager extends EventTarget {
log.info( log.info(
`${logId}: Server prekey count is ${preKeyCount}, generating a new set` `${logId}: Server prekey count is ${preKeyCount}, generating a new set`
); );
preKeys = await this.generateNewPreKeys(serviceIdKind); preKeys = await this.#generateNewPreKeys(serviceIdKind);
} }
let pqPreKeys: Array<UploadKyberPreKeyType> | undefined; let pqPreKeys: Array<UploadKyberPreKeyType> | undefined;
@ -518,14 +518,14 @@ export default class AccountManager extends EventTarget {
log.info( log.info(
`${logId}: Server kyber prekey count is ${kyberPreKeyCount}, generating a new set` `${logId}: Server kyber prekey count is ${kyberPreKeyCount}, generating a new set`
); );
pqPreKeys = await this.generateNewKyberPreKeys(serviceIdKind); pqPreKeys = await this.#generateNewKyberPreKeys(serviceIdKind);
} }
const pqLastResortPreKey = await this.maybeUpdateLastResortKyberKey( const pqLastResortPreKey = await this.#maybeUpdateLastResortKyberKey(
serviceIdKind, serviceIdKind,
forceUpdate forceUpdate
); );
const signedPreKey = await this.maybeUpdateSignedPreKey( const signedPreKey = await this.#maybeUpdateSignedPreKey(
serviceIdKind, serviceIdKind,
forceUpdate forceUpdate
); );
@ -601,7 +601,7 @@ export default class AccountManager extends EventTarget {
return false; return false;
} }
private async generateSignedPreKey( async #generateSignedPreKey(
serviceIdKind: ServiceIdKind, serviceIdKind: ServiceIdKind,
identityKey: KeyPairType identityKey: KeyPairType
): Promise<CompatSignedPreKeyType> { ): Promise<CompatSignedPreKeyType> {
@ -625,13 +625,13 @@ export default class AccountManager extends EventTarget {
return key; return key;
} }
private async maybeUpdateSignedPreKey( async #maybeUpdateSignedPreKey(
serviceIdKind: ServiceIdKind, serviceIdKind: ServiceIdKind,
forceUpdate = false forceUpdate = false
): Promise<UploadSignedPreKeyType | undefined> { ): Promise<UploadSignedPreKeyType | undefined> {
const ourServiceId = const ourServiceId =
window.textsecure.storage.user.getCheckedServiceId(serviceIdKind); window.textsecure.storage.user.getCheckedServiceId(serviceIdKind);
const identityKey = this.getIdentityKeyOrThrow(ourServiceId); const identityKey = this.#getIdentityKeyOrThrow(ourServiceId);
const logId = `AccountManager.maybeUpdateSignedPreKey(${serviceIdKind}, ${ourServiceId})`; const logId = `AccountManager.maybeUpdateSignedPreKey(${serviceIdKind}, ${ourServiceId})`;
const store = window.textsecure.storage.protocol; const store = window.textsecure.storage.protocol;
@ -662,7 +662,7 @@ export default class AccountManager extends EventTarget {
return; return;
} }
const key = await this.generateSignedPreKey(serviceIdKind, identityKey); const key = await this.#generateSignedPreKey(serviceIdKind, identityKey);
log.info(`${logId}: Saving new signed prekey`, key.keyId); log.info(`${logId}: Saving new signed prekey`, key.keyId);
await store.storeSignedPreKey(ourServiceId, key.keyId, key.keyPair); await store.storeSignedPreKey(ourServiceId, key.keyId, key.keyPair);
@ -670,7 +670,7 @@ export default class AccountManager extends EventTarget {
return signedPreKeyToUploadSignedPreKey(key); return signedPreKeyToUploadSignedPreKey(key);
} }
private async generateLastResortKyberKey( async #generateLastResortKyberKey(
serviceIdKind: ServiceIdKind, serviceIdKind: ServiceIdKind,
identityKey: KeyPairType identityKey: KeyPairType
): Promise<KyberPreKeyRecord> { ): Promise<KyberPreKeyRecord> {
@ -695,13 +695,13 @@ export default class AccountManager extends EventTarget {
return record; return record;
} }
private async maybeUpdateLastResortKyberKey( async #maybeUpdateLastResortKyberKey(
serviceIdKind: ServiceIdKind, serviceIdKind: ServiceIdKind,
forceUpdate = false forceUpdate = false
): Promise<UploadSignedPreKeyType | undefined> { ): Promise<UploadSignedPreKeyType | undefined> {
const ourServiceId = const ourServiceId =
window.textsecure.storage.user.getCheckedServiceId(serviceIdKind); window.textsecure.storage.user.getCheckedServiceId(serviceIdKind);
const identityKey = this.getIdentityKeyOrThrow(ourServiceId); const identityKey = this.#getIdentityKeyOrThrow(ourServiceId);
const logId = `maybeUpdateLastResortKyberKey(${serviceIdKind}, ${ourServiceId})`; const logId = `maybeUpdateLastResortKyberKey(${serviceIdKind}, ${ourServiceId})`;
const store = window.textsecure.storage.protocol; const store = window.textsecure.storage.protocol;
@ -732,7 +732,7 @@ export default class AccountManager extends EventTarget {
return; return;
} }
const record = await this.generateLastResortKyberKey( const record = await this.#generateLastResortKyberKey(
serviceIdKind, serviceIdKind,
identityKey identityKey
); );
@ -912,22 +912,18 @@ export default class AccountManager extends EventTarget {
} }
} }
private async createAccount( async #createAccount(options: CreateAccountOptionsType): Promise<void> {
options: CreateAccountOptionsType
): Promise<void> {
this.dispatchEvent(new Event('startRegistration')); this.dispatchEvent(new Event('startRegistration'));
const registrationBaton = this.server.startRegistration(); const registrationBaton = this.server.startRegistration();
try { try {
await this.doCreateAccount(options); await this.#doCreateAccount(options);
} finally { } finally {
this.server.finishRegistration(registrationBaton); this.server.finishRegistration(registrationBaton);
} }
await this.registrationDone(); await this.#registrationDone();
} }
private async doCreateAccount( async #doCreateAccount(options: CreateAccountOptionsType): Promise<void> {
options: CreateAccountOptionsType
): Promise<void> {
const { const {
number, number,
verificationCode, verificationCode,
@ -1032,19 +1028,19 @@ export default class AccountManager extends EventTarget {
let ourPni: PniString; let ourPni: PniString;
let deviceId: number; let deviceId: number;
const aciPqLastResortPreKey = await this.generateLastResortKyberKey( const aciPqLastResortPreKey = await this.#generateLastResortKyberKey(
ServiceIdKind.ACI, ServiceIdKind.ACI,
aciKeyPair aciKeyPair
); );
const pniPqLastResortPreKey = await this.generateLastResortKyberKey( const pniPqLastResortPreKey = await this.#generateLastResortKyberKey(
ServiceIdKind.PNI, ServiceIdKind.PNI,
pniKeyPair pniKeyPair
); );
const aciSignedPreKey = await this.generateSignedPreKey( const aciSignedPreKey = await this.#generateSignedPreKey(
ServiceIdKind.ACI, ServiceIdKind.ACI,
aciKeyPair aciKeyPair
); );
const pniSignedPreKey = await this.generateSignedPreKey( const pniSignedPreKey = await this.#generateSignedPreKey(
ServiceIdKind.PNI, ServiceIdKind.PNI,
pniKeyPair pniKeyPair
); );
@ -1333,8 +1329,8 @@ export default class AccountManager extends EventTarget {
window.textsecure.storage.user.getCheckedServiceId(serviceIdKind); window.textsecure.storage.user.getCheckedServiceId(serviceIdKind);
const logId = `AccountManager.generateKeys(${serviceIdKind}, ${ourServiceId})`; const logId = `AccountManager.generateKeys(${serviceIdKind}, ${ourServiceId})`;
const preKeys = await this.generateNewPreKeys(serviceIdKind, count); const preKeys = await this.#generateNewPreKeys(serviceIdKind, count);
const pqPreKeys = await this.generateNewKyberPreKeys(serviceIdKind, count); const pqPreKeys = await this.#generateNewKyberPreKeys(serviceIdKind, count);
log.info( log.info(
`${logId}: Generated ` + `${logId}: Generated ` +
@ -1347,13 +1343,13 @@ export default class AccountManager extends EventTarget {
await this._cleanKyberPreKeys(serviceIdKind); await this._cleanKyberPreKeys(serviceIdKind);
return { return {
identityKey: this.getIdentityKeyOrThrow(ourServiceId).pubKey, identityKey: this.#getIdentityKeyOrThrow(ourServiceId).pubKey,
preKeys, preKeys,
pqPreKeys, pqPreKeys,
}; };
} }
private async registrationDone(): Promise<void> { async #registrationDone(): Promise<void> {
log.info('registration done'); log.info('registration done');
this.dispatchEvent(new Event('registration')); this.dispatchEvent(new Event('registration'));
} }

View file

@ -79,7 +79,7 @@ export class ParseContactsTransform extends Transform {
public contacts: Array<ContactDetailsWithAvatar> = []; public contacts: Array<ContactDetailsWithAvatar> = [];
public activeContact: Proto.ContactDetails | undefined; public activeContact: Proto.ContactDetails | undefined;
private unused: Uint8Array | undefined; #unused: Uint8Array | undefined;
override async _transform( override async _transform(
chunk: Buffer | undefined, chunk: Buffer | undefined,
@ -93,9 +93,9 @@ export class ParseContactsTransform extends Transform {
try { try {
let data = chunk; let data = chunk;
if (this.unused) { if (this.#unused) {
data = Buffer.concat([this.unused, data]); data = Buffer.concat([this.#unused, data]);
this.unused = undefined; this.#unused = undefined;
} }
const reader = Reader.create(data); const reader = Reader.create(data);
@ -110,7 +110,7 @@ export class ParseContactsTransform extends Transform {
if (err instanceof RangeError) { if (err instanceof RangeError) {
// Note: A failed decodeDelimited() does in fact update reader.pos, so we // Note: A failed decodeDelimited() does in fact update reader.pos, so we
// must reset to startPos // must reset to startPos
this.unused = data.subarray(startPos); this.#unused = data.subarray(startPos);
done(); done();
return; return;
} }
@ -174,7 +174,7 @@ export class ParseContactsTransform extends Transform {
} else { } else {
// We have an attachment, but we haven't read enough data yet. We need to // We have an attachment, but we haven't read enough data yet. We need to
// wait for another chunk. // wait for another chunk.
this.unused = data.subarray(reader.pos); this.#unused = data.subarray(reader.pos);
done(); done();
return; return;
} }

File diff suppressed because it is too large Load diff

View file

@ -82,27 +82,26 @@ export type ProvisionerOptionsType = Readonly<{
const INACTIVE_SOCKET_TIMEOUT = 30 * MINUTE; const INACTIVE_SOCKET_TIMEOUT = 30 * MINUTE;
export class Provisioner { export class Provisioner {
private readonly cipher = new ProvisioningCipher(); readonly #cipher = new ProvisioningCipher();
private readonly server: WebAPIType; readonly #server: WebAPIType;
private readonly appVersion: string; readonly #appVersion: string;
#state: StateType = { step: Step.Idle };
private state: StateType = { step: Step.Idle }; #wsr: IWebSocketResource | undefined;
private wsr: IWebSocketResource | undefined;
constructor(options: ProvisionerOptionsType) { constructor(options: ProvisionerOptionsType) {
this.server = options.server; this.#server = options.server;
this.appVersion = options.appVersion; this.#appVersion = options.appVersion;
} }
public close(error = new Error('Provisioner closed')): void { public close(error = new Error('Provisioner closed')): void {
try { try {
this.wsr?.close(); this.#wsr?.close();
} catch { } catch {
// Best effort // Best effort
} }
const prevState = this.state; const prevState = this.#state;
this.state = { step: Step.Done }; this.#state = { step: Step.Done };
if (prevState.step === Step.WaitingForURL) { if (prevState.step === Step.WaitingForURL) {
prevState.url.reject(error); prevState.url.reject(error);
@ -113,15 +112,15 @@ export class Provisioner {
public async getURL(): Promise<string> { public async getURL(): Promise<string> {
strictAssert( strictAssert(
this.state.step === Step.Idle, this.#state.step === Step.Idle,
`Invalid state for getURL: ${this.state.step}` `Invalid state for getURL: ${this.#state.step}`
); );
this.state = { step: Step.Connecting }; this.#state = { step: Step.Connecting };
const wsr = await this.server.getProvisioningResource({ const wsr = await this.#server.getProvisioningResource({
handleRequest: (request: IncomingWebSocketRequest) => { handleRequest: (request: IncomingWebSocketRequest) => {
try { try {
this.handleRequest(request); this.#handleRequest(request);
} catch (error) { } catch (error) {
log.error( log.error(
'Provisioner.handleRequest: failure', 'Provisioner.handleRequest: failure',
@ -131,7 +130,7 @@ export class Provisioner {
} }
}, },
}); });
this.wsr = wsr; this.#wsr = wsr;
let inactiveTimer: NodeJS.Timeout | undefined; let inactiveTimer: NodeJS.Timeout | undefined;
@ -159,12 +158,12 @@ export class Provisioner {
document.addEventListener('visibilitychange', onVisibilityChange); document.addEventListener('visibilitychange', onVisibilityChange);
if (this.state.step !== Step.Connecting) { if (this.#state.step !== Step.Connecting) {
this.close(); this.close();
throw new Error('Provisioner closed early'); throw new Error('Provisioner closed early');
} }
this.state = { this.#state = {
step: Step.WaitingForURL, step: Step.WaitingForURL,
url: explodePromise(), url: explodePromise(),
}; };
@ -177,7 +176,7 @@ export class Provisioner {
} }
inactiveTimer = undefined; inactiveTimer = undefined;
if (this.state.step === Step.ReadyToLink) { if (this.#state.step === Step.ReadyToLink) {
// WebSocket close is not an issue since we no longer need it // WebSocket close is not an issue since we no longer need it
return; return;
} }
@ -186,15 +185,15 @@ export class Provisioner {
this.close(new Error('websocket closed')); this.close(new Error('websocket closed'));
}); });
return this.state.url.promise; return this.#state.url.promise;
} }
public async waitForEnvelope(): Promise<void> { public async waitForEnvelope(): Promise<void> {
strictAssert( strictAssert(
this.state.step === Step.WaitingForEnvelope, this.#state.step === Step.WaitingForEnvelope,
`Invalid state for waitForEnvelope: ${this.state.step}` `Invalid state for waitForEnvelope: ${this.#state.step}`
); );
await this.state.done.promise; await this.#state.done.promise;
} }
public prepareLinkData({ public prepareLinkData({
@ -202,11 +201,11 @@ export class Provisioner {
backupFile, backupFile,
}: PrepareLinkDataOptionsType): CreateLinkedDeviceOptionsType { }: PrepareLinkDataOptionsType): CreateLinkedDeviceOptionsType {
strictAssert( strictAssert(
this.state.step === Step.ReadyToLink, this.#state.step === Step.ReadyToLink,
`Invalid state for prepareLinkData: ${this.state.step}` `Invalid state for prepareLinkData: ${this.#state.step}`
); );
const { envelope } = this.state; const { envelope } = this.#state;
this.state = { step: Step.Done }; this.#state = { step: Step.Done };
const { const {
number, number,
@ -273,31 +272,31 @@ export class Provisioner {
public isLinkAndSync(): boolean { public isLinkAndSync(): boolean {
strictAssert( strictAssert(
this.state.step === Step.ReadyToLink, this.#state.step === Step.ReadyToLink,
`Invalid state for prepareLinkData: ${this.state.step}` `Invalid state for prepareLinkData: ${this.#state.step}`
); );
const { envelope } = this.state; const { envelope } = this.#state;
return ( return (
isLinkAndSyncEnabled(this.appVersion) && isLinkAndSyncEnabled(this.#appVersion) &&
Bytes.isNotEmpty(envelope.ephemeralBackupKey) Bytes.isNotEmpty(envelope.ephemeralBackupKey)
); );
} }
private handleRequest(request: IncomingWebSocketRequest): void { #handleRequest(request: IncomingWebSocketRequest): void {
const pubKey = this.cipher.getPublicKey(); const pubKey = this.#cipher.getPublicKey();
if ( if (
request.requestType === ServerRequestType.ProvisioningAddress && request.requestType === ServerRequestType.ProvisioningAddress &&
request.body request.body
) { ) {
strictAssert( strictAssert(
this.state.step === Step.WaitingForURL, this.#state.step === Step.WaitingForURL,
`Unexpected provisioning address, state: ${this.state}` `Unexpected provisioning address, state: ${this.#state}`
); );
const prevState = this.state; const prevState = this.#state;
this.state = { step: Step.WaitingForEnvelope, done: explodePromise() }; this.#state = { step: Step.WaitingForEnvelope, done: explodePromise() };
const proto = Proto.ProvisioningUuid.decode(request.body); const proto = Proto.ProvisioningUuid.decode(request.body);
const { uuid } = proto; const { uuid } = proto;
@ -307,7 +306,9 @@ export class Provisioner {
.toAppUrl({ .toAppUrl({
uuid, uuid,
pubKey: Bytes.toBase64(pubKey), pubKey: Bytes.toBase64(pubKey),
capabilities: isLinkAndSyncEnabled(this.appVersion) ? ['backup'] : [], capabilities: isLinkAndSyncEnabled(this.#appVersion)
? ['backup']
: [],
}) })
.toString(); .toString();
@ -320,17 +321,17 @@ export class Provisioner {
request.body request.body
) { ) {
strictAssert( strictAssert(
this.state.step === Step.WaitingForEnvelope, this.#state.step === Step.WaitingForEnvelope,
`Unexpected provisioning address, state: ${this.state}` `Unexpected provisioning address, state: ${this.#state}`
); );
const prevState = this.state; const prevState = this.#state;
const ciphertext = Proto.ProvisionEnvelope.decode(request.body); const ciphertext = Proto.ProvisionEnvelope.decode(request.body);
const message = this.cipher.decrypt(ciphertext); const message = this.#cipher.decrypt(ciphertext);
this.state = { step: Step.ReadyToLink, envelope: message }; this.#state = { step: Step.ReadyToLink, envelope: message };
request.respond(200, 'OK'); request.respond(200, 'OK');
this.wsr?.close(); this.#wsr?.close();
prevState.done.resolve(); prevState.done.resolve();
} else { } else {

View file

@ -1967,7 +1967,7 @@ export default class MessageSender {
options?: Readonly<SendOptionsType>; options?: Readonly<SendOptionsType>;
}> }>
): Promise<CallbackResultType> { ): Promise<CallbackResultType> {
return this.sendReceiptMessage({ return this.#sendReceiptMessage({
...options, ...options,
type: Proto.ReceiptMessage.Type.DELIVERY, type: Proto.ReceiptMessage.Type.DELIVERY,
}); });
@ -1981,7 +1981,7 @@ export default class MessageSender {
options?: Readonly<SendOptionsType>; options?: Readonly<SendOptionsType>;
}> }>
): Promise<CallbackResultType> { ): Promise<CallbackResultType> {
return this.sendReceiptMessage({ return this.#sendReceiptMessage({
...options, ...options,
type: Proto.ReceiptMessage.Type.READ, type: Proto.ReceiptMessage.Type.READ,
}); });
@ -1995,13 +1995,13 @@ export default class MessageSender {
options?: Readonly<SendOptionsType>; options?: Readonly<SendOptionsType>;
}> }>
): Promise<CallbackResultType> { ): Promise<CallbackResultType> {
return this.sendReceiptMessage({ return this.#sendReceiptMessage({
...options, ...options,
type: Proto.ReceiptMessage.Type.VIEWED, type: Proto.ReceiptMessage.Type.VIEWED,
}); });
} }
private async sendReceiptMessage({ async #sendReceiptMessage({
senderAci, senderAci,
timestamps, timestamps,
type, type,

View file

@ -83,37 +83,24 @@ export type SocketManagerOptions = Readonly<{
// Incoming requests on unauthenticated resource are not currently supported. // Incoming requests on unauthenticated resource are not currently supported.
// IWebSocketResource is responsible for their immediate termination. // IWebSocketResource is responsible for their immediate termination.
export class SocketManager extends EventListener { export class SocketManager extends EventListener {
private backOff = new BackOff(FIBONACCI_TIMEOUTS, { #backOff = new BackOff(FIBONACCI_TIMEOUTS, {
jitter: JITTER, jitter: JITTER,
}); });
private authenticated?: AbortableProcess<IWebSocketResource>; #authenticated?: AbortableProcess<IWebSocketResource>;
#unauthenticated?: AbortableProcess<IWebSocketResource>;
private unauthenticated?: AbortableProcess<IWebSocketResource>; #unauthenticatedExpirationTimer?: NodeJS.Timeout;
#credentials?: WebAPICredentials;
private unauthenticatedExpirationTimer?: NodeJS.Timeout; #lazyProxyAgent?: Promise<ProxyAgent>;
#status = SocketStatus.CLOSED;
private credentials?: WebAPICredentials; #requestHandlers = new Set<IRequestHandler>();
#incomingRequestQueue = new Array<IncomingWebSocketRequest>();
private lazyProxyAgent?: Promise<ProxyAgent>; #isNavigatorOffline = false;
#privIsOnline: boolean | undefined;
private status = SocketStatus.CLOSED; #isRemotelyExpired = false;
#hasStoriesDisabled: boolean;
private requestHandlers = new Set<IRequestHandler>(); #reconnectController: AbortController | undefined;
#envelopeCount = 0;
private incomingRequestQueue = new Array<IncomingWebSocketRequest>();
private isNavigatorOffline = false;
private privIsOnline: boolean | undefined;
private isRemotelyExpired = false;
private hasStoriesDisabled: boolean;
private reconnectController: AbortController | undefined;
private envelopeCount = 0;
constructor( constructor(
private readonly libsignalNet: Net.Net, private readonly libsignalNet: Net.Net,
@ -121,16 +108,16 @@ export class SocketManager extends EventListener {
) { ) {
super(); super();
this.hasStoriesDisabled = options.hasStoriesDisabled; this.#hasStoriesDisabled = options.hasStoriesDisabled;
} }
public getStatus(): SocketStatus { public getStatus(): SocketStatus {
return this.status; return this.#status;
} }
private markOffline() { #markOffline() {
if (this.privIsOnline !== false) { if (this.#privIsOnline !== false) {
this.privIsOnline = false; this.#privIsOnline = false;
this.emit('offline'); this.emit('offline');
} }
} }
@ -138,7 +125,7 @@ export class SocketManager extends EventListener {
// Update WebAPICredentials and reconnect authenticated resource if // Update WebAPICredentials and reconnect authenticated resource if
// credentials changed // credentials changed
public async authenticate(credentials: WebAPICredentials): Promise<void> { public async authenticate(credentials: WebAPICredentials): Promise<void> {
if (this.isRemotelyExpired) { if (this.#isRemotelyExpired) {
throw new HTTPError('SocketManager remotely expired', { throw new HTTPError('SocketManager remotely expired', {
code: 0, code: 0,
headers: {}, headers: {},
@ -153,13 +140,13 @@ export class SocketManager extends EventListener {
} }
if ( if (
this.credentials && this.#credentials &&
this.credentials.username === username && this.#credentials.username === username &&
this.credentials.password === password && this.#credentials.password === password &&
this.authenticated this.#authenticated
) { ) {
try { try {
await this.authenticated.getResult(); await this.#authenticated.getResult();
} catch (error) { } catch (error) {
log.warn( log.warn(
'SocketManager: failed to wait for existing authenticated socket ' + 'SocketManager: failed to wait for existing authenticated socket ' +
@ -169,61 +156,61 @@ export class SocketManager extends EventListener {
return; return;
} }
this.credentials = credentials; this.#credentials = credentials;
log.info( log.info(
'SocketManager: connecting authenticated socket ' + 'SocketManager: connecting authenticated socket ' +
`(hasStoriesDisabled=${this.hasStoriesDisabled})` `(hasStoriesDisabled=${this.#hasStoriesDisabled})`
); );
this.setStatus(SocketStatus.CONNECTING); this.#setStatus(SocketStatus.CONNECTING);
const proxyAgent = await this.getProxyAgent(); const proxyAgent = await this.#getProxyAgent();
const useLibsignalTransport = const useLibsignalTransport =
window.Signal.RemoteConfig.isEnabled( window.Signal.RemoteConfig.isEnabled(
'desktop.experimentalTransport.enableAuth' 'desktop.experimentalTransport.enableAuth'
) && this.transportOption(proxyAgent) === TransportOption.Libsignal; ) && this.#transportOption(proxyAgent) === TransportOption.Libsignal;
const process = useLibsignalTransport const process = useLibsignalTransport
? connectAuthenticatedLibsignal({ ? connectAuthenticatedLibsignal({
libsignalNet: this.libsignalNet, libsignalNet: this.libsignalNet,
name: AUTHENTICATED_CHANNEL_NAME, name: AUTHENTICATED_CHANNEL_NAME,
credentials: this.credentials, credentials: this.#credentials,
handler: (req: IncomingWebSocketRequest): void => { handler: (req: IncomingWebSocketRequest): void => {
this.queueOrHandleRequest(req); this.#queueOrHandleRequest(req);
}, },
receiveStories: !this.hasStoriesDisabled, receiveStories: !this.#hasStoriesDisabled,
keepalive: { path: '/v1/keepalive' }, keepalive: { path: '/v1/keepalive' },
}) })
: this.connectResource({ : this.#connectResource({
name: AUTHENTICATED_CHANNEL_NAME, name: AUTHENTICATED_CHANNEL_NAME,
path: '/v1/websocket/', path: '/v1/websocket/',
resourceOptions: { resourceOptions: {
name: AUTHENTICATED_CHANNEL_NAME, name: AUTHENTICATED_CHANNEL_NAME,
keepalive: { path: '/v1/keepalive' }, keepalive: { path: '/v1/keepalive' },
handleRequest: (req: IncomingWebSocketRequest): void => { handleRequest: (req: IncomingWebSocketRequest): void => {
this.queueOrHandleRequest(req); this.#queueOrHandleRequest(req);
}, },
}, },
extraHeaders: { extraHeaders: {
Authorization: getBasicAuth({ username, password }), Authorization: getBasicAuth({ username, password }),
'X-Signal-Receive-Stories': String(!this.hasStoriesDisabled), 'X-Signal-Receive-Stories': String(!this.#hasStoriesDisabled),
}, },
proxyAgent, proxyAgent,
}); });
// Cancel previous connect attempt or close socket // Cancel previous connect attempt or close socket
this.authenticated?.abort(); this.#authenticated?.abort();
this.authenticated = process; this.#authenticated = process;
const reconnect = async (): Promise<void> => { const reconnect = async (): Promise<void> => {
if (this.isRemotelyExpired) { if (this.#isRemotelyExpired) {
log.info('SocketManager: remotely expired, not reconnecting'); log.info('SocketManager: remotely expired, not reconnecting');
return; return;
} }
const timeout = this.backOff.getAndIncrement(); const timeout = this.#backOff.getAndIncrement();
log.info( log.info(
'SocketManager: reconnecting authenticated socket ' + 'SocketManager: reconnecting authenticated socket ' +
@ -231,7 +218,7 @@ export class SocketManager extends EventListener {
); );
const reconnectController = new AbortController(); const reconnectController = new AbortController();
this.reconnectController = reconnectController; this.#reconnectController = reconnectController;
try { try {
await sleep(timeout, reconnectController.signal); await sleep(timeout, reconnectController.signal);
@ -239,20 +226,20 @@ export class SocketManager extends EventListener {
log.info('SocketManager: reconnect cancelled'); log.info('SocketManager: reconnect cancelled');
return; return;
} finally { } finally {
if (this.reconnectController === reconnectController) { if (this.#reconnectController === reconnectController) {
this.reconnectController = undefined; this.#reconnectController = undefined;
} }
} }
if (this.authenticated) { if (this.#authenticated) {
log.info('SocketManager: authenticated socket already connecting'); log.info('SocketManager: authenticated socket already connecting');
return; return;
} }
strictAssert(this.credentials !== undefined, 'Missing credentials'); strictAssert(this.#credentials !== undefined, 'Missing credentials');
try { try {
await this.authenticate(this.credentials); await this.authenticate(this.#credentials);
} catch (error) { } catch (error) {
log.info( log.info(
'SocketManager: authenticated socket failed to reconnect ' + 'SocketManager: authenticated socket failed to reconnect ' +
@ -265,7 +252,7 @@ export class SocketManager extends EventListener {
let authenticated: IWebSocketResource; let authenticated: IWebSocketResource;
try { try {
authenticated = await process.getResult(); authenticated = await process.getResult();
this.setStatus(SocketStatus.OPEN); this.#setStatus(SocketStatus.OPEN);
} catch (error) { } catch (error) {
log.warn( log.warn(
'SocketManager: authenticated socket connection failed with ' + 'SocketManager: authenticated socket connection failed with ' +
@ -273,11 +260,11 @@ export class SocketManager extends EventListener {
); );
// The socket was deliberately closed, don't follow up // The socket was deliberately closed, don't follow up
if (this.authenticated !== process) { if (this.#authenticated !== process) {
return; return;
} }
this.dropAuthenticated(process); this.#dropAuthenticated(process);
if (error instanceof HTTPError) { if (error instanceof HTTPError) {
const { code } = error; const { code } = error;
@ -293,10 +280,10 @@ export class SocketManager extends EventListener {
} }
if (code === -1) { if (code === -1) {
this.markOffline(); this.#markOffline();
} }
} else if (error instanceof ConnectTimeoutError) { } else if (error instanceof ConnectTimeoutError) {
this.markOffline(); this.#markOffline();
} else if ( } else if (
error instanceof LibSignalErrorBase && error instanceof LibSignalErrorBase &&
error.code === ErrorCode.DeviceDelinked error.code === ErrorCode.DeviceDelinked
@ -320,11 +307,11 @@ export class SocketManager extends EventListener {
); );
window.logAuthenticatedConnect?.(); window.logAuthenticatedConnect?.();
this.envelopeCount = 0; this.#envelopeCount = 0;
this.backOff.reset(); this.#backOff.reset();
authenticated.addEventListener('close', ({ code, reason }): void => { authenticated.addEventListener('close', ({ code, reason }): void => {
if (this.authenticated !== process) { if (this.#authenticated !== process) {
return; return;
} }
@ -332,7 +319,7 @@ export class SocketManager extends EventListener {
'SocketManager: authenticated socket closed ' + 'SocketManager: authenticated socket closed ' +
`with code=${code} and reason=${reason}` `with code=${code} and reason=${reason}`
); );
this.dropAuthenticated(process); this.#dropAuthenticated(process);
if (code === NORMAL_DISCONNECT_CODE) { if (code === NORMAL_DISCONNECT_CODE) {
// Intentional disconnect // Intentional disconnect
@ -351,27 +338,27 @@ export class SocketManager extends EventListener {
// Either returns currently connecting/active authenticated // Either returns currently connecting/active authenticated
// IWebSocketResource or connects a fresh one. // IWebSocketResource or connects a fresh one.
public async getAuthenticatedResource(): Promise<IWebSocketResource> { public async getAuthenticatedResource(): Promise<IWebSocketResource> {
if (!this.authenticated) { if (!this.#authenticated) {
strictAssert(this.credentials !== undefined, 'Missing credentials'); strictAssert(this.#credentials !== undefined, 'Missing credentials');
await this.authenticate(this.credentials); await this.authenticate(this.#credentials);
} }
strictAssert(this.authenticated !== undefined, 'Authentication failed'); strictAssert(this.#authenticated !== undefined, 'Authentication failed');
return this.authenticated.getResult(); return this.#authenticated.getResult();
} }
// Creates new IWebSocketResource for AccountManager's provisioning // Creates new IWebSocketResource for AccountManager's provisioning
public async getProvisioningResource( public async getProvisioningResource(
handler: IRequestHandler handler: IRequestHandler
): Promise<IWebSocketResource> { ): Promise<IWebSocketResource> {
if (this.isRemotelyExpired) { if (this.#isRemotelyExpired) {
throw new Error('Remotely expired, not connecting provisioning socket'); throw new Error('Remotely expired, not connecting provisioning socket');
} }
return this.connectResource({ return this.#connectResource({
name: 'provisioning', name: 'provisioning',
path: '/v1/websocket/provisioning/', path: '/v1/websocket/provisioning/',
proxyAgent: await this.getProxyAgent(), proxyAgent: await this.#getProxyAgent(),
resourceOptions: { resourceOptions: {
name: 'provisioning', name: 'provisioning',
handleRequest: (req: IncomingWebSocketRequest): void => { handleRequest: (req: IncomingWebSocketRequest): void => {
@ -390,7 +377,7 @@ export class SocketManager extends EventListener {
url: string; url: string;
extraHeaders?: Record<string, string>; extraHeaders?: Record<string, string>;
}): Promise<WebSocket> { }): Promise<WebSocket> {
const proxyAgent = await this.getProxyAgent(); const proxyAgent = await this.#getProxyAgent();
return connectWebSocket({ return connectWebSocket({
name: 'art-creator-provisioning', name: 'art-creator-provisioning',
@ -412,11 +399,11 @@ export class SocketManager extends EventListener {
const headers = new Headers(init.headers); const headers = new Headers(init.headers);
let resource: IWebSocketResource; let resource: IWebSocketResource;
if (this.isAuthenticated(headers)) { if (this.#isAuthenticated(headers)) {
resource = await this.getAuthenticatedResource(); resource = await this.getAuthenticatedResource();
} else { } else {
resource = await this.getUnauthenticatedResource(); resource = await this.#getUnauthenticatedResource();
await this.startUnauthenticatedExpirationTimer(resource); await this.#startUnauthenticatedExpirationTimer(resource);
} }
const { path } = URL.parse(url); const { path } = URL.parse(url);
@ -460,9 +447,9 @@ export class SocketManager extends EventListener {
} }
public registerRequestHandler(handler: IRequestHandler): void { public registerRequestHandler(handler: IRequestHandler): void {
this.requestHandlers.add(handler); this.#requestHandlers.add(handler);
const queue = this.incomingRequestQueue; const queue = this.#incomingRequestQueue;
if (queue.length === 0) { if (queue.length === 0) {
return; return;
} }
@ -470,22 +457,22 @@ export class SocketManager extends EventListener {
log.info( log.info(
`SocketManager: processing ${queue.length} queued incoming requests` `SocketManager: processing ${queue.length} queued incoming requests`
); );
this.incomingRequestQueue = []; this.#incomingRequestQueue = [];
for (const req of queue) { for (const req of queue) {
this.queueOrHandleRequest(req); this.#queueOrHandleRequest(req);
} }
} }
public unregisterRequestHandler(handler: IRequestHandler): void { public unregisterRequestHandler(handler: IRequestHandler): void {
this.requestHandlers.delete(handler); this.#requestHandlers.delete(handler);
} }
public async onHasStoriesDisabledChange(newValue: boolean): Promise<void> { public async onHasStoriesDisabledChange(newValue: boolean): Promise<void> {
if (this.hasStoriesDisabled === newValue) { if (this.#hasStoriesDisabled === newValue) {
return; return;
} }
this.hasStoriesDisabled = newValue; this.#hasStoriesDisabled = newValue;
log.info( log.info(
`SocketManager: reconnecting after setting hasStoriesDisabled=${newValue}` `SocketManager: reconnecting after setting hasStoriesDisabled=${newValue}`
); );
@ -495,24 +482,25 @@ export class SocketManager extends EventListener {
public async reconnect(): Promise<void> { public async reconnect(): Promise<void> {
log.info('SocketManager.reconnect: starting...'); log.info('SocketManager.reconnect: starting...');
const { authenticated, unauthenticated } = this; const unauthenticated = this.#unauthenticated;
const authenticated = this.#authenticated;
if (authenticated) { if (authenticated) {
authenticated.abort(); authenticated.abort();
this.dropAuthenticated(authenticated); this.#dropAuthenticated(authenticated);
} }
if (unauthenticated) { if (unauthenticated) {
unauthenticated.abort(); unauthenticated.abort();
this.dropUnauthenticated(unauthenticated); this.#dropUnauthenticated(unauthenticated);
} }
if (this.credentials) { if (this.#credentials) {
this.backOff.reset(); this.#backOff.reset();
// Cancel old reconnect attempt // Cancel old reconnect attempt
this.reconnectController?.abort(); this.#reconnectController?.abort();
// Start the new attempt // Start the new attempt
await this.authenticate(this.credentials); await this.authenticate(this.#credentials);
} }
log.info('SocketManager.reconnect: complete.'); log.info('SocketManager.reconnect: complete.');
@ -522,71 +510,71 @@ export class SocketManager extends EventListener {
public async check(): Promise<void> { public async check(): Promise<void> {
log.info('SocketManager.check'); log.info('SocketManager.check');
await Promise.all([ await Promise.all([
this.checkResource(this.authenticated), this.#checkResource(this.#authenticated),
this.checkResource(this.unauthenticated), this.#checkResource(this.#unauthenticated),
]); ]);
} }
public async onNavigatorOnline(): Promise<void> { public async onNavigatorOnline(): Promise<void> {
log.info('SocketManager.onNavigatorOnline'); log.info('SocketManager.onNavigatorOnline');
this.isNavigatorOffline = false; this.#isNavigatorOffline = false;
this.backOff.reset(FIBONACCI_TIMEOUTS); this.#backOff.reset(FIBONACCI_TIMEOUTS);
// Reconnect earlier if waiting // Reconnect earlier if waiting
if (this.credentials !== undefined) { if (this.#credentials !== undefined) {
this.reconnectController?.abort(); this.#reconnectController?.abort();
await this.authenticate(this.credentials); await this.authenticate(this.#credentials);
} }
} }
public async onNavigatorOffline(): Promise<void> { public async onNavigatorOffline(): Promise<void> {
log.info('SocketManager.onNavigatorOffline'); log.info('SocketManager.onNavigatorOffline');
this.isNavigatorOffline = true; this.#isNavigatorOffline = true;
this.backOff.reset(EXTENDED_FIBONACCI_TIMEOUTS); this.#backOff.reset(EXTENDED_FIBONACCI_TIMEOUTS);
await this.check(); await this.check();
} }
public async onRemoteExpiration(): Promise<void> { public async onRemoteExpiration(): Promise<void> {
log.info('SocketManager.onRemoteExpiration'); log.info('SocketManager.onRemoteExpiration');
this.isRemotelyExpired = true; this.#isRemotelyExpired = true;
// Cancel reconnect attempt if any // Cancel reconnect attempt if any
this.reconnectController?.abort(); this.#reconnectController?.abort();
} }
public async logout(): Promise<void> { public async logout(): Promise<void> {
const { authenticated } = this; const authenticated = this.#authenticated;
if (authenticated) { if (authenticated) {
authenticated.abort(); authenticated.abort();
this.dropAuthenticated(authenticated); this.#dropAuthenticated(authenticated);
} }
this.credentials = undefined; this.#credentials = undefined;
} }
public get isOnline(): boolean | undefined { public get isOnline(): boolean | undefined {
return this.privIsOnline; return this.#privIsOnline;
} }
// //
// Private // Private
// //
private setStatus(status: SocketStatus): void { #setStatus(status: SocketStatus): void {
if (this.status === status) { if (this.#status === status) {
return; return;
} }
this.status = status; this.#status = status;
this.emit('statusChange'); this.emit('statusChange');
if (this.status === SocketStatus.OPEN && !this.privIsOnline) { if (this.#status === SocketStatus.OPEN && !this.#privIsOnline) {
this.privIsOnline = true; this.#privIsOnline = true;
this.emit('online'); this.emit('online');
} }
} }
private transportOption(proxyAgent: ProxyAgent | undefined): TransportOption { #transportOption(proxyAgent: ProxyAgent | undefined): TransportOption {
const { hostname } = URL.parse(this.options.url); const { hostname } = URL.parse(this.options.url);
// transport experiment doesn't support proxy // transport experiment doesn't support proxy
@ -629,17 +617,17 @@ export class SocketManager extends EventListener {
: TransportOption.Original; : TransportOption.Original;
} }
private async getUnauthenticatedResource(): Promise<IWebSocketResource> { async #getUnauthenticatedResource(): Promise<IWebSocketResource> {
// awaiting on `this.getProxyAgent()` needs to happen here // awaiting on `this.getProxyAgent()` needs to happen here
// so that there are no calls to `await` between checking // so that there are no calls to `await` between checking
// the value of `this.unauthenticated` and assigning it later in this function // the value of `this.unauthenticated` and assigning it later in this function
const proxyAgent = await this.getProxyAgent(); const proxyAgent = await this.#getProxyAgent();
if (this.unauthenticated) { if (this.#unauthenticated) {
return this.unauthenticated.getResult(); return this.#unauthenticated.getResult();
} }
if (this.isRemotelyExpired) { if (this.#isRemotelyExpired) {
throw new HTTPError('SocketManager remotely expired', { throw new HTTPError('SocketManager remotely expired', {
code: 0, code: 0,
headers: {}, headers: {},
@ -649,7 +637,7 @@ export class SocketManager extends EventListener {
log.info('SocketManager: connecting unauthenticated socket'); log.info('SocketManager: connecting unauthenticated socket');
const transportOption = this.transportOption(proxyAgent); const transportOption = this.#transportOption(proxyAgent);
log.info( log.info(
`SocketManager: connecting unauthenticated socket, transport option [${transportOption}]` `SocketManager: connecting unauthenticated socket, transport option [${transportOption}]`
); );
@ -663,7 +651,7 @@ export class SocketManager extends EventListener {
keepalive: { path: '/v1/keepalive' }, keepalive: { path: '/v1/keepalive' },
}); });
} else { } else {
process = this.connectResource({ process = this.#connectResource({
name: UNAUTHENTICATED_CHANNEL_NAME, name: UNAUTHENTICATED_CHANNEL_NAME,
path: '/v1/websocket/', path: '/v1/websocket/',
proxyAgent, proxyAgent,
@ -675,17 +663,17 @@ export class SocketManager extends EventListener {
}); });
} }
this.unauthenticated = process; this.#unauthenticated = process;
let unauthenticated: IWebSocketResource; let unauthenticated: IWebSocketResource;
try { try {
unauthenticated = await this.unauthenticated.getResult(); unauthenticated = await this.#unauthenticated.getResult();
} catch (error) { } catch (error) {
log.info( log.info(
'SocketManager: failed to connect unauthenticated socket ' + 'SocketManager: failed to connect unauthenticated socket ' +
` due to error: ${Errors.toLogFormat(error)}` ` due to error: ${Errors.toLogFormat(error)}`
); );
this.dropUnauthenticated(process); this.#dropUnauthenticated(process);
throw error; throw error;
} }
@ -694,7 +682,7 @@ export class SocketManager extends EventListener {
); );
unauthenticated.addEventListener('close', ({ code, reason }): void => { unauthenticated.addEventListener('close', ({ code, reason }): void => {
if (this.unauthenticated !== process) { if (this.#unauthenticated !== process) {
return; return;
} }
@ -703,13 +691,13 @@ export class SocketManager extends EventListener {
`with code=${code} and reason=${reason}` `with code=${code} and reason=${reason}`
); );
this.dropUnauthenticated(process); this.#dropUnauthenticated(process);
}); });
return this.unauthenticated.getResult(); return this.#unauthenticated.getResult();
} }
private connectResource({ #connectResource({
name, name,
path, path,
proxyAgent, proxyAgent,
@ -757,7 +745,10 @@ export class SocketManager extends EventListener {
resourceOptions.transportOption === TransportOption.Original; resourceOptions.transportOption === TransportOption.Original;
return shadowingModeEnabled return shadowingModeEnabled
? webSocketResourceConnection ? webSocketResourceConnection
: this.connectWithShadowing(webSocketResourceConnection, resourceOptions); : this.#connectWithShadowing(
webSocketResourceConnection,
resourceOptions
);
} }
/** /**
@ -774,7 +765,7 @@ export class SocketManager extends EventListener {
* @param options `WebSocketResourceOptions` options * @param options `WebSocketResourceOptions` options
* @private * @private
*/ */
private connectWithShadowing( #connectWithShadowing(
mainConnection: AbortableProcess<WebSocketResource>, mainConnection: AbortableProcess<WebSocketResource>,
options: WebSocketResourceOptions options: WebSocketResourceOptions
): AbortableProcess<IWebSocketResource> { ): AbortableProcess<IWebSocketResource> {
@ -809,7 +800,7 @@ export class SocketManager extends EventListener {
); );
} }
private async checkResource( async #checkResource(
process?: AbortableProcess<IWebSocketResource> process?: AbortableProcess<IWebSocketResource>
): Promise<void> { ): Promise<void> {
if (!process) { if (!process) {
@ -820,41 +811,37 @@ export class SocketManager extends EventListener {
// Force shorter timeout if we think we might be offline // Force shorter timeout if we think we might be offline
resource.forceKeepAlive( resource.forceKeepAlive(
this.isNavigatorOffline ? OFFLINE_KEEPALIVE_TIMEOUT_MS : undefined this.#isNavigatorOffline ? OFFLINE_KEEPALIVE_TIMEOUT_MS : undefined
); );
} }
private dropAuthenticated( #dropAuthenticated(process: AbortableProcess<IWebSocketResource>): void {
process: AbortableProcess<IWebSocketResource> if (this.#authenticated !== process) {
): void {
if (this.authenticated !== process) {
return; return;
} }
this.incomingRequestQueue = []; this.#incomingRequestQueue = [];
this.authenticated = undefined; this.#authenticated = undefined;
this.setStatus(SocketStatus.CLOSED); this.#setStatus(SocketStatus.CLOSED);
} }
private dropUnauthenticated( #dropUnauthenticated(process: AbortableProcess<IWebSocketResource>): void {
process: AbortableProcess<IWebSocketResource> if (this.#unauthenticated !== process) {
): void {
if (this.unauthenticated !== process) {
return; return;
} }
this.unauthenticated = undefined; this.#unauthenticated = undefined;
if (!this.unauthenticatedExpirationTimer) { if (!this.#unauthenticatedExpirationTimer) {
return; return;
} }
clearTimeout(this.unauthenticatedExpirationTimer); clearTimeout(this.#unauthenticatedExpirationTimer);
this.unauthenticatedExpirationTimer = undefined; this.#unauthenticatedExpirationTimer = undefined;
} }
private async startUnauthenticatedExpirationTimer( async #startUnauthenticatedExpirationTimer(
expected: IWebSocketResource expected: IWebSocketResource
): Promise<void> { ): Promise<void> {
const process = this.unauthenticated; const process = this.#unauthenticated;
strictAssert( strictAssert(
process !== undefined, process !== undefined,
'Unauthenticated socket must be connected' 'Unauthenticated socket must be connected'
@ -866,28 +853,28 @@ export class SocketManager extends EventListener {
'Unauthenticated resource should be the same' 'Unauthenticated resource should be the same'
); );
if (this.unauthenticatedExpirationTimer) { if (this.#unauthenticatedExpirationTimer) {
return; return;
} }
log.info( log.info(
'SocketManager: starting expiration timer for unauthenticated socket' 'SocketManager: starting expiration timer for unauthenticated socket'
); );
this.unauthenticatedExpirationTimer = setTimeout(async () => { this.#unauthenticatedExpirationTimer = setTimeout(async () => {
log.info( log.info(
'SocketManager: shutting down unauthenticated socket after timeout' 'SocketManager: shutting down unauthenticated socket after timeout'
); );
unauthenticated.shutdown(); unauthenticated.shutdown();
// The socket is either deliberately closed or reconnected already // The socket is either deliberately closed or reconnected already
if (this.unauthenticated !== process) { if (this.#unauthenticated !== process) {
return; return;
} }
this.dropUnauthenticated(process); this.#dropUnauthenticated(process);
try { try {
await this.getUnauthenticatedResource(); await this.#getUnauthenticatedResource();
} catch (error) { } catch (error) {
log.warn( log.warn(
'SocketManager: failed to reconnect unauthenticated socket ' + 'SocketManager: failed to reconnect unauthenticated socket ' +
@ -897,22 +884,22 @@ export class SocketManager extends EventListener {
}, FIVE_MINUTES); }, FIVE_MINUTES);
} }
private queueOrHandleRequest(req: IncomingWebSocketRequest): void { #queueOrHandleRequest(req: IncomingWebSocketRequest): void {
if (req.requestType === ServerRequestType.ApiMessage) { if (req.requestType === ServerRequestType.ApiMessage) {
this.envelopeCount += 1; this.#envelopeCount += 1;
if (this.envelopeCount === 1) { if (this.#envelopeCount === 1) {
this.emit('firstEnvelope', req); this.emit('firstEnvelope', req);
} }
} }
if (this.requestHandlers.size === 0) { if (this.#requestHandlers.size === 0) {
this.incomingRequestQueue.push(req); this.#incomingRequestQueue.push(req);
log.info( log.info(
'SocketManager: request handler unavailable, ' + 'SocketManager: request handler unavailable, ' +
`queued request. Queue size: ${this.incomingRequestQueue.length}` `queued request. Queue size: ${this.#incomingRequestQueue.length}`
); );
return; return;
} }
for (const handlers of this.requestHandlers) { for (const handlers of this.#requestHandlers) {
try { try {
handlers.handleRequest(req); handlers.handleRequest(req);
} catch (error) { } catch (error) {
@ -924,8 +911,8 @@ export class SocketManager extends EventListener {
} }
} }
private isAuthenticated(headers: Headers): boolean { #isAuthenticated(headers: Headers): boolean {
if (!this.credentials) { if (!this.#credentials) {
return false; return false;
} }
@ -946,17 +933,17 @@ export class SocketManager extends EventListener {
); );
return ( return (
username === this.credentials.username && username === this.#credentials.username &&
password === this.credentials.password password === this.#credentials.password
); );
} }
private async getProxyAgent(): Promise<ProxyAgent | undefined> { async #getProxyAgent(): Promise<ProxyAgent | undefined> {
if (this.options.proxyUrl && !this.lazyProxyAgent) { if (this.options.proxyUrl && !this.#lazyProxyAgent) {
// Cache the promise so that we don't import concurrently. // Cache the promise so that we don't import concurrently.
this.lazyProxyAgent = createProxyAgent(this.options.proxyUrl); this.#lazyProxyAgent = createProxyAgent(this.options.proxyUrl);
} }
return this.lazyProxyAgent; return this.#lazyProxyAgent;
} }
// EventEmitter types // EventEmitter types

View file

@ -18,13 +18,10 @@ export class Storage implements StorageInterface {
public readonly blocked: Blocked; public readonly blocked: Blocked;
private ready = false; #ready = false;
#readyCallbacks: Array<() => void> = [];
private readyCallbacks: Array<() => void> = []; #items: Partial<Access> = Object.create(null);
#privProtocol: SignalProtocolStore | undefined;
private items: Partial<Access> = Object.create(null);
private privProtocol: SignalProtocolStore | undefined;
constructor() { constructor() {
this.user = new User(this); this.user = new User(this);
@ -35,14 +32,14 @@ export class Storage implements StorageInterface {
get protocol(): SignalProtocolStore { get protocol(): SignalProtocolStore {
assertDev( assertDev(
this.privProtocol !== undefined, this.#privProtocol !== undefined,
'SignalProtocolStore not initialized' 'SignalProtocolStore not initialized'
); );
return this.privProtocol; return this.#privProtocol;
} }
set protocol(value: SignalProtocolStore) { set protocol(value: SignalProtocolStore) {
this.privProtocol = value; this.#privProtocol = value;
} }
// `StorageInterface` implementation // `StorageInterface` implementation
@ -60,11 +57,11 @@ export class Storage implements StorageInterface {
key: K, key: K,
defaultValue?: Access[K] defaultValue?: Access[K]
): Access[K] | undefined { ): Access[K] | undefined {
if (!this.ready) { if (!this.#ready) {
log.warn('Called storage.get before storage is ready. key:', key); log.warn('Called storage.get before storage is ready. key:', key);
} }
const item = this.items[key]; const item = this.#items[key];
if (item === undefined) { if (item === undefined) {
return defaultValue; return defaultValue;
} }
@ -76,22 +73,22 @@ export class Storage implements StorageInterface {
key: K, key: K,
value: Access[K] value: Access[K]
): Promise<void> { ): Promise<void> {
if (!this.ready) { if (!this.#ready) {
log.warn('Called storage.put before storage is ready. key:', key); log.warn('Called storage.put before storage is ready. key:', key);
} }
this.items[key] = value; this.#items[key] = value;
await DataWriter.createOrUpdateItem({ id: key, value }); await DataWriter.createOrUpdateItem({ id: key, value });
window.reduxActions?.items.putItemExternal(key, value); window.reduxActions?.items.putItemExternal(key, value);
} }
public async remove<K extends keyof Access>(key: K): Promise<void> { public async remove<K extends keyof Access>(key: K): Promise<void> {
if (!this.ready) { if (!this.#ready) {
log.warn('Called storage.remove before storage is ready. key:', key); log.warn('Called storage.remove before storage is ready. key:', key);
} }
delete this.items[key]; delete this.#items[key];
await DataWriter.removeItemById(key); await DataWriter.removeItemById(key);
window.reduxActions?.items.removeItemExternal(key); window.reduxActions?.items.removeItemExternal(key);
@ -100,29 +97,29 @@ export class Storage implements StorageInterface {
// Regular methods // Regular methods
public onready(callback: () => void): void { public onready(callback: () => void): void {
if (this.ready) { if (this.#ready) {
callback(); callback();
} else { } else {
this.readyCallbacks.push(callback); this.#readyCallbacks.push(callback);
} }
} }
public async fetch(): Promise<void> { public async fetch(): Promise<void> {
this.reset(); this.reset();
Object.assign(this.items, await DataReader.getAllItems()); Object.assign(this.#items, await DataReader.getAllItems());
this.ready = true; this.#ready = true;
this.callListeners(); this.#callListeners();
} }
public reset(): void { public reset(): void {
this.ready = false; this.#ready = false;
this.items = Object.create(null); this.#items = Object.create(null);
} }
public getItemsState(): Partial<Access> { public getItemsState(): Partial<Access> {
if (!this.ready) { if (!this.#ready) {
log.warn('Called getItemsState before storage is ready'); log.warn('Called getItemsState before storage is ready');
} }
@ -130,8 +127,7 @@ export class Storage implements StorageInterface {
const state = Object.create(null); const state = Object.create(null);
// TypeScript isn't smart enough to figure out the types automatically. const items = this.#items;
const { items } = this;
const allKeys = Object.keys(items) as Array<keyof typeof items>; const allKeys = Object.keys(items) as Array<keyof typeof items>;
for (const key of allKeys) { for (const key of allKeys) {
@ -141,12 +137,12 @@ export class Storage implements StorageInterface {
return state; return state;
} }
private callListeners(): void { #callListeners(): void {
if (!this.ready) { if (!this.#ready) {
return; return;
} }
const callbacks = this.readyCallbacks; const callbacks = this.#readyCallbacks;
this.readyCallbacks = []; this.#readyCallbacks = [];
callbacks.forEach(callback => callback()); callbacks.forEach(callback => callback());
} }
} }

View file

@ -15,7 +15,7 @@ import { singleProtoJobQueue } from '../jobs/singleProtoJobQueue';
import * as Errors from '../types/errors'; import * as Errors from '../types/errors';
class SyncRequestInner extends EventTarget { class SyncRequestInner extends EventTarget {
private started = false; #started = false;
contactSync?: boolean; contactSync?: boolean;
@ -44,14 +44,14 @@ class SyncRequestInner extends EventTarget {
} }
async start(): Promise<void> { async start(): Promise<void> {
if (this.started) { if (this.#started) {
assertDev( assertDev(
false, false,
'SyncRequestInner: started more than once. Doing nothing' 'SyncRequestInner: started more than once. Doing nothing'
); );
return; return;
} }
this.started = true; this.#started = true;
if (window.ConversationController.areWePrimaryDevice()) { if (window.ConversationController.areWePrimaryDevice()) {
log.warn('SyncRequest.start: We are primary device; returning early'); log.warn('SyncRequest.start: We are primary device; returning early');
@ -108,7 +108,7 @@ class SyncRequestInner extends EventTarget {
} }
export default class SyncRequest { export default class SyncRequest {
private inner: SyncRequestInner; #inner: SyncRequestInner;
addEventListener: ( addEventListener: (
name: 'success' | 'timeout', name: 'success' | 'timeout',
@ -122,12 +122,12 @@ export default class SyncRequest {
constructor(receiver: MessageReceiver, timeoutMillis?: number) { constructor(receiver: MessageReceiver, timeoutMillis?: number) {
const inner = new SyncRequestInner(receiver, timeoutMillis); const inner = new SyncRequestInner(receiver, timeoutMillis);
this.inner = inner; this.#inner = inner;
this.addEventListener = inner.addEventListener.bind(inner); this.addEventListener = inner.addEventListener.bind(inner);
this.removeEventListener = inner.removeEventListener.bind(inner); this.removeEventListener = inner.removeEventListener.bind(inner);
} }
start(): void { start(): void {
void this.inner.start(); void this.#inner.start();
} }
} }

View file

@ -41,16 +41,16 @@ export class UpdateKeysListener {
} }
clearTimeoutIfNecessary(this.timeout); clearTimeoutIfNecessary(this.timeout);
this.timeout = setTimeout(() => this.runWhenOnline(), waitTime); this.timeout = setTimeout(() => this.#runWhenOnline(), waitTime);
} }
private scheduleNextUpdate(): void { #scheduleNextUpdate(): void {
const now = Date.now(); const now = Date.now();
const nextTime = now + UPDATE_INTERVAL; const nextTime = now + UPDATE_INTERVAL;
void window.textsecure.storage.put(UPDATE_TIME_STORAGE_KEY, nextTime); void window.textsecure.storage.put(UPDATE_TIME_STORAGE_KEY, nextTime);
} }
private async run(): Promise<void> { async #run(): Promise<void> {
log.info('UpdateKeysListener: Updating keys...'); log.info('UpdateKeysListener: Updating keys...');
try { try {
const accountManager = window.getAccountManager(); const accountManager = window.getAccountManager();
@ -79,7 +79,7 @@ export class UpdateKeysListener {
} }
} }
this.scheduleNextUpdate(); this.#scheduleNextUpdate();
this.setTimeoutForNextRun(); this.setTimeoutForNextRun();
} catch (error) { } catch (error) {
const errorString = const errorString =
@ -93,9 +93,9 @@ export class UpdateKeysListener {
} }
} }
private runWhenOnline() { #runWhenOnline() {
if (window.textsecure.server?.isOnline()) { if (window.textsecure.server?.isOnline()) {
void this.run(); void this.#run();
} else { } else {
log.info( log.info(
'UpdateKeysListener: We are offline; will update keys when we are next online' 'UpdateKeysListener: We are offline; will update keys when we are next online'

View file

@ -193,7 +193,7 @@ export class IncomingWebSocketRequestLibsignal
export class IncomingWebSocketRequestLegacy export class IncomingWebSocketRequestLegacy
implements IncomingWebSocketRequest implements IncomingWebSocketRequest
{ {
private readonly id: Long; readonly #id: Long;
public readonly requestType: ServerRequestType; public readonly requestType: ServerRequestType;
@ -209,7 +209,7 @@ export class IncomingWebSocketRequestLegacy
strictAssert(request.verb, 'request without verb'); strictAssert(request.verb, 'request without verb');
strictAssert(request.path, 'request without path'); strictAssert(request.path, 'request without path');
this.id = request.id; this.#id = request.id;
this.requestType = resolveType(request.path, request.verb); this.requestType = resolveType(request.path, request.verb);
this.body = dropNull(request.body); this.body = dropNull(request.body);
this.timestamp = resolveTimestamp(request.headers || []); this.timestamp = resolveTimestamp(request.headers || []);
@ -218,7 +218,7 @@ export class IncomingWebSocketRequestLegacy
public respond(status: number, message: string): void { public respond(status: number, message: string): void {
const bytes = Proto.WebSocketMessage.encode({ const bytes = Proto.WebSocketMessage.encode({
type: Proto.WebSocketMessage.Type.RESPONSE, type: Proto.WebSocketMessage.Type.RESPONSE,
response: { id: this.id, message, status }, response: { id: this.#id, message, status },
}).finish(); }).finish();
this.sendBytes(Buffer.from(bytes)); this.sendBytes(Buffer.from(bytes));
@ -479,7 +479,7 @@ export class LibsignalWebSocketResource
// socket alive using websocket pings, so we don't need a timer-based // socket alive using websocket pings, so we don't need a timer-based
// keepalive mechanism. But we still send one-off keepalive requests when // keepalive mechanism. But we still send one-off keepalive requests when
// things change (see forceKeepAlive()). // things change (see forceKeepAlive()).
private keepalive: KeepAliveSender; #keepalive: KeepAliveSender;
constructor( constructor(
private readonly chatService: Net.ChatConnection, private readonly chatService: Net.ChatConnection,
@ -490,7 +490,7 @@ export class LibsignalWebSocketResource
) { ) {
super(); super();
this.keepalive = new KeepAliveSender(this, this.logId, keepalive); this.#keepalive = new KeepAliveSender(this, this.logId, keepalive);
} }
public localPort(): number { public localPort(): number {
@ -551,7 +551,7 @@ export class LibsignalWebSocketResource
} }
public forceKeepAlive(timeout?: number): void { public forceKeepAlive(timeout?: number): void {
drop(this.keepalive.send(timeout)); drop(this.#keepalive.send(timeout));
} }
public async sendRequest(options: SendRequestOptions): Promise<Response> { public async sendRequest(options: SendRequestOptions): Promise<Response> {
@ -578,28 +578,24 @@ export class LibsignalWebSocketResource
} }
export class WebSocketResourceWithShadowing implements IWebSocketResource { export class WebSocketResourceWithShadowing implements IWebSocketResource {
private shadowing: LibsignalWebSocketResource | undefined; #shadowing: LibsignalWebSocketResource | undefined;
#stats: AggregatedStats;
private stats: AggregatedStats; #statsTimer: NodeJS.Timeout;
#shadowingWithReporting: boolean;
private statsTimer: NodeJS.Timeout; #logId: string;
private shadowingWithReporting: boolean;
private logId: string;
constructor( constructor(
private readonly main: WebSocketResource, private readonly main: WebSocketResource,
private readonly shadowingConnection: AbortableProcess<LibsignalWebSocketResource>, private readonly shadowingConnection: AbortableProcess<LibsignalWebSocketResource>,
options: WebSocketResourceOptions options: WebSocketResourceOptions
) { ) {
this.stats = AggregatedStats.createEmpty(); this.#stats = AggregatedStats.createEmpty();
this.logId = `WebSocketResourceWithShadowing(${options.name})`; this.#logId = `WebSocketResourceWithShadowing(${options.name})`;
this.statsTimer = setInterval( this.#statsTimer = setInterval(
() => this.updateStats(options.name), () => this.#updateStats(options.name),
STATS_UPDATE_INTERVAL STATS_UPDATE_INTERVAL
); );
this.shadowingWithReporting = this.#shadowingWithReporting =
options.transportOption === TransportOption.ShadowingHigh; options.transportOption === TransportOption.ShadowingHigh;
// the idea is that we want to keep the shadowing connection process // the idea is that we want to keep the shadowing connection process
@ -608,33 +604,33 @@ export class WebSocketResourceWithShadowing implements IWebSocketResource {
// or an error reported in case of connection failure // or an error reported in case of connection failure
const initializeAfterConnected = async () => { const initializeAfterConnected = async () => {
try { try {
this.shadowing = await shadowingConnection.resultPromise; this.#shadowing = await shadowingConnection.resultPromise;
// checking IP one time per connection // checking IP one time per connection
if (this.main.ipVersion() !== this.shadowing.ipVersion()) { if (this.main.ipVersion() !== this.#shadowing.ipVersion()) {
this.stats.ipVersionMismatches += 1; this.#stats.ipVersionMismatches += 1;
const mainIpType = this.main.ipVersion(); const mainIpType = this.main.ipVersion();
const shadowIpType = this.shadowing.ipVersion(); const shadowIpType = this.#shadowing.ipVersion();
log.warn( log.warn(
`${this.logId}: libsignal websocket IP [${shadowIpType}], Desktop websocket IP [${mainIpType}]` `${this.#logId}: libsignal websocket IP [${shadowIpType}], Desktop websocket IP [${mainIpType}]`
); );
} }
} catch (error) { } catch (error) {
this.stats.connectionFailures += 1; this.#stats.connectionFailures += 1;
} }
}; };
drop(initializeAfterConnected()); drop(initializeAfterConnected());
this.addEventListener('close', (_ev): void => { this.addEventListener('close', (_ev): void => {
clearInterval(this.statsTimer); clearInterval(this.#statsTimer);
this.updateStats(options.name); this.#updateStats(options.name);
}); });
} }
private updateStats(name: string) { #updateStats(name: string) {
const storedStats = AggregatedStats.loadOrCreateEmpty(name); const storedStats = AggregatedStats.loadOrCreateEmpty(name);
let updatedStats = AggregatedStats.add(storedStats, this.stats); let updatedStats = AggregatedStats.add(storedStats, this.#stats);
if ( if (
this.shadowingWithReporting && this.#shadowingWithReporting &&
AggregatedStats.shouldReportError(updatedStats) && AggregatedStats.shouldReportError(updatedStats) &&
!isProduction(window.getVersion()) !isProduction(window.getVersion())
) { ) {
@ -642,14 +638,14 @@ export class WebSocketResourceWithShadowing implements IWebSocketResource {
toastType: ToastType.TransportError, toastType: ToastType.TransportError,
}); });
log.warn( log.warn(
`${this.logId}: experimental transport toast displayed, flushing transport statistics before resetting`, `${this.#logId}: experimental transport toast displayed, flushing transport statistics before resetting`,
updatedStats updatedStats
); );
updatedStats = AggregatedStats.createEmpty(); updatedStats = AggregatedStats.createEmpty();
updatedStats.lastToastTimestamp = Date.now(); updatedStats.lastToastTimestamp = Date.now();
} }
AggregatedStats.store(updatedStats, name); AggregatedStats.store(updatedStats, name);
this.stats = AggregatedStats.createEmpty(); this.#stats = AggregatedStats.createEmpty();
} }
public localPort(): number | undefined { public localPort(): number | undefined {
@ -665,9 +661,9 @@ export class WebSocketResourceWithShadowing implements IWebSocketResource {
public close(code = NORMAL_DISCONNECT_CODE, reason?: string): void { public close(code = NORMAL_DISCONNECT_CODE, reason?: string): void {
this.main.close(code, reason); this.main.close(code, reason);
if (this.shadowing) { if (this.#shadowing) {
this.shadowing.close(code, reason); this.#shadowing.close(code, reason);
this.shadowing = undefined; this.#shadowing = undefined;
} else { } else {
this.shadowingConnection.abort(); this.shadowingConnection.abort();
} }
@ -675,9 +671,9 @@ export class WebSocketResourceWithShadowing implements IWebSocketResource {
public shutdown(): void { public shutdown(): void {
this.main.shutdown(); this.main.shutdown();
if (this.shadowing) { if (this.#shadowing) {
this.shadowing.shutdown(); this.#shadowing.shutdown();
this.shadowing = undefined; this.#shadowing = undefined;
} else { } else {
this.shadowingConnection.abort(); this.shadowingConnection.abort();
} }
@ -695,48 +691,48 @@ export class WebSocketResourceWithShadowing implements IWebSocketResource {
// attempting to run a healthcheck on a libsignal transport. // attempting to run a healthcheck on a libsignal transport.
if ( if (
isSuccessfulStatusCode(response.status) && isSuccessfulStatusCode(response.status) &&
this.shouldSendShadowRequest() this.#shouldSendShadowRequest()
) { ) {
drop(this.sendShadowRequest()); drop(this.#sendShadowRequest());
} }
return response; return response;
} }
private async sendShadowRequest(): Promise<void> { async #sendShadowRequest(): Promise<void> {
// In the shadowing mode, it could be that we're either // In the shadowing mode, it could be that we're either
// still connecting libsignal websocket or have already closed it. // still connecting libsignal websocket or have already closed it.
// In those cases we're not running shadowing check. // In those cases we're not running shadowing check.
if (!this.shadowing) { if (!this.#shadowing) {
log.info( log.info(
`${this.logId}: skipping healthcheck - websocket not connected or already closed` `${this.#logId}: skipping healthcheck - websocket not connected or already closed`
); );
return; return;
} }
try { try {
const healthCheckResult = await this.shadowing.sendRequest({ const healthCheckResult = await this.#shadowing.sendRequest({
verb: 'GET', verb: 'GET',
path: '/v1/keepalive', path: '/v1/keepalive',
timeout: KEEPALIVE_TIMEOUT_MS, timeout: KEEPALIVE_TIMEOUT_MS,
}); });
this.stats.requestsCompared += 1; this.#stats.requestsCompared += 1;
if (!isSuccessfulStatusCode(healthCheckResult.status)) { if (!isSuccessfulStatusCode(healthCheckResult.status)) {
this.stats.healthcheckBadStatus += 1; this.#stats.healthcheckBadStatus += 1;
log.warn( log.warn(
`${this.logId}: keepalive via libsignal responded with status [${healthCheckResult.status}]` `${this.#logId}: keepalive via libsignal responded with status [${healthCheckResult.status}]`
); );
} }
} catch (error) { } catch (error) {
this.stats.healthcheckFailures += 1; this.#stats.healthcheckFailures += 1;
log.warn( log.warn(
`${this.logId}: failed to send keepalive via libsignal`, `${this.#logId}: failed to send keepalive via libsignal`,
Errors.toLogFormat(error) Errors.toLogFormat(error)
); );
} }
} }
private shouldSendShadowRequest(): boolean { #shouldSendShadowRequest(): boolean {
return this.shadowingWithReporting || random(0, 100) < 10; return this.#shadowingWithReporting || random(0, 100) < 10;
} }
} }
@ -748,28 +744,21 @@ export default class WebSocketResource
extends EventTarget extends EventTarget
implements IWebSocketResource implements IWebSocketResource
{ {
private outgoingId = Long.fromNumber(1, true); #outgoingId = Long.fromNumber(1, true);
#closed = false;
private closed = false; readonly #outgoingMap = new Map<
private readonly outgoingMap = new Map<
string, string,
(result: SendRequestResult) => void (result: SendRequestResult) => void
>(); >();
private readonly boundOnMessage: (message: IMessage) => void; readonly #boundOnMessage: (message: IMessage) => void;
#activeRequests = new Set<IncomingWebSocketRequest | string>();
private activeRequests = new Set<IncomingWebSocketRequest | string>(); #shuttingDown = false;
#shutdownTimer?: Timers.Timeout;
private shuttingDown = false; readonly #logId: string;
readonly #localSocketPort: number | undefined;
private shutdownTimer?: Timers.Timeout; readonly #socketIpVersion: IpVersion | undefined;
private readonly logId: string;
private readonly localSocketPort: number | undefined;
private readonly socketIpVersion: IpVersion | undefined;
// Public for tests // Public for tests
public readonly keepalive?: KeepAlive; public readonly keepalive?: KeepAlive;
@ -780,25 +769,25 @@ export default class WebSocketResource
) { ) {
super(); super();
this.logId = `WebSocketResource(${options.name})`; this.#logId = `WebSocketResource(${options.name})`;
this.localSocketPort = socket.socket.localPort; this.#localSocketPort = socket.socket.localPort;
if (!socket.socket.localAddress) { if (!socket.socket.localAddress) {
this.socketIpVersion = undefined; this.#socketIpVersion = undefined;
} }
if (socket.socket.localAddress == null) { if (socket.socket.localAddress == null) {
this.socketIpVersion = undefined; this.#socketIpVersion = undefined;
} else if (net.isIPv4(socket.socket.localAddress)) { } else if (net.isIPv4(socket.socket.localAddress)) {
this.socketIpVersion = IpVersion.IPv4; this.#socketIpVersion = IpVersion.IPv4;
} else if (net.isIPv6(socket.socket.localAddress)) { } else if (net.isIPv6(socket.socket.localAddress)) {
this.socketIpVersion = IpVersion.IPv6; this.#socketIpVersion = IpVersion.IPv6;
} else { } else {
this.socketIpVersion = undefined; this.#socketIpVersion = undefined;
} }
this.boundOnMessage = this.onMessage.bind(this); this.#boundOnMessage = this.#onMessage.bind(this);
socket.on('message', this.boundOnMessage); socket.on('message', this.#boundOnMessage);
if (options.keepalive) { if (options.keepalive) {
const keepalive = new KeepAlive( const keepalive = new KeepAlive(
@ -811,26 +800,26 @@ export default class WebSocketResource
keepalive.reset(); keepalive.reset();
socket.on('close', () => this.keepalive?.stop()); socket.on('close', () => this.keepalive?.stop());
socket.on('error', (error: Error) => { socket.on('error', (error: Error) => {
log.warn(`${this.logId}: WebSocket error`, Errors.toLogFormat(error)); log.warn(`${this.#logId}: WebSocket error`, Errors.toLogFormat(error));
}); });
} }
socket.on('close', (code, reason) => { socket.on('close', (code, reason) => {
this.closed = true; this.#closed = true;
log.warn(`${this.logId}: Socket closed`); log.warn(`${this.#logId}: Socket closed`);
this.dispatchEvent(new CloseEvent(code, reason || 'normal')); this.dispatchEvent(new CloseEvent(code, reason || 'normal'));
}); });
this.addEventListener('close', () => this.onClose()); this.addEventListener('close', () => this.#onClose());
} }
public ipVersion(): IpVersion | undefined { public ipVersion(): IpVersion | undefined {
return this.socketIpVersion; return this.#socketIpVersion;
} }
public localPort(): number | undefined { public localPort(): number | undefined {
return this.localSocketPort; return this.#localSocketPort;
} }
public override addEventListener( public override addEventListener(
@ -843,12 +832,15 @@ export default class WebSocketResource
} }
public async sendRequest(options: SendRequestOptions): Promise<Response> { public async sendRequest(options: SendRequestOptions): Promise<Response> {
const id = this.outgoingId; const id = this.#outgoingId;
const idString = id.toString(); const idString = id.toString();
strictAssert(!this.outgoingMap.has(idString), 'Duplicate outgoing request'); strictAssert(
!this.#outgoingMap.has(idString),
'Duplicate outgoing request'
);
// Note that this automatically wraps // Note that this automatically wraps
this.outgoingId = this.outgoingId.add(1); this.#outgoingId = this.#outgoingId.add(1);
const bytes = Proto.WebSocketMessage.encode({ const bytes = Proto.WebSocketMessage.encode({
type: Proto.WebSocketMessage.Type.REQUEST, type: Proto.WebSocketMessage.Type.REQUEST,
@ -871,25 +863,25 @@ export default class WebSocketResource
'WebSocket request byte size exceeded' 'WebSocket request byte size exceeded'
); );
strictAssert(!this.shuttingDown, 'Cannot send request, shutting down'); strictAssert(!this.#shuttingDown, 'Cannot send request, shutting down');
this.addActive(idString); this.#addActive(idString);
const promise = new Promise<SendRequestResult>((resolve, reject) => { const promise = new Promise<SendRequestResult>((resolve, reject) => {
let timer = options.timeout let timer = options.timeout
? Timers.setTimeout(() => { ? Timers.setTimeout(() => {
this.removeActive(idString); this.#removeActive(idString);
this.close(UNEXPECTED_DISCONNECT_CODE, 'Request timed out'); this.close(UNEXPECTED_DISCONNECT_CODE, 'Request timed out');
reject(new Error(`Request timed out; id: [${idString}]`)); reject(new Error(`Request timed out; id: [${idString}]`));
}, options.timeout) }, options.timeout)
: undefined; : undefined;
this.outgoingMap.set(idString, result => { this.#outgoingMap.set(idString, result => {
if (timer !== undefined) { if (timer !== undefined) {
Timers.clearTimeout(timer); Timers.clearTimeout(timer);
timer = undefined; timer = undefined;
} }
this.keepalive?.reset(); this.keepalive?.reset();
this.removeActive(idString); this.#removeActive(idString);
resolve(result); resolve(result);
}); });
}); });
@ -908,58 +900,58 @@ export default class WebSocketResource
} }
public close(code = NORMAL_DISCONNECT_CODE, reason?: string): void { public close(code = NORMAL_DISCONNECT_CODE, reason?: string): void {
if (this.closed) { if (this.#closed) {
log.info(`${this.logId}.close: Already closed! ${code}/${reason}`); log.info(`${this.#logId}.close: Already closed! ${code}/${reason}`);
return; return;
} }
log.info(`${this.logId}.close(${code})`); log.info(`${this.#logId}.close(${code})`);
if (this.keepalive) { if (this.keepalive) {
this.keepalive.stop(); this.keepalive.stop();
} }
this.socket.close(code, reason); this.socket.close(code, reason);
this.socket.removeListener('message', this.boundOnMessage); this.socket.removeListener('message', this.#boundOnMessage);
// On linux the socket can wait a long time to emit its close event if we've // On linux the socket can wait a long time to emit its close event if we've
// lost the internet connection. On the order of minutes. This speeds that // lost the internet connection. On the order of minutes. This speeds that
// process up. // process up.
Timers.setTimeout(() => { Timers.setTimeout(() => {
if (this.closed) { if (this.#closed) {
return; return;
} }
log.warn(`${this.logId}.close: Dispatching our own socket close event`); log.warn(`${this.#logId}.close: Dispatching our own socket close event`);
this.dispatchEvent(new CloseEvent(code, reason || 'normal')); this.dispatchEvent(new CloseEvent(code, reason || 'normal'));
}, 5 * durations.SECOND); }, 5 * durations.SECOND);
} }
public shutdown(): void { public shutdown(): void {
if (this.closed) { if (this.#closed) {
return; return;
} }
if (this.activeRequests.size === 0) { if (this.#activeRequests.size === 0) {
log.info(`${this.logId}.shutdown: no active requests, closing`); log.info(`${this.#logId}.shutdown: no active requests, closing`);
this.close(NORMAL_DISCONNECT_CODE, 'Shutdown'); this.close(NORMAL_DISCONNECT_CODE, 'Shutdown');
return; return;
} }
this.shuttingDown = true; this.#shuttingDown = true;
log.info(`${this.logId}.shutdown: shutting down`); log.info(`${this.#logId}.shutdown: shutting down`);
this.shutdownTimer = Timers.setTimeout(() => { this.#shutdownTimer = Timers.setTimeout(() => {
if (this.closed) { if (this.#closed) {
return; return;
} }
log.warn(`${this.logId}.shutdown: Failed to shutdown gracefully`); log.warn(`${this.#logId}.shutdown: Failed to shutdown gracefully`);
this.close(NORMAL_DISCONNECT_CODE, 'Shutdown'); this.close(NORMAL_DISCONNECT_CODE, 'Shutdown');
}, THIRTY_SECONDS); }, THIRTY_SECONDS);
} }
private onMessage({ type, binaryData }: IMessage): void { #onMessage({ type, binaryData }: IMessage): void {
if (type !== 'binary' || !binaryData) { if (type !== 'binary' || !binaryData) {
throw new Error(`Unsupported websocket message type: ${type}`); throw new Error(`Unsupported websocket message type: ${type}`);
} }
@ -976,7 +968,7 @@ export default class WebSocketResource
const incomingRequest = new IncomingWebSocketRequestLegacy( const incomingRequest = new IncomingWebSocketRequestLegacy(
message.request, message.request,
(bytes: Buffer): void => { (bytes: Buffer): void => {
this.removeActive(incomingRequest); this.#removeActive(incomingRequest);
strictAssert( strictAssert(
bytes.length <= MAX_MESSAGE_SIZE, bytes.length <= MAX_MESSAGE_SIZE,
@ -986,12 +978,12 @@ export default class WebSocketResource
} }
); );
if (this.shuttingDown) { if (this.#shuttingDown) {
incomingRequest.respond(-1, 'Shutting down'); incomingRequest.respond(-1, 'Shutting down');
return; return;
} }
this.addActive(incomingRequest); this.#addActive(incomingRequest);
handleRequest(incomingRequest); handleRequest(incomingRequest);
} else if ( } else if (
message.type === Proto.WebSocketMessage.Type.RESPONSE && message.type === Proto.WebSocketMessage.Type.RESPONSE &&
@ -1001,8 +993,8 @@ export default class WebSocketResource
strictAssert(response.id, 'response without id'); strictAssert(response.id, 'response without id');
const responseIdString = response.id.toString(); const responseIdString = response.id.toString();
const resolve = this.outgoingMap.get(responseIdString); const resolve = this.#outgoingMap.get(responseIdString);
this.outgoingMap.delete(responseIdString); this.#outgoingMap.delete(responseIdString);
if (!resolve) { if (!resolve) {
throw new Error(`Received response for unknown request ${response.id}`); throw new Error(`Received response for unknown request ${response.id}`);
@ -1017,9 +1009,9 @@ export default class WebSocketResource
} }
} }
private onClose(): void { #onClose(): void {
const outgoing = new Map(this.outgoingMap); const outgoing = new Map(this.#outgoingMap);
this.outgoingMap.clear(); this.#outgoingMap.clear();
for (const resolve of outgoing.values()) { for (const resolve of outgoing.values()) {
resolve({ resolve({
@ -1031,30 +1023,30 @@ export default class WebSocketResource
} }
} }
private addActive(request: IncomingWebSocketRequest | string): void { #addActive(request: IncomingWebSocketRequest | string): void {
this.activeRequests.add(request); this.#activeRequests.add(request);
} }
private removeActive(request: IncomingWebSocketRequest | string): void { #removeActive(request: IncomingWebSocketRequest | string): void {
if (!this.activeRequests.has(request)) { if (!this.#activeRequests.has(request)) {
log.warn(`${this.logId}.removeActive: removing unknown request`); log.warn(`${this.#logId}.removeActive: removing unknown request`);
return; return;
} }
this.activeRequests.delete(request); this.#activeRequests.delete(request);
if (this.activeRequests.size !== 0) { if (this.#activeRequests.size !== 0) {
return; return;
} }
if (!this.shuttingDown) { if (!this.#shuttingDown) {
return; return;
} }
if (this.shutdownTimer) { if (this.#shutdownTimer) {
Timers.clearTimeout(this.shutdownTimer); Timers.clearTimeout(this.#shutdownTimer);
this.shutdownTimer = undefined; this.#shutdownTimer = undefined;
} }
log.info(`${this.logId}.removeActive: shutdown complete`); log.info(`${this.#logId}.removeActive: shutdown complete`);
this.close(NORMAL_DISCONNECT_CODE, 'Shutdown'); this.close(NORMAL_DISCONNECT_CODE, 'Shutdown');
} }
@ -1109,7 +1101,7 @@ const LOG_KEEPALIVE_AFTER_MS = 500;
* intervals. * intervals.
*/ */
class KeepAliveSender { class KeepAliveSender {
private path: string; #path: string;
protected wsr: IWebSocketResource; protected wsr: IWebSocketResource;
@ -1121,7 +1113,7 @@ class KeepAliveSender {
opts: KeepAliveOptionsType = {} opts: KeepAliveOptionsType = {}
) { ) {
this.logId = `WebSocketResources.KeepAlive(${name})`; this.logId = `WebSocketResources.KeepAlive(${name})`;
this.path = opts.path ?? '/'; this.#path = opts.path ?? '/';
this.wsr = websocketResource; this.wsr = websocketResource;
} }
@ -1133,7 +1125,7 @@ class KeepAliveSender {
const { status } = await pTimeout( const { status } = await pTimeout(
this.wsr.sendRequest({ this.wsr.sendRequest({
verb: 'GET', verb: 'GET',
path: this.path, path: this.#path,
}), }),
timeout timeout
); );
@ -1176,9 +1168,8 @@ class KeepAliveSender {
* {@link KeepAliveSender}. * {@link KeepAliveSender}.
*/ */
class KeepAlive extends KeepAliveSender { class KeepAlive extends KeepAliveSender {
private keepAliveTimer: Timers.Timeout | undefined; #keepAliveTimer: Timers.Timeout | undefined;
#lastAliveAt: number = Date.now();
private lastAliveAt: number = Date.now();
constructor( constructor(
websocketResource: WebSocketResource, websocketResource: WebSocketResource,
@ -1189,18 +1180,18 @@ class KeepAlive extends KeepAliveSender {
} }
public stop(): void { public stop(): void {
this.clearTimers(); this.#clearTimers();
} }
public override async send(timeout = KEEPALIVE_TIMEOUT_MS): Promise<boolean> { public override async send(timeout = KEEPALIVE_TIMEOUT_MS): Promise<boolean> {
this.clearTimers(); this.#clearTimers();
const isStale = isOlderThan(this.lastAliveAt, STALE_THRESHOLD_MS); const isStale = isOlderThan(this.#lastAliveAt, STALE_THRESHOLD_MS);
if (isStale) { if (isStale) {
log.info(`${this.logId}.send: disconnecting due to stale state`); log.info(`${this.logId}.send: disconnecting due to stale state`);
this.wsr.close( this.wsr.close(
UNEXPECTED_DISCONNECT_CODE, UNEXPECTED_DISCONNECT_CODE,
`Last keepalive request was too far in the past: ${this.lastAliveAt}` `Last keepalive request was too far in the past: ${this.#lastAliveAt}`
); );
return false; return false;
} }
@ -1216,20 +1207,20 @@ class KeepAlive extends KeepAliveSender {
} }
public reset(): void { public reset(): void {
this.lastAliveAt = Date.now(); this.#lastAliveAt = Date.now();
this.clearTimers(); this.#clearTimers();
this.keepAliveTimer = Timers.setTimeout( this.#keepAliveTimer = Timers.setTimeout(
() => this.send(), () => this.send(),
KEEPALIVE_INTERVAL_MS KEEPALIVE_INTERVAL_MS
); );
} }
private clearTimers(): void { #clearTimers(): void {
if (this.keepAliveTimer) { if (this.#keepAliveTimer) {
Timers.clearTimeout(this.keepAliveTimer); Timers.clearTimeout(this.#keepAliveTimer);
this.keepAliveTimer = undefined; this.#keepAliveTimer = undefined;
} }
} }
} }

View file

@ -15,12 +15,12 @@ export type CDSIOptionsType = Readonly<{
CDSSocketManagerBaseOptionsType; CDSSocketManagerBaseOptionsType;
export class CDSI extends CDSSocketManagerBase<CDSISocket, CDSIOptionsType> { export class CDSI extends CDSSocketManagerBase<CDSISocket, CDSIOptionsType> {
private readonly mrenclave: Buffer; readonly #mrenclave: Buffer;
constructor(libsignalNet: Net.Net, options: CDSIOptionsType) { constructor(libsignalNet: Net.Net, options: CDSIOptionsType) {
super(libsignalNet, options); super(libsignalNet, options);
this.mrenclave = Buffer.from(Bytes.fromHex(options.mrenclave)); this.#mrenclave = Buffer.from(Bytes.fromHex(options.mrenclave));
} }
protected override getSocketUrl(): string { protected override getSocketUrl(): string {
@ -33,7 +33,7 @@ export class CDSI extends CDSSocketManagerBase<CDSISocket, CDSIOptionsType> {
return new CDSISocket({ return new CDSISocket({
logger: this.logger, logger: this.logger,
socket, socket,
mrenclave: this.mrenclave, mrenclave: this.#mrenclave,
}); });
} }
} }

View file

@ -14,7 +14,7 @@ export type CDSISocketOptionsType = Readonly<{
CDSSocketBaseOptionsType; CDSSocketBaseOptionsType;
export class CDSISocket extends CDSSocketBase<CDSISocketOptionsType> { export class CDSISocket extends CDSSocketBase<CDSISocketOptionsType> {
private privCdsClient: Cds2Client | undefined; #privCdsClient: Cds2Client | undefined;
public override async handshake(): Promise<void> { public override async handshake(): Promise<void> {
strictAssert( strictAssert(
@ -31,23 +31,23 @@ export class CDSISocket extends CDSSocketBase<CDSISocketOptionsType> {
const earliestValidTimestamp = new Date(); const earliestValidTimestamp = new Date();
strictAssert( strictAssert(
this.privCdsClient === undefined, this.#privCdsClient === undefined,
'CDSI handshake called twice' 'CDSI handshake called twice'
); );
this.privCdsClient = Cds2Client.new( this.#privCdsClient = Cds2Client.new(
this.options.mrenclave, this.options.mrenclave,
attestationMessage, attestationMessage,
earliestValidTimestamp earliestValidTimestamp
); );
} }
this.socket.sendBytes(this.cdsClient.initialRequest()); this.socket.sendBytes(this.#cdsClient.initialRequest());
{ {
const { done, value: message } = await this.socketIterator.next(); const { done, value: message } = await this.socketIterator.next();
strictAssert(!done, 'CDSI socket expected handshake data'); strictAssert(!done, 'CDSI socket expected handshake data');
this.cdsClient.completeHandshake(message); this.#cdsClient.completeHandshake(message);
} }
this.state = CDSSocketState.Established; this.state = CDSSocketState.Established;
@ -57,7 +57,7 @@ export class CDSISocket extends CDSSocketBase<CDSISocketOptionsType> {
_version: number, _version: number,
request: Buffer request: Buffer
): Promise<void> { ): Promise<void> {
this.socket.sendBytes(this.cdsClient.establishedSend(request)); this.socket.sendBytes(this.#cdsClient.establishedSend(request));
const { done, value: ciphertext } = await this.socketIterator.next(); const { done, value: ciphertext } = await this.socketIterator.next();
strictAssert(!done, 'CDSISocket.sendRequest(): expected token message'); strictAssert(!done, 'CDSISocket.sendRequest(): expected token message');
@ -70,7 +70,7 @@ export class CDSISocket extends CDSSocketBase<CDSISocketOptionsType> {
strictAssert(token, 'CDSISocket.sendRequest(): expected token'); strictAssert(token, 'CDSISocket.sendRequest(): expected token');
this.socket.sendBytes( this.socket.sendBytes(
this.cdsClient.establishedSend( this.#cdsClient.establishedSend(
Buffer.from( Buffer.from(
Proto.CDSClientRequest.encode({ Proto.CDSClientRequest.encode({
tokenAck: true, tokenAck: true,
@ -83,15 +83,15 @@ export class CDSISocket extends CDSSocketBase<CDSISocketOptionsType> {
protected override async decryptResponse( protected override async decryptResponse(
ciphertext: Buffer ciphertext: Buffer
): Promise<Buffer> { ): Promise<Buffer> {
return this.cdsClient.establishedRecv(ciphertext); return this.#cdsClient.establishedRecv(ciphertext);
} }
// //
// Private // Private
// //
private get cdsClient(): Cds2Client { get #cdsClient(): Cds2Client {
strictAssert(this.privCdsClient, 'CDSISocket did not start handshake'); strictAssert(this.#privCdsClient, 'CDSISocket did not start handshake');
return this.privCdsClient; return this.#privCdsClient;
} }
} }

View file

@ -56,7 +56,7 @@ export abstract class CDSSocketBase<
this.logger = options.logger; this.logger = options.logger;
this.socket = options.socket; this.socket = options.socket;
this.socketIterator = this.iterateSocket(); this.socketIterator = this.#iterateSocket();
} }
public async close(code: number, reason: string): Promise<void> { public async close(code: number, reason: string): Promise<void> {
@ -161,7 +161,7 @@ export abstract class CDSSocketBase<
// Private // Private
// //
private iterateSocket(): AsyncIterator<Buffer> { #iterateSocket(): AsyncIterator<Buffer> {
const stream = new Readable({ read: noop, objectMode: true }); const stream = new Readable({ read: noop, objectMode: true });
this.socket.on('message', ({ type, binaryData }) => { this.socket.on('message', ({ type, binaryData }) => {

View file

@ -41,7 +41,7 @@ export abstract class CDSSocketManagerBase<
Socket extends CDSSocketBase, Socket extends CDSSocketBase,
Options extends CDSSocketManagerBaseOptionsType, Options extends CDSSocketManagerBaseOptionsType,
> extends CDSBase<Options> { > extends CDSBase<Options> {
private retryAfter?: number; #retryAfter?: number;
constructor( constructor(
private readonly libsignalNet: Net.Net, private readonly libsignalNet: Net.Net,
@ -55,27 +55,27 @@ export abstract class CDSSocketManagerBase<
): Promise<CDSResponseType> { ): Promise<CDSResponseType> {
const log = this.logger; const log = this.logger;
if (this.retryAfter !== undefined) { if (this.#retryAfter !== undefined) {
const delay = Math.max(0, this.retryAfter - Date.now()); const delay = Math.max(0, this.#retryAfter - Date.now());
log.info(`CDSSocketManager: waiting ${delay}ms before retrying`); log.info(`CDSSocketManager: waiting ${delay}ms before retrying`);
await sleep(delay); await sleep(delay);
} }
if (options.useLibsignal) { if (options.useLibsignal) {
return this.requestViaLibsignal(options); return this.#requestViaLibsignal(options);
} }
return this.requestViaNativeSocket(options); return this.#requestViaNativeSocket(options);
} }
private async requestViaNativeSocket( async #requestViaNativeSocket(
options: CDSRequestOptionsType options: CDSRequestOptionsType
): Promise<CDSResponseType> { ): Promise<CDSResponseType> {
const log = this.logger; const log = this.logger;
const auth = await this.getAuth(); const auth = await this.getAuth();
log.info('CDSSocketManager: connecting socket'); log.info('CDSSocketManager: connecting socket');
const socket = await this.connect(auth).getResult(); const socket = await this.#connect(auth).getResult();
log.info('CDSSocketManager: connected socket'); log.info('CDSSocketManager: connected socket');
try { try {
@ -97,8 +97,8 @@ export abstract class CDSSocketManagerBase<
} catch (error) { } catch (error) {
if (error instanceof RateLimitedError) { if (error instanceof RateLimitedError) {
if (error.retryAfterSecs > 0) { if (error.retryAfterSecs > 0) {
this.retryAfter = Math.max( this.#retryAfter = Math.max(
this.retryAfter ?? Date.now(), this.#retryAfter ?? Date.now(),
Date.now() + error.retryAfterSecs * durations.SECOND Date.now() + error.retryAfterSecs * durations.SECOND
); );
} }
@ -110,7 +110,7 @@ export abstract class CDSSocketManagerBase<
} }
} }
private async requestViaLibsignal( async #requestViaLibsignal(
options: CDSRequestOptionsType options: CDSRequestOptionsType
): Promise<CDSResponseType> { ): Promise<CDSResponseType> {
const log = this.logger; const log = this.logger;
@ -139,8 +139,8 @@ export abstract class CDSSocketManagerBase<
error.code === LibSignalErrorCode.RateLimitedError error.code === LibSignalErrorCode.RateLimitedError
) { ) {
const retryError = error as NetRateLimitedError; const retryError = error as NetRateLimitedError;
this.retryAfter = Math.max( this.#retryAfter = Math.max(
this.retryAfter ?? Date.now(), this.#retryAfter ?? Date.now(),
Date.now() + retryError.retryAfterSecs * durations.SECOND Date.now() + retryError.retryAfterSecs * durations.SECOND
); );
} }
@ -148,7 +148,7 @@ export abstract class CDSSocketManagerBase<
} }
} }
private connect(auth: CDSAuthType): AbortableProcess<Socket> { #connect(auth: CDSAuthType): AbortableProcess<Socket> {
return connectWebSocket<Socket>({ return connectWebSocket<Socket>({
name: 'CDSSocket', name: 'CDSSocket',
url: this.getSocketUrl(), url: this.getSocketUrl(),

View file

@ -142,7 +142,8 @@ export class User {
} }
public getDeviceId(): number | undefined { public getDeviceId(): number | undefined {
const value = this._getDeviceIdFromUuid() || this._getDeviceIdFromNumber(); const value =
this.#_getDeviceIdFromUuid() || this.#_getDeviceIdFromNumber();
if (value === undefined) { if (value === undefined) {
return undefined; return undefined;
} }
@ -202,7 +203,7 @@ export class User {
}; };
} }
private _getDeviceIdFromUuid(): string | undefined { #_getDeviceIdFromUuid(): string | undefined {
const uuid = this.storage.get('uuid_id'); const uuid = this.storage.get('uuid_id');
if (uuid === undefined) { if (uuid === undefined) {
return undefined; return undefined;
@ -210,7 +211,7 @@ export class User {
return Helpers.unencodeNumber(uuid)[1]; return Helpers.unencodeNumber(uuid)[1];
} }
private _getDeviceIdFromNumber(): string | undefined { #_getDeviceIdFromNumber(): string | undefined {
const numberId = this.storage.get('number_id'); const numberId = this.storage.get('number_id');
if (numberId === undefined) { if (numberId === undefined) {
return undefined; return undefined;

View file

@ -124,28 +124,23 @@ export abstract class Updater {
protected readonly logger: LoggerType; protected readonly logger: LoggerType;
private readonly settingsChannel: SettingsChannel; readonly #settingsChannel: SettingsChannel;
protected readonly getMainWindow: () => BrowserWindow | undefined; protected readonly getMainWindow: () => BrowserWindow | undefined;
private throttledSendDownloadingUpdate: (( #throttledSendDownloadingUpdate: ((
downloadedSize: number, downloadedSize: number,
downloadSize: number downloadSize: number
) => void) & { ) => void) & {
cancel: () => void; cancel: () => void;
}; };
private activeDownload: Promise<boolean> | undefined; #activeDownload: Promise<boolean> | undefined;
#markedCannotUpdate = false;
private markedCannotUpdate = false; #restarting = false;
readonly #canRunSilently: () => boolean;
private restarting = false; #autoRetryAttempts = 0;
#autoRetryAfter: number | undefined;
private readonly canRunSilently: () => boolean;
private autoRetryAttempts = 0;
private autoRetryAfter: number | undefined;
constructor({ constructor({
settingsChannel, settingsChannel,
@ -153,12 +148,12 @@ export abstract class Updater {
getMainWindow, getMainWindow,
canRunSilently, canRunSilently,
}: UpdaterOptionsType) { }: UpdaterOptionsType) {
this.settingsChannel = settingsChannel; this.#settingsChannel = settingsChannel;
this.logger = logger; this.logger = logger;
this.getMainWindow = getMainWindow; this.getMainWindow = getMainWindow;
this.canRunSilently = canRunSilently; this.#canRunSilently = canRunSilently;
this.throttledSendDownloadingUpdate = throttle( this.#throttledSendDownloadingUpdate = throttle(
(downloadedSize: number, downloadSize: number) => { (downloadedSize: number, downloadSize: number) => {
const mainWindow = this.getMainWindow(); const mainWindow = this.getMainWindow();
mainWindow?.webContents.send( mainWindow?.webContents.send(
@ -178,32 +173,32 @@ export abstract class Updater {
// //
public async force(): Promise<void> { public async force(): Promise<void> {
this.markedCannotUpdate = false; this.#markedCannotUpdate = false;
return this.checkForUpdatesMaybeInstall(CheckType.ForceDownload); return this.#checkForUpdatesMaybeInstall(CheckType.ForceDownload);
} }
// If the updater was about to restart the app but the user cancelled it, show dialog // If the updater was about to restart the app but the user cancelled it, show dialog
// to let them retry the restart // to let them retry the restart
public onRestartCancelled(): void { public onRestartCancelled(): void {
if (!this.restarting) { if (!this.#restarting) {
return; return;
} }
this.logger.info( this.logger.info(
'updater/onRestartCancelled: restart was cancelled. forcing update to reset updater state' 'updater/onRestartCancelled: restart was cancelled. forcing update to reset updater state'
); );
this.restarting = false; this.#restarting = false;
markShouldNotQuit(); markShouldNotQuit();
drop(this.checkForUpdatesMaybeInstall(CheckType.AllowSameVersion)); drop(this.#checkForUpdatesMaybeInstall(CheckType.AllowSameVersion));
} }
public async start(): Promise<void> { public async start(): Promise<void> {
this.logger.info('updater/start: starting checks...'); this.logger.info('updater/start: starting checks...');
this.schedulePoll(); this.#schedulePoll();
await this.deletePreviousInstallers(); await this.deletePreviousInstallers();
await this.checkForUpdatesMaybeInstall(CheckType.Normal); await this.#checkForUpdatesMaybeInstall(CheckType.Normal);
} }
// //
@ -232,14 +227,14 @@ export abstract class Updater {
error: Error, error: Error,
dialogType = DialogType.Cannot_Update dialogType = DialogType.Cannot_Update
): void { ): void {
if (this.markedCannotUpdate) { if (this.#markedCannotUpdate) {
this.logger.warn( this.logger.warn(
'updater/markCannotUpdate: already marked', 'updater/markCannotUpdate: already marked',
Errors.toLogFormat(error) Errors.toLogFormat(error)
); );
return; return;
} }
this.markedCannotUpdate = true; this.#markedCannotUpdate = true;
this.logger.error( this.logger.error(
'updater/markCannotUpdate: marking due to error: ' + 'updater/markCannotUpdate: marking due to error: ' +
@ -253,13 +248,13 @@ export abstract class Updater {
this.setUpdateListener(async () => { this.setUpdateListener(async () => {
this.logger.info('updater/markCannotUpdate: retrying after user action'); this.logger.info('updater/markCannotUpdate: retrying after user action');
this.markedCannotUpdate = false; this.#markedCannotUpdate = false;
await this.checkForUpdatesMaybeInstall(CheckType.Normal); await this.#checkForUpdatesMaybeInstall(CheckType.Normal);
}); });
} }
protected markRestarting(): void { protected markRestarting(): void {
this.restarting = true; this.#restarting = true;
markShouldQuit(); markShouldQuit();
} }
@ -267,7 +262,7 @@ export abstract class Updater {
// Private methods // Private methods
// //
private schedulePoll(): void { #schedulePoll(): void {
const now = Date.now(); const now = Date.now();
const earliestPollTime = now - (now % POLL_INTERVAL) + POLL_INTERVAL; const earliestPollTime = now - (now % POLL_INTERVAL) + POLL_INTERVAL;
@ -279,46 +274,46 @@ export abstract class Updater {
this.logger.info(`updater/schedulePoll: polling in ${timeoutMs}ms`); this.logger.info(`updater/schedulePoll: polling in ${timeoutMs}ms`);
setTimeout(() => { setTimeout(() => {
drop(this.safePoll()); drop(this.#safePoll());
}, timeoutMs); }, timeoutMs);
} }
private async safePoll(): Promise<void> { async #safePoll(): Promise<void> {
try { try {
if (this.autoRetryAfter != null && Date.now() < this.autoRetryAfter) { if (this.#autoRetryAfter != null && Date.now() < this.#autoRetryAfter) {
this.logger.info( this.logger.info(
`updater/safePoll: not polling until ${this.autoRetryAfter}` `updater/safePoll: not polling until ${this.#autoRetryAfter}`
); );
return; return;
} }
this.logger.info('updater/safePoll: polling now'); this.logger.info('updater/safePoll: polling now');
await this.checkForUpdatesMaybeInstall(CheckType.Normal); await this.#checkForUpdatesMaybeInstall(CheckType.Normal);
} catch (error) { } catch (error) {
this.logger.error(`updater/safePoll: ${Errors.toLogFormat(error)}`); this.logger.error(`updater/safePoll: ${Errors.toLogFormat(error)}`);
} finally { } finally {
this.schedulePoll(); this.#schedulePoll();
} }
} }
private async downloadAndInstall( async #downloadAndInstall(
updateInfo: UpdateInformationType, updateInfo: UpdateInformationType,
mode: DownloadMode mode: DownloadMode
): Promise<boolean> { ): Promise<boolean> {
if (this.activeDownload) { if (this.#activeDownload) {
return this.activeDownload; return this.#activeDownload;
} }
try { try {
this.activeDownload = this.doDownloadAndInstall(updateInfo, mode); this.#activeDownload = this.#doDownloadAndInstall(updateInfo, mode);
return await this.activeDownload; return await this.#activeDownload;
} finally { } finally {
this.activeDownload = undefined; this.#activeDownload = undefined;
} }
} }
private async doDownloadAndInstall( async #doDownloadAndInstall(
updateInfo: UpdateInformationType, updateInfo: UpdateInformationType,
mode: DownloadMode mode: DownloadMode
): Promise<boolean> { ): Promise<boolean> {
@ -333,22 +328,22 @@ export abstract class Updater {
let downloadResult: DownloadUpdateResultType | undefined; let downloadResult: DownloadUpdateResultType | undefined;
try { try {
downloadResult = await this.downloadUpdate(updateInfo, mode); downloadResult = await this.#downloadUpdate(updateInfo, mode);
} catch (error) { } catch (error) {
// Restore state in case of download error // Restore state in case of download error
this.version = oldVersion; this.version = oldVersion;
if ( if (
mode === DownloadMode.Automatic && mode === DownloadMode.Automatic &&
this.autoRetryAttempts < MAX_AUTO_RETRY_ATTEMPTS this.#autoRetryAttempts < MAX_AUTO_RETRY_ATTEMPTS
) { ) {
this.autoRetryAttempts += 1; this.#autoRetryAttempts += 1;
this.autoRetryAfter = Date.now() + AUTO_RETRY_DELAY; this.#autoRetryAfter = Date.now() + AUTO_RETRY_DELAY;
logger.warn( logger.warn(
'downloadAndInstall: transient error ' + 'downloadAndInstall: transient error ' +
`${Errors.toLogFormat(error)}, ` + `${Errors.toLogFormat(error)}, ` +
`attempts=${this.autoRetryAttempts}, ` + `attempts=${this.#autoRetryAttempts}, ` +
`retryAfter=${this.autoRetryAfter}` `retryAfter=${this.#autoRetryAfter}`
); );
return false; return false;
} }
@ -356,8 +351,8 @@ export abstract class Updater {
throw error; throw error;
} }
this.autoRetryAttempts = 0; this.#autoRetryAttempts = 0;
this.autoRetryAfter = undefined; this.#autoRetryAfter = undefined;
if (!downloadResult) { if (!downloadResult) {
logger.warn('downloadAndInstall: no update was downloaded'); logger.warn('downloadAndInstall: no update was downloaded');
@ -391,7 +386,7 @@ export abstract class Updater {
const isSilent = const isSilent =
updateInfo.vendor?.requireUserConfirmation !== 'true' && updateInfo.vendor?.requireUserConfirmation !== 'true' &&
this.canRunSilently(); this.#canRunSilently();
const handler = await this.installUpdate(updateFilePath, isSilent); const handler = await this.installUpdate(updateFilePath, isSilent);
if (isSilent || mode === DownloadMode.ForceUpdate) { if (isSilent || mode === DownloadMode.ForceUpdate) {
@ -430,13 +425,11 @@ export abstract class Updater {
} }
} }
private async checkForUpdatesMaybeInstall( async #checkForUpdatesMaybeInstall(checkType: CheckType): Promise<void> {
checkType: CheckType
): Promise<void> {
const { logger } = this; const { logger } = this;
logger.info('checkForUpdatesMaybeInstall: checking for update...'); logger.info('checkForUpdatesMaybeInstall: checking for update...');
const updateInfo = await this.checkForUpdates(checkType); const updateInfo = await this.#checkForUpdates(checkType);
if (!updateInfo) { if (!updateInfo) {
return; return;
} }
@ -444,7 +437,7 @@ export abstract class Updater {
const { version: newVersion } = updateInfo; const { version: newVersion } = updateInfo;
if (checkType === CheckType.ForceDownload) { if (checkType === CheckType.ForceDownload) {
await this.downloadAndInstall(updateInfo, DownloadMode.ForceUpdate); await this.#downloadAndInstall(updateInfo, DownloadMode.ForceUpdate);
return; return;
} }
@ -462,9 +455,9 @@ export abstract class Updater {
throw missingCaseError(checkType); throw missingCaseError(checkType);
} }
const autoDownloadUpdates = await this.getAutoDownloadUpdateSetting(); const autoDownloadUpdates = await this.#getAutoDownloadUpdateSetting();
if (autoDownloadUpdates) { if (autoDownloadUpdates) {
await this.downloadAndInstall(updateInfo, DownloadMode.Automatic); await this.#downloadAndInstall(updateInfo, DownloadMode.Automatic);
return; return;
} }
@ -473,10 +466,10 @@ export abstract class Updater {
mode = DownloadMode.DifferentialOnly; mode = DownloadMode.DifferentialOnly;
} }
await this.offerUpdate(updateInfo, mode, 0); await this.#offerUpdate(updateInfo, mode, 0);
} }
private async offerUpdate( async #offerUpdate(
updateInfo: UpdateInformationType, updateInfo: UpdateInformationType,
mode: DownloadMode, mode: DownloadMode,
attempt: number attempt: number
@ -486,13 +479,17 @@ export abstract class Updater {
this.setUpdateListener(async () => { this.setUpdateListener(async () => {
logger.info('offerUpdate: have not downloaded update, going to download'); logger.info('offerUpdate: have not downloaded update, going to download');
const didDownload = await this.downloadAndInstall(updateInfo, mode); const didDownload = await this.#downloadAndInstall(updateInfo, mode);
if (!didDownload && mode === DownloadMode.DifferentialOnly) { if (!didDownload && mode === DownloadMode.DifferentialOnly) {
this.logger.warn( this.logger.warn(
'offerUpdate: Failed to download differential update, offering full' 'offerUpdate: Failed to download differential update, offering full'
); );
this.throttledSendDownloadingUpdate.cancel(); this.#throttledSendDownloadingUpdate.cancel();
return this.offerUpdate(updateInfo, DownloadMode.FullOnly, attempt + 1); return this.#offerUpdate(
updateInfo,
DownloadMode.FullOnly,
attempt + 1
);
} }
strictAssert(didDownload, 'FullOnly must always download update'); strictAssert(didDownload, 'FullOnly must always download update');
@ -527,7 +524,7 @@ export abstract class Updater {
); );
} }
private async checkForUpdates( async #checkForUpdates(
checkType: CheckType checkType: CheckType
): Promise<UpdateInformationType | undefined> { ): Promise<UpdateInformationType | undefined> {
if (isAdhoc(packageJson.version)) { if (isAdhoc(packageJson.version)) {
@ -591,13 +588,13 @@ export abstract class Updater {
const fileName = getUpdateFileName( const fileName = getUpdateFileName(
parsedYaml, parsedYaml,
process.platform, process.platform,
await this.getArch() await this.#getArch()
); );
const sha512 = getSHA512(parsedYaml, fileName); const sha512 = getSHA512(parsedYaml, fileName);
strictAssert(sha512 !== undefined, 'Missing required hash'); strictAssert(sha512 !== undefined, 'Missing required hash');
const latestInstaller = await this.getLatestCachedInstaller( const latestInstaller = await this.#getLatestCachedInstaller(
extname(fileName) extname(fileName)
); );
@ -650,7 +647,7 @@ export abstract class Updater {
}; };
} }
private async getLatestCachedInstaller( async #getLatestCachedInstaller(
extension: string extension: string
): Promise<string | undefined> { ): Promise<string | undefined> {
const cacheDir = await createUpdateCacheDirIfNeeded(); const cacheDir = await createUpdateCacheDirIfNeeded();
@ -661,7 +658,7 @@ export abstract class Updater {
return oldFiles.find(fileName => extname(fileName) === extension); return oldFiles.find(fileName => extname(fileName) === extension);
} }
private async downloadUpdate( async #downloadUpdate(
{ fileName, sha512, differentialData, size }: UpdateInformationType, { fileName, sha512, differentialData, size }: UpdateInformationType,
mode: DownloadMode mode: DownloadMode
): Promise<DownloadUpdateResultType | undefined> { ): Promise<DownloadUpdateResultType | undefined> {
@ -757,7 +754,7 @@ export abstract class Updater {
try { try {
await downloadDifferentialData(tempUpdatePath, differentialData, { await downloadDifferentialData(tempUpdatePath, differentialData, {
statusCallback: updateOnProgress statusCallback: updateOnProgress
? this.throttledSendDownloadingUpdate ? this.#throttledSendDownloadingUpdate
: undefined, : undefined,
logger: this.logger, logger: this.logger,
}); });
@ -783,7 +780,7 @@ export abstract class Updater {
await gracefulRmRecursive(this.logger, cacheDir); await gracefulRmRecursive(this.logger, cacheDir);
cacheDir = await createUpdateCacheDirIfNeeded(); cacheDir = await createUpdateCacheDirIfNeeded();
await this.downloadAndReport( await this.#downloadAndReport(
updateFileUrl, updateFileUrl,
size, size,
tempUpdatePath, tempUpdatePath,
@ -854,7 +851,7 @@ export abstract class Updater {
} }
} }
private async downloadAndReport( async #downloadAndReport(
updateFileUrl: string, updateFileUrl: string,
downloadSize: number, downloadSize: number,
targetUpdatePath: string, targetUpdatePath: string,
@ -869,7 +866,7 @@ export abstract class Updater {
downloadStream.on('data', data => { downloadStream.on('data', data => {
downloadedSize += data.length; downloadedSize += data.length;
this.throttledSendDownloadingUpdate(downloadedSize, downloadSize); this.#throttledSendDownloadingUpdate(downloadedSize, downloadSize);
}); });
} }
@ -888,9 +885,9 @@ export abstract class Updater {
}); });
} }
private async getAutoDownloadUpdateSetting(): Promise<boolean> { async #getAutoDownloadUpdateSetting(): Promise<boolean> {
try { try {
return await this.settingsChannel.getSettingFromMainWindow( return await this.#settingsChannel.getSettingFromMainWindow(
'autoDownloadUpdate' 'autoDownloadUpdate'
); );
} catch (error) { } catch (error) {
@ -902,7 +899,7 @@ export abstract class Updater {
} }
} }
private async getArch(): Promise<typeof process.arch> { async #getArch(): Promise<typeof process.arch> {
if (process.arch === 'arm64') { if (process.arch === 'arm64') {
return process.arch; return process.arch;
} }

View file

@ -23,7 +23,7 @@ export class MacOSUpdater extends Updater {
logger.info('downloadAndInstall: handing download to electron...'); logger.info('downloadAndInstall: handing download to electron...');
try { try {
await this.handToAutoUpdate(updateFilePath); await this.#handToAutoUpdate(updateFilePath);
} catch (error) { } catch (error) {
const readOnly = 'Cannot update while running on a read-only volume'; const readOnly = 'Cannot update while running on a read-only volume';
const message: string = error.message || ''; const message: string = error.message || '';
@ -47,7 +47,7 @@ export class MacOSUpdater extends Updater {
}; };
} }
private async handToAutoUpdate(filePath: string): Promise<void> { async #handToAutoUpdate(filePath: string): Promise<void> {
const { logger } = this; const { logger } = this;
const { promise, resolve, reject } = explodePromise<void>(); const { promise, resolve, reject } = explodePromise<void>();

View file

@ -17,7 +17,7 @@ const unlink = pify(unlinkCallback);
const IS_EXE = /\.exe$/i; const IS_EXE = /\.exe$/i;
export class WindowsUpdater extends Updater { export class WindowsUpdater extends Updater {
private installing = false; #installing = false;
// This is fixed by our new install mechanisms... // This is fixed by our new install mechanisms...
// https://github.com/signalapp/Signal-Desktop/issues/2369 // https://github.com/signalapp/Signal-Desktop/issues/2369
@ -52,8 +52,8 @@ export class WindowsUpdater extends Updater {
return async () => { return async () => {
logger.info('downloadAndInstall: installing...'); logger.info('downloadAndInstall: installing...');
try { try {
await this.install(updateFilePath, isSilent); await this.#install(updateFilePath, isSilent);
this.installing = true; this.#installing = true;
} catch (error) { } catch (error) {
this.markCannotUpdate(error); this.markCannotUpdate(error);
@ -72,8 +72,8 @@ export class WindowsUpdater extends Updater {
app.quit(); app.quit();
} }
private async install(filePath: string, isSilent: boolean): Promise<void> { async #install(filePath: string, isSilent: boolean): Promise<void> {
if (this.installing) { if (this.#installing) {
return; return;
} }

View file

@ -9,7 +9,7 @@ export interface IController {
} }
export class AbortableProcess<Result> implements IController { export class AbortableProcess<Result> implements IController {
private abortReject: (error: Error) => void; #abortReject: (error: Error) => void;
public readonly resultPromise: Promise<Result>; public readonly resultPromise: Promise<Result>;
@ -21,13 +21,13 @@ export class AbortableProcess<Result> implements IController {
const { promise: abortPromise, reject: abortReject } = const { promise: abortPromise, reject: abortReject } =
explodePromise<Result>(); explodePromise<Result>();
this.abortReject = abortReject; this.#abortReject = abortReject;
this.resultPromise = Promise.race([abortPromise, resultPromise]); this.resultPromise = Promise.race([abortPromise, resultPromise]);
} }
public abort(): void { public abort(): void {
this.controller.abort(); this.controller.abort();
this.abortReject(new Error(`Process "${this.name}" was aborted`)); this.#abortReject(new Error(`Process "${this.name}" was aborted`));
} }
public getResult(): Promise<Result> { public getResult(): Promise<Result> {

View file

@ -16,32 +16,30 @@ import { once, noop } from 'lodash';
* See the tests to see how this works. * See the tests to see how this works.
*/ */
export class AsyncQueue<T> implements AsyncIterable<T> { export class AsyncQueue<T> implements AsyncIterable<T> {
private onAdd: () => void = noop; #onAdd: () => void = noop;
#queue: Array<T> = [];
private queue: Array<T> = []; #isReading = false;
private isReading = false;
add(value: Readonly<T>): void { add(value: Readonly<T>): void {
this.queue.push(value); this.#queue.push(value);
this.onAdd(); this.#onAdd();
} }
async *[Symbol.asyncIterator](): AsyncIterator<T> { async *[Symbol.asyncIterator](): AsyncIterator<T> {
if (this.isReading) { if (this.#isReading) {
throw new Error('Cannot iterate over a queue more than once'); throw new Error('Cannot iterate over a queue more than once');
} }
this.isReading = true; this.#isReading = true;
while (true) { while (true) {
yield* this.queue; yield* this.#queue;
this.queue = []; this.#queue = [];
// We want to iterate over the queue in series. // We want to iterate over the queue in series.
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
await new Promise<void>(resolve => { await new Promise<void>(resolve => {
this.onAdd = once(resolve); this.#onAdd = once(resolve);
}); });
} }
} }

View file

@ -38,7 +38,7 @@ export type BackOffOptionsType = Readonly<{
const DEFAULT_RANDOM = () => Math.random(); const DEFAULT_RANDOM = () => Math.random();
export class BackOff { export class BackOff {
private count = 0; #count = 0;
constructor( constructor(
private timeouts: ReadonlyArray<number>, private timeouts: ReadonlyArray<number>,
@ -46,7 +46,7 @@ export class BackOff {
) {} ) {}
public get(): number { public get(): number {
let result = this.timeouts[this.count]; let result = this.timeouts[this.#count];
const { jitter = 0, random = DEFAULT_RANDOM } = this.options; const { jitter = 0, random = DEFAULT_RANDOM } = this.options;
// Do not apply jitter larger than the timeout value. It is supposed to be // Do not apply jitter larger than the timeout value. It is supposed to be
@ -60,7 +60,7 @@ export class BackOff {
public getAndIncrement(): number { public getAndIncrement(): number {
const result = this.get(); const result = this.get();
if (!this.isFull()) { if (!this.isFull()) {
this.count += 1; this.#count += 1;
} }
return result; return result;
@ -70,14 +70,14 @@ export class BackOff {
if (newTimeouts !== undefined) { if (newTimeouts !== undefined) {
this.timeouts = newTimeouts; this.timeouts = newTimeouts;
} }
this.count = 0; this.#count = 0;
} }
public isFull(): boolean { public isFull(): boolean {
return this.count === this.timeouts.length - 1; return this.#count === this.timeouts.length - 1;
} }
public getIndex(): number { public getIndex(): number {
return this.count; return this.#count;
} }
} }

View file

@ -11,10 +11,10 @@ enum State {
} }
export class DelimitedStream extends Transform { export class DelimitedStream extends Transform {
private state = State.Prefix; #state = State.Prefix;
private prefixValue = 0; #prefixValue = 0;
private prefixSize = 0; #prefixSize = 0;
private parts = new Array<Buffer>(); #parts = new Array<Buffer>();
constructor() { constructor() {
super({ readableObjectMode: true }); super({ readableObjectMode: true });
@ -27,7 +27,7 @@ export class DelimitedStream extends Transform {
): void { ): void {
let offset = 0; let offset = 0;
while (offset < chunk.length) { while (offset < chunk.length) {
if (this.state === State.Prefix) { if (this.#state === State.Prefix) {
const b = chunk[offset]; const b = chunk[offset];
offset += 1; offset += 1;
@ -38,50 +38,50 @@ export class DelimitedStream extends Transform {
const value = b & 0x7f; const value = b & 0x7f;
// eslint-disable-next-line no-bitwise // eslint-disable-next-line no-bitwise
this.prefixValue |= value << (7 * this.prefixSize); this.#prefixValue |= value << (7 * this.#prefixSize);
this.prefixSize += 1; this.#prefixSize += 1;
// Check that we didn't go over 32bits. Node.js buffers can never // Check that we didn't go over 32bits. Node.js buffers can never
// be larger than 2gb anyway! // be larger than 2gb anyway!
if (this.prefixSize > 4) { if (this.#prefixSize > 4) {
done(new Error('Delimiter encoding overflow')); done(new Error('Delimiter encoding overflow'));
return; return;
} }
if (isLast) { if (isLast) {
this.state = State.Data; this.#state = State.Data;
} }
} else if (this.state === State.Data) { } else if (this.#state === State.Data) {
const toTake = Math.min(this.prefixValue, chunk.length - offset); const toTake = Math.min(this.#prefixValue, chunk.length - offset);
const part = chunk.slice(offset, offset + toTake); const part = chunk.slice(offset, offset + toTake);
offset += toTake; offset += toTake;
this.prefixValue -= toTake; this.#prefixValue -= toTake;
this.parts.push(part); this.#parts.push(part);
if (this.prefixValue <= 0) { if (this.#prefixValue <= 0) {
this.state = State.Prefix; this.#state = State.Prefix;
this.prefixSize = 0; this.#prefixSize = 0;
this.prefixValue = 0; this.#prefixValue = 0;
const whole = Buffer.concat(this.parts); const whole = Buffer.concat(this.#parts);
this.parts = []; this.#parts = [];
this.push(whole); this.push(whole);
} }
} else { } else {
throw missingCaseError(this.state); throw missingCaseError(this.#state);
} }
} }
done(); done();
} }
override _flush(done: (error?: Error) => void): void { override _flush(done: (error?: Error) => void): void {
if (this.state !== State.Prefix) { if (this.#state !== State.Prefix) {
done(new Error('Unfinished data')); done(new Error('Unfinished data'));
return; return;
} }
if (this.prefixSize !== 0) { if (this.#prefixSize !== 0) {
done(new Error('Unfinished prefix')); done(new Error('Unfinished prefix'));
return; return;
} }

View file

@ -20,15 +20,13 @@
import { drop } from './drop'; import { drop } from './drop';
export class LatestQueue { export class LatestQueue {
private isRunning: boolean; #isRunning: boolean;
#queuedTask?: () => Promise<void>;
private queuedTask?: () => Promise<void>; #onceEmptyCallbacks: Array<() => unknown>;
private onceEmptyCallbacks: Array<() => unknown>;
constructor() { constructor() {
this.isRunning = false; this.#isRunning = false;
this.onceEmptyCallbacks = []; this.#onceEmptyCallbacks = [];
} }
/** /**
@ -39,25 +37,25 @@ export class LatestQueue {
* tasks will be enqueued at a time. * tasks will be enqueued at a time.
*/ */
add(task: () => Promise<void>): void { add(task: () => Promise<void>): void {
if (this.isRunning) { if (this.#isRunning) {
this.queuedTask = task; this.#queuedTask = task;
} else { } else {
this.isRunning = true; this.#isRunning = true;
drop( drop(
task().finally(() => { task().finally(() => {
this.isRunning = false; this.#isRunning = false;
const { queuedTask } = this; const queuedTask = this.#queuedTask;
if (queuedTask) { if (queuedTask) {
this.queuedTask = undefined; this.#queuedTask = undefined;
this.add(queuedTask); this.add(queuedTask);
} else { } else {
try { try {
this.onceEmptyCallbacks.forEach(callback => { this.#onceEmptyCallbacks.forEach(callback => {
callback(); callback();
}); });
} finally { } finally {
this.onceEmptyCallbacks = []; this.#onceEmptyCallbacks = [];
} }
} }
}) })
@ -69,6 +67,6 @@ export class LatestQueue {
* Adds a callback to be called the first time the queue goes from "running" to "empty". * Adds a callback to be called the first time the queue goes from "running" to "empty".
*/ */
onceEmpty(callback: () => unknown): void { onceEmpty(callback: () => unknown): void {
this.onceEmptyCallbacks.push(callback); this.#onceEmptyCallbacks.push(callback);
} }
} }

View file

@ -26,26 +26,24 @@ export class Sound {
private static context: AudioContext | undefined; private static context: AudioContext | undefined;
private readonly loop: boolean; readonly #loop: boolean;
#node?: AudioBufferSourceNode;
private node?: AudioBufferSourceNode; readonly #soundType: SoundType;
private readonly soundType: SoundType;
constructor(options: SoundOpts) { constructor(options: SoundOpts) {
this.loop = Boolean(options.loop); this.#loop = Boolean(options.loop);
this.soundType = options.soundType; this.#soundType = options.soundType;
} }
async play(): Promise<void> { async play(): Promise<void> {
let soundBuffer = Sound.sounds.get(this.soundType); let soundBuffer = Sound.sounds.get(this.#soundType);
if (!soundBuffer) { if (!soundBuffer) {
try { try {
const src = Sound.getSrc(this.soundType); const src = Sound.getSrc(this.#soundType);
const buffer = await Sound.loadSoundFile(src); const buffer = await Sound.loadSoundFile(src);
const decodedBuffer = await this.context.decodeAudioData(buffer); const decodedBuffer = await this.#context.decodeAudioData(buffer);
Sound.sounds.set(this.soundType, decodedBuffer); Sound.sounds.set(this.#soundType, decodedBuffer);
soundBuffer = decodedBuffer; soundBuffer = decodedBuffer;
} catch (err) { } catch (err) {
log.error(`Sound error: ${err}`); log.error(`Sound error: ${err}`);
@ -53,28 +51,28 @@ export class Sound {
} }
} }
const soundNode = this.context.createBufferSource(); const soundNode = this.#context.createBufferSource();
soundNode.buffer = soundBuffer; soundNode.buffer = soundBuffer;
const volumeNode = this.context.createGain(); const volumeNode = this.#context.createGain();
soundNode.connect(volumeNode); soundNode.connect(volumeNode);
volumeNode.connect(this.context.destination); volumeNode.connect(this.#context.destination);
soundNode.loop = this.loop; soundNode.loop = this.#loop;
soundNode.start(0, 0); soundNode.start(0, 0);
this.node = soundNode; this.#node = soundNode;
} }
stop(): void { stop(): void {
if (this.node) { if (this.#node) {
this.node.stop(0); this.#node.stop(0);
this.node = undefined; this.#node = undefined;
} }
} }
private get context(): AudioContext { get #context(): AudioContext {
if (!Sound.context) { if (!Sound.context) {
Sound.context = new AudioContext(); Sound.context = new AudioContext();
} }

View file

@ -13,30 +13,31 @@ type EntryType = Readonly<{
let startupProcessingQueue: StartupQueue | undefined; let startupProcessingQueue: StartupQueue | undefined;
export class StartupQueue { export class StartupQueue {
private readonly map = new Map<string, EntryType>(); readonly #map = new Map<string, EntryType>();
private readonly running: PQueue = new PQueue({
readonly #running: PQueue = new PQueue({
// mostly io-bound work that is not very parallelizable // mostly io-bound work that is not very parallelizable
// small number should be sufficient // small number should be sufficient
concurrency: 5, concurrency: 5,
}); });
public add(id: string, value: number, f: () => Promise<void>): void { public add(id: string, value: number, f: () => Promise<void>): void {
const existing = this.map.get(id); const existing = this.#map.get(id);
if (existing && existing.value >= value) { if (existing && existing.value >= value) {
return; return;
} }
this.map.set(id, { value, callback: f }); this.#map.set(id, { value, callback: f });
} }
public flush(): void { public flush(): void {
log.info('StartupQueue: Processing', this.map.size, 'actions'); log.info('StartupQueue: Processing', this.#map.size, 'actions');
const values = Array.from(this.map.values()); const values = Array.from(this.#map.values());
this.map.clear(); this.#map.clear();
for (const { callback } of values) { for (const { callback } of values) {
void this.running.add(async () => { void this.#running.add(async () => {
try { try {
return callback(); return callback();
} catch (error) { } catch (error) {
@ -50,11 +51,11 @@ export class StartupQueue {
} }
} }
private shutdown(): Promise<void> { #shutdown(): Promise<void> {
log.info( log.info(
`StartupQueue: Waiting for ${this.running.pending} tasks to drain` `StartupQueue: Waiting for ${this.#running.pending} tasks to drain`
); );
return this.running.onIdle(); return this.#running.onIdle();
} }
static initialize(): void { static initialize(): void {
@ -75,6 +76,8 @@ export class StartupQueue {
} }
static async shutdown(): Promise<void> { static async shutdown(): Promise<void> {
await startupProcessingQueue?.shutdown(); if (startupProcessingQueue != null) {
await startupProcessingQueue.#shutdown();
}
} }
} }

View file

@ -12,7 +12,7 @@ const ringtoneEventQueue = new PQueue({
}); });
class CallingTones { class CallingTones {
private ringtone?: Sound; #ringtone?: Sound;
async handRaised() { async handRaised() {
const canPlayTone = window.Events.getCallRingtoneNotification(); const canPlayTone = window.Events.getCallRingtoneNotification();
@ -41,9 +41,9 @@ class CallingTones {
async playRingtone() { async playRingtone() {
await ringtoneEventQueue.add(async () => { await ringtoneEventQueue.add(async () => {
if (this.ringtone) { if (this.#ringtone) {
this.ringtone.stop(); this.#ringtone.stop();
this.ringtone = undefined; this.#ringtone = undefined;
} }
const canPlayTone = window.Events.getCallRingtoneNotification(); const canPlayTone = window.Events.getCallRingtoneNotification();
@ -51,20 +51,20 @@ class CallingTones {
return; return;
} }
this.ringtone = new Sound({ this.#ringtone = new Sound({
loop: true, loop: true,
soundType: SoundType.Ringtone, soundType: SoundType.Ringtone,
}); });
await this.ringtone.play(); await this.#ringtone.play();
}); });
} }
async stopRingtone() { async stopRingtone() {
await ringtoneEventQueue.add(async () => { await ringtoneEventQueue.add(async () => {
if (this.ringtone) { if (this.#ringtone) {
this.ringtone.stop(); this.#ringtone.stop();
this.ringtone = undefined; this.#ringtone = undefined;
} }
}); });
} }

View file

@ -87,7 +87,7 @@ export type DesktopCapturerBaton = Readonly<{
}>; }>;
export class DesktopCapturer { export class DesktopCapturer {
private state: State; #state: State;
private static getDisplayMediaPromise: Promise<MediaStream> | undefined; private static getDisplayMediaPromise: Promise<MediaStream> | undefined;
@ -102,47 +102,47 @@ export class DesktopCapturer {
} }
if (macScreenShare.isSupported) { if (macScreenShare.isSupported) {
this.state = { this.#state = {
step: Step.NativeMacOS, step: Step.NativeMacOS,
stream: this.getNativeMacOSStream(), stream: this.#getNativeMacOSStream(),
}; };
} else { } else {
this.state = { step: Step.RequestingMedia, promise: this.getStream() }; this.#state = { step: Step.RequestingMedia, promise: this.#getStream() };
} }
} }
public abort(): void { public abort(): void {
if (this.state.step === Step.NativeMacOS) { if (this.#state.step === Step.NativeMacOS) {
this.state.stream.stop(); this.#state.stream.stop();
} }
if (this.state.step === Step.SelectingSource) { if (this.#state.step === Step.SelectingSource) {
this.state.onSource(undefined); this.#state.onSource(undefined);
} }
this.state = { step: Step.Error }; this.#state = { step: Step.Error };
} }
public selectSource(id: string): void { public selectSource(id: string): void {
strictAssert( strictAssert(
this.state.step === Step.SelectingSource, this.#state.step === Step.SelectingSource,
`Invalid state in "selectSource" ${this.state.step}` `Invalid state in "selectSource" ${this.#state.step}`
); );
const { promise, sources, onSource } = this.state; const { promise, sources, onSource } = this.#state;
const source = id == null ? undefined : sources.find(s => s.id === id); const source = id == null ? undefined : sources.find(s => s.id === id);
this.state = { step: Step.SelectedSource, promise }; this.#state = { step: Step.SelectedSource, promise };
onSource(source); onSource(source);
} }
/** @internal */ /** @internal */
private onSources( #onSources(
sources: ReadonlyArray<DesktopCapturerSource> sources: ReadonlyArray<DesktopCapturerSource>
): Promise<DesktopCapturerSource | undefined> { ): Promise<DesktopCapturerSource | undefined> {
strictAssert( strictAssert(
this.state.step === Step.RequestingMedia, this.#state.step === Step.RequestingMedia,
`Invalid state in "onSources" ${this.state.step}` `Invalid state in "onSources" ${this.#state.step}`
); );
const presentableSources = sources const presentableSources = sources
@ -158,25 +158,25 @@ export class DesktopCapturer {
? source.appIcon.toDataURL() ? source.appIcon.toDataURL()
: undefined, : undefined,
id: source.id, id: source.id,
name: this.translateSourceName(source), name: this.#translateSourceName(source),
isScreen: isScreenSource(source), isScreen: isScreenSource(source),
thumbnail: source.thumbnail.toDataURL(), thumbnail: source.thumbnail.toDataURL(),
}; };
}) })
.filter(isNotNil); .filter(isNotNil);
const { promise } = this.state; const { promise } = this.#state;
const { promise: source, resolve: onSource } = explodePromise< const { promise: source, resolve: onSource } = explodePromise<
DesktopCapturerSource | undefined DesktopCapturerSource | undefined
>(); >();
this.state = { step: Step.SelectingSource, promise, sources, onSource }; this.#state = { step: Step.SelectingSource, promise, sources, onSource };
this.options.onPresentableSources(presentableSources); this.options.onPresentableSources(presentableSources);
return source; return source;
} }
private async getStream(): Promise<void> { async #getStream(): Promise<void> {
liveCapturers.add(this); liveCapturers.add(this);
try { try {
// Only allow one global getDisplayMedia() request at a time // Only allow one global getDisplayMedia() request at a time
@ -210,28 +210,28 @@ export class DesktopCapturer {
}); });
strictAssert( strictAssert(
this.state.step === Step.RequestingMedia || this.#state.step === Step.RequestingMedia ||
this.state.step === Step.SelectedSource, this.#state.step === Step.SelectedSource,
`Invalid state in "getStream.success" ${this.state.step}` `Invalid state in "getStream.success" ${this.#state.step}`
); );
this.options.onMediaStream(stream); this.options.onMediaStream(stream);
this.state = { step: Step.Done }; this.#state = { step: Step.Done };
} catch (error) { } catch (error) {
strictAssert( strictAssert(
this.state.step === Step.RequestingMedia || this.#state.step === Step.RequestingMedia ||
this.state.step === Step.SelectedSource, this.#state.step === Step.SelectedSource,
`Invalid state in "getStream.error" ${this.state.step}` `Invalid state in "getStream.error" ${this.#state.step}`
); );
this.options.onError(error); this.options.onError(error);
this.state = { step: Step.Error }; this.#state = { step: Step.Error };
} finally { } finally {
liveCapturers.delete(this); liveCapturers.delete(this);
DesktopCapturer.getDisplayMediaPromise = undefined; DesktopCapturer.getDisplayMediaPromise = undefined;
} }
} }
private getNativeMacOSStream(): macScreenShare.Stream { #getNativeMacOSStream(): macScreenShare.Stream {
const track = new MediaStreamTrackGenerator({ kind: 'video' }); const track = new MediaStreamTrackGenerator({ kind: 'video' });
const writer = track.writable.getWriter(); const writer = track.writable.getWriter();
@ -311,7 +311,7 @@ export class DesktopCapturer {
return stream; return stream;
} }
private translateSourceName(source: DesktopCapturerSource): string { #translateSourceName(source: DesktopCapturerSource): string {
const { i18n } = this.options; const { i18n } = this.options;
const { name } = source; const { name } = source;
@ -345,7 +345,7 @@ export class DesktopCapturer {
strictAssert(!done, 'No capturer available for incoming sources'); strictAssert(!done, 'No capturer available for incoming sources');
liveCapturers.delete(capturer); liveCapturers.delete(capturer);
selected = await capturer.onSources(sources); selected = await capturer.#onSources(sources);
} catch (error) { } catch (error) {
log.error( log.error(
'desktopCapturer: failed to get the source', 'desktopCapturer: failed to get the source',

View file

@ -283,17 +283,17 @@ class RepeatIterable<T> implements Iterable<T> {
} }
class RepeatIterator<T> implements Iterator<T> { class RepeatIterator<T> implements Iterator<T> {
private readonly iteratorResult: IteratorResult<T>; readonly #iteratorResult: IteratorResult<T>;
constructor(value: Readonly<T>) { constructor(value: Readonly<T>) {
this.iteratorResult = { this.#iteratorResult = {
done: false, done: false,
value, value,
}; };
} }
next(): IteratorResult<T> { next(): IteratorResult<T> {
return this.iteratorResult; return this.#iteratorResult;
} }
} }

View file

@ -2483,7 +2483,7 @@
{ {
"rule": "React-createRef", "rule": "React-createRef",
"path": "ts/components/conversation/Message.tsx", "path": "ts/components/conversation/Message.tsx",
"line": " private metadataRef: React.RefObject<HTMLDivElement> = React.createRef();", "line": " #metadataRef: React.RefObject<HTMLDivElement> = React.createRef();",
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2023-06-30T22:12:49.259Z", "updated": "2023-06-30T22:12:49.259Z",
"reasonDetail": "Used for excluding the message metadata from triple-click selections." "reasonDetail": "Used for excluding the message metadata from triple-click selections."

View file

@ -39,13 +39,10 @@ export function getDeltaIntoPast(delta?: number): number {
} }
export class RetryPlaceholders { export class RetryPlaceholders {
private items: Array<RetryItemType>; #items: Array<RetryItemType>;
#byConversation: ByConversationLookupType;
private byConversation: ByConversationLookupType; #byMessage: ByMessageLookupType;
#retryReceiptLifespan: number;
private byMessage: ByMessageLookupType;
private retryReceiptLifespan: number;
constructor(options: { retryReceiptLifespan?: number } = {}) { constructor(options: { retryReceiptLifespan?: number } = {}) {
if (!window.storage) { if (!window.storage) {
@ -66,41 +63,41 @@ export class RetryPlaceholders {
); );
} }
this.items = parsed.success ? parsed.data : []; this.#items = parsed.success ? parsed.data : [];
this.sortByExpiresAtAsc(); this.sortByExpiresAtAsc();
this.byConversation = this.makeByConversationLookup(); this.#byConversation = this.makeByConversationLookup();
this.byMessage = this.makeByMessageLookup(); this.#byMessage = this.makeByMessageLookup();
this.retryReceiptLifespan = options.retryReceiptLifespan || HOUR; this.#retryReceiptLifespan = options.retryReceiptLifespan || HOUR;
log.info( log.info(
`RetryPlaceholders.constructor: Started with ${this.items.length} items, lifespan of ${this.retryReceiptLifespan}` `RetryPlaceholders.constructor: Started with ${this.#items.length} items, lifespan of ${this.#retryReceiptLifespan}`
); );
} }
// Arranging local data for efficiency // Arranging local data for efficiency
sortByExpiresAtAsc(): void { sortByExpiresAtAsc(): void {
this.items.sort( this.#items.sort(
(left: RetryItemType, right: RetryItemType) => (left: RetryItemType, right: RetryItemType) =>
left.receivedAt - right.receivedAt left.receivedAt - right.receivedAt
); );
} }
makeByConversationLookup(): ByConversationLookupType { makeByConversationLookup(): ByConversationLookupType {
return groupBy(this.items, item => item.conversationId); return groupBy(this.#items, item => item.conversationId);
} }
makeByMessageLookup(): ByMessageLookupType { makeByMessageLookup(): ByMessageLookupType {
const lookup = new Map<string, RetryItemType>(); const lookup = new Map<string, RetryItemType>();
this.items.forEach(item => { this.#items.forEach(item => {
lookup.set(getItemId(item.conversationId, item.sentAt), item); lookup.set(getItemId(item.conversationId, item.sentAt), item);
}); });
return lookup; return lookup;
} }
makeLookups(): void { makeLookups(): void {
this.byConversation = this.makeByConversationLookup(); this.#byConversation = this.makeByConversationLookup();
this.byMessage = this.makeByMessageLookup(); this.#byMessage = this.makeByMessageLookup();
} }
// Basic data management // Basic data management
@ -115,33 +112,33 @@ export class RetryPlaceholders {
); );
} }
this.items.push(item); this.#items.push(item);
this.sortByExpiresAtAsc(); this.sortByExpiresAtAsc();
this.makeLookups(); this.makeLookups();
await this.save(); await this.save();
} }
async save(): Promise<void> { async save(): Promise<void> {
await window.storage.put(STORAGE_KEY, this.items); await window.storage.put(STORAGE_KEY, this.#items);
} }
// Finding items in different ways // Finding items in different ways
getCount(): number { getCount(): number {
return this.items.length; return this.#items.length;
} }
getNextToExpire(): RetryItemType | undefined { getNextToExpire(): RetryItemType | undefined {
return this.items[0]; return this.#items[0];
} }
async getExpiredAndRemove(): Promise<Array<RetryItemType>> { async getExpiredAndRemove(): Promise<Array<RetryItemType>> {
const expiration = getDeltaIntoPast(this.retryReceiptLifespan); const expiration = getDeltaIntoPast(this.#retryReceiptLifespan);
const max = this.items.length; const max = this.#items.length;
const result: Array<RetryItemType> = []; const result: Array<RetryItemType> = [];
for (let i = 0; i < max; i += 1) { for (let i = 0; i < max; i += 1) {
const item = this.items[i]; const item = this.#items[i];
if (item.receivedAt <= expiration) { if (item.receivedAt <= expiration) {
result.push(item); result.push(item);
} else { } else {
@ -153,7 +150,7 @@ export class RetryPlaceholders {
`RetryPlaceholders.getExpiredAndRemove: Found ${result.length} expired items` `RetryPlaceholders.getExpiredAndRemove: Found ${result.length} expired items`
); );
this.items.splice(0, result.length); this.#items.splice(0, result.length);
this.makeLookups(); this.makeLookups();
await this.save(); await this.save();
@ -162,7 +159,7 @@ export class RetryPlaceholders {
async findByConversationAndMarkOpened(conversationId: string): Promise<void> { async findByConversationAndMarkOpened(conversationId: string): Promise<void> {
let changed = 0; let changed = 0;
const items = this.byConversation[conversationId]; const items = this.#byConversation[conversationId];
(items || []).forEach(item => { (items || []).forEach(item => {
if (!item.wasOpened) { if (!item.wasOpened) {
changed += 1; changed += 1;
@ -184,14 +181,14 @@ export class RetryPlaceholders {
conversationId: string, conversationId: string,
sentAt: number sentAt: number
): Promise<RetryItemType | undefined> { ): Promise<RetryItemType | undefined> {
const result = this.byMessage.get(getItemId(conversationId, sentAt)); const result = this.#byMessage.get(getItemId(conversationId, sentAt));
if (!result) { if (!result) {
return undefined; return undefined;
} }
const index = this.items.findIndex(item => item === result); const index = this.#items.findIndex(item => item === result);
this.items.splice(index, 1); this.#items.splice(index, 1);
this.makeLookups(); this.makeLookups();
log.info( log.info(

View file

@ -9,8 +9,8 @@ import * as Errors from '../types/errors';
* but also a way to force sleeping tasks to immediately resolve/reject on shutdown * but also a way to force sleeping tasks to immediately resolve/reject on shutdown
*/ */
export class Sleeper { export class Sleeper {
private shuttingDown = false; #shuttingDown = false;
private shutdownCallbacks: Set<() => void> = new Set(); #shutdownCallbacks: Set<() => void> = new Set();
/** /**
* delay by ms, careful when using on a loop if resolving on shutdown (default) * delay by ms, careful when using on a loop if resolving on shutdown (default)
@ -46,7 +46,7 @@ export class Sleeper {
} }
}; };
if (this.shuttingDown) { if (this.#shuttingDown) {
log.info( log.info(
`Sleeper: sleep called when shutdown is in progress, scheduling immediate ${ `Sleeper: sleep called when shutdown is in progress, scheduling immediate ${
resolveOnShutdown ? 'resolution' : 'rejection' resolveOnShutdown ? 'resolution' : 'rejection'
@ -58,30 +58,30 @@ export class Sleeper {
timeout = setTimeout(() => { timeout = setTimeout(() => {
resolve(); resolve();
this.removeShutdownCallback(shutdownCallback); this.#removeShutdownCallback(shutdownCallback);
}, ms); }, ms);
this.addShutdownCallback(shutdownCallback); this.#addShutdownCallback(shutdownCallback);
}); });
} }
private addShutdownCallback(callback: () => void) { #addShutdownCallback(callback: () => void) {
this.shutdownCallbacks.add(callback); this.#shutdownCallbacks.add(callback);
} }
private removeShutdownCallback(callback: () => void) { #removeShutdownCallback(callback: () => void) {
this.shutdownCallbacks.delete(callback); this.#shutdownCallbacks.delete(callback);
} }
shutdown(): void { shutdown(): void {
if (this.shuttingDown) { if (this.#shuttingDown) {
return; return;
} }
log.info( log.info(
`Sleeper: shutting down, settling ${this.shutdownCallbacks.size} in-progress sleep calls` `Sleeper: shutting down, settling ${this.#shutdownCallbacks.size} in-progress sleep calls`
); );
this.shuttingDown = true; this.#shuttingDown = true;
this.shutdownCallbacks.forEach(cb => { this.#shutdownCallbacks.forEach(cb => {
try { try {
cb(); cb();
} catch (error) { } catch (error) {