Add localized emoji search
This commit is contained in:
		
					parent
					
						
							
								ce0fb22041
							
						
					
				
			
			
				commit
				
					
						e90553b3b3
					
				
			
		
					 17 changed files with 878 additions and 97 deletions
				
			
		
							
								
								
									
										188
									
								
								app/OptionalResourceService.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										188
									
								
								app/OptionalResourceService.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,188 @@
 | 
			
		|||
// Copyright 2024 Signal Messenger, LLC
 | 
			
		||||
// SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		||||
 | 
			
		||||
import { join, dirname } from 'node:path';
 | 
			
		||||
import { mkdir, readFile, readdir, writeFile, unlink } from 'node:fs/promises';
 | 
			
		||||
import { createHash, timingSafeEqual } from 'node:crypto';
 | 
			
		||||
import { ipcMain } from 'electron';
 | 
			
		||||
import LRU from 'lru-cache';
 | 
			
		||||
import got from 'got';
 | 
			
		||||
import PQueue from 'p-queue';
 | 
			
		||||
 | 
			
		||||
import type {
 | 
			
		||||
  OptionalResourceType,
 | 
			
		||||
  OptionalResourcesDictType,
 | 
			
		||||
} from '../ts/types/OptionalResource';
 | 
			
		||||
import { OptionalResourcesDictSchema } from '../ts/types/OptionalResource';
 | 
			
		||||
import * as log from '../ts/logging/log';
 | 
			
		||||
import { getGotOptions } from '../ts/updater/got';
 | 
			
		||||
import { drop } from '../ts/util/drop';
 | 
			
		||||
 | 
			
		||||
const RESOURCES_DICT_PATH = join(
 | 
			
		||||
  __dirname,
 | 
			
		||||
  '..',
 | 
			
		||||
  'build',
 | 
			
		||||
  'optional-resources.json'
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
const MAX_CACHE_SIZE = 10 * 1024 * 1024;
 | 
			
		||||
 | 
			
		||||
export class OptionalResourceService {
 | 
			
		||||
  private maybeDeclaration: OptionalResourcesDictType | undefined;
 | 
			
		||||
 | 
			
		||||
  private readonly cache = new LRU<string, Buffer>({
 | 
			
		||||
    max: MAX_CACHE_SIZE,
 | 
			
		||||
 | 
			
		||||
    length: buf => buf.length,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  private readonly fileQueues = new Map<string, PQueue>();
 | 
			
		||||
 | 
			
		||||
  constructor(private readonly resourcesDir: string) {
 | 
			
		||||
    ipcMain.handle('OptionalResourceService:getData', (_event, name) =>
 | 
			
		||||
      this.getData(name)
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    drop(this.lazyInit());
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public static create(resourcesDir: string): OptionalResourceService {
 | 
			
		||||
    return new OptionalResourceService(resourcesDir);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public async getData(name: string): Promise<Buffer | undefined> {
 | 
			
		||||
    await this.lazyInit();
 | 
			
		||||
 | 
			
		||||
    const decl = this.declaration[name];
 | 
			
		||||
    if (!decl) {
 | 
			
		||||
      return undefined;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const inMemory = this.cache.get(name);
 | 
			
		||||
    if (inMemory) {
 | 
			
		||||
      return inMemory;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const filePath = join(this.resourcesDir, name);
 | 
			
		||||
    return this.queueFileWork(filePath, async () => {
 | 
			
		||||
      try {
 | 
			
		||||
        const onDisk = await readFile(filePath);
 | 
			
		||||
        const digest = createHash('sha512').update(onDisk).digest();
 | 
			
		||||
 | 
			
		||||
        // Same digest and size
 | 
			
		||||
        if (
 | 
			
		||||
          timingSafeEqual(digest, Buffer.from(decl.digest, 'base64')) &&
 | 
			
		||||
          onDisk.length === decl.size
 | 
			
		||||
        ) {
 | 
			
		||||
          log.warn(`OptionalResourceService: loaded ${name} from disk`);
 | 
			
		||||
          this.cache.set(name, onDisk);
 | 
			
		||||
          return onDisk;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        log.warn(`OptionalResourceService: ${name} is no longer valid on disk`);
 | 
			
		||||
      } catch (error) {
 | 
			
		||||
        if (error.code !== 'ENOENT') {
 | 
			
		||||
          throw error;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // We get here if file doesn't exist or if its digest/size is different
 | 
			
		||||
      try {
 | 
			
		||||
        await unlink(filePath);
 | 
			
		||||
      } catch {
 | 
			
		||||
        // Just do our best effort and move forward
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      return this.fetch(name, decl, filePath);
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  //
 | 
			
		||||
  // Private
 | 
			
		||||
  //
 | 
			
		||||
 | 
			
		||||
  private async lazyInit(): Promise<void> {
 | 
			
		||||
    if (this.maybeDeclaration !== undefined) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const json = JSON.parse(await readFile(RESOURCES_DICT_PATH, 'utf8'));
 | 
			
		||||
    this.maybeDeclaration = OptionalResourcesDictSchema.parse(json);
 | 
			
		||||
 | 
			
		||||
    // Clean unknown resources
 | 
			
		||||
    let subPaths: Array<string>;
 | 
			
		||||
    try {
 | 
			
		||||
      subPaths = await readdir(this.resourcesDir);
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      // Directory wasn't created yet
 | 
			
		||||
      if (error.code === 'ENOENT') {
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
      throw error;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    await Promise.all(
 | 
			
		||||
      subPaths.map(async subPath => {
 | 
			
		||||
        if (this.declaration[subPath]) {
 | 
			
		||||
          return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const fullPath = join(this.resourcesDir, subPath);
 | 
			
		||||
 | 
			
		||||
        try {
 | 
			
		||||
          await unlink(fullPath);
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
          log.error(
 | 
			
		||||
            `OptionalResourceService: failed to cleanup ${subPath}`,
 | 
			
		||||
            error
 | 
			
		||||
          );
 | 
			
		||||
        }
 | 
			
		||||
      })
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private get declaration(): OptionalResourcesDictType {
 | 
			
		||||
    if (this.maybeDeclaration === undefined) {
 | 
			
		||||
      throw new Error('optional-resources.json not loaded yet');
 | 
			
		||||
    }
 | 
			
		||||
    return this.maybeDeclaration;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private async queueFileWork<R>(
 | 
			
		||||
    filePath: string,
 | 
			
		||||
    body: () => Promise<R>
 | 
			
		||||
  ): Promise<R> {
 | 
			
		||||
    let queue = this.fileQueues.get(filePath);
 | 
			
		||||
    if (!queue) {
 | 
			
		||||
      queue = new PQueue({ concurrency: 1 });
 | 
			
		||||
      this.fileQueues.set(filePath, queue);
 | 
			
		||||
    }
 | 
			
		||||
    try {
 | 
			
		||||
      return await queue.add(body);
 | 
			
		||||
    } finally {
 | 
			
		||||
      if (queue.size === 0) {
 | 
			
		||||
        this.fileQueues.delete(filePath);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private async fetch(
 | 
			
		||||
    name: string,
 | 
			
		||||
    decl: OptionalResourceType,
 | 
			
		||||
    destPath: string
 | 
			
		||||
  ): Promise<Buffer> {
 | 
			
		||||
    const result = await got(decl.url, getGotOptions()).buffer();
 | 
			
		||||
 | 
			
		||||
    this.cache.set(name, result);
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      await mkdir(dirname(destPath), { recursive: true });
 | 
			
		||||
      await writeFile(destPath, result);
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      log.error('OptionalResourceService: failed to save file', error);
 | 
			
		||||
      // Still return the data that we just fetched
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return result;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -79,6 +79,7 @@ import { updateDefaultSession } from './updateDefaultSession';
 | 
			
		|||
import { PreventDisplaySleepService } from './PreventDisplaySleepService';
 | 
			
		||||
import { SystemTrayService, focusAndForceToTop } from './SystemTrayService';
 | 
			
		||||
import { SystemTraySettingCache } from './SystemTraySettingCache';
 | 
			
		||||
import { OptionalResourceService } from './OptionalResourceService';
 | 
			
		||||
import {
 | 
			
		||||
  SystemTraySetting,
 | 
			
		||||
  shouldMinimizeToSystemTray,
 | 
			
		||||
| 
						 | 
				
			
			@ -1759,6 +1760,8 @@ app.on('ready', async () => {
 | 
			
		|||
  // Write buffered information into newly created logger.
 | 
			
		||||
  consoleLogger.writeBufferInto(logger);
 | 
			
		||||
 | 
			
		||||
  OptionalResourceService.create(join(userDataPath, 'optionalResources'));
 | 
			
		||||
 | 
			
		||||
  sqlInitPromise = initializeSQL(userDataPath);
 | 
			
		||||
 | 
			
		||||
  if (!resolvedTranslationsLocale) {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										337
									
								
								build/optional-resources.json
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										337
									
								
								build/optional-resources.json
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,337 @@
 | 
			
		|||
{
 | 
			
		||||
  "emoji-index-ar.json": {
 | 
			
		||||
    "url": "https://updates2.signal.org/static/android/emoji/search/13/ar.json",
 | 
			
		||||
    "size": 448278,
 | 
			
		||||
    "digest": "lgdLVdv4hSGfVTEvJbk733xYk8ZJvH+yi47peAJsytl7NWjm2WJN/d6Z3Aoxe1kLYiup7ugtoX3MIzeR3JZc0A=="
 | 
			
		||||
  },
 | 
			
		||||
  "emoji-index-en.json": {
 | 
			
		||||
    "url": "https://updates2.signal.org/static/android/emoji/search/13/en.json",
 | 
			
		||||
    "size": 383831,
 | 
			
		||||
    "digest": "jIu4ARhWJ8rP/suFEgB3T50nXbECt78CNXrHcUWAtfUiDLLLIUKn+52p3NfygmqCdxa5TRyDF5dFRbnGrWocIg=="
 | 
			
		||||
  },
 | 
			
		||||
  "emoji-index-hu.json": {
 | 
			
		||||
    "url": "https://updates2.signal.org/static/android/emoji/search/13/hu.json",
 | 
			
		||||
    "size": 399572,
 | 
			
		||||
    "digest": "PUvDX27TJtruOTFhz5m1ztnpTXpMJBYnAezl47gMIdwaKJSMwuS94pYMn3u1VgDRaC2DZpuLL19NFqsNWBgAYA=="
 | 
			
		||||
  },
 | 
			
		||||
  "emoji-index-sw.json": {
 | 
			
		||||
    "url": "https://updates2.signal.org/static/android/emoji/search/13/sw.json",
 | 
			
		||||
    "size": 399076,
 | 
			
		||||
    "digest": "mZmsfYh+bmxUPbut2wAojZjJZlzzFptkNMoe20PyV4znpUiDHRT/DLcLJ+EFdoJU39wlXkVWUxq3lbTUb7EUjQ=="
 | 
			
		||||
  },
 | 
			
		||||
  "emoji-index-cs.json": {
 | 
			
		||||
    "url": "https://updates2.signal.org/static/android/emoji/search/13/cs.json",
 | 
			
		||||
    "size": 403468,
 | 
			
		||||
    "digest": "ao9kU4RKppGTmtg37J+JxW7luexkQtvTcp6WhpjItNPqnNzWF2l8hrD24uN0ahFbBccrQ6cmEaaST9wkuZ2HZA=="
 | 
			
		||||
  },
 | 
			
		||||
  "emoji-index-hr-HR.json": {
 | 
			
		||||
    "url": "https://updates2.signal.org/static/android/emoji/search/13/hr.json",
 | 
			
		||||
    "size": 399715,
 | 
			
		||||
    "digest": "x3XmsTKpaORoJOx0GcHs4gDOeKpq6djGYpzlG8GPvhxmU5PDzC++eK2Nu7HU78+tYQoATolIhvZ7NGOA3WRWKw=="
 | 
			
		||||
  },
 | 
			
		||||
  "emoji-index-lv-LV.json": {
 | 
			
		||||
    "url": "https://updates2.signal.org/static/android/emoji/search/13/lv.json",
 | 
			
		||||
    "size": 410046,
 | 
			
		||||
    "digest": "YnWNX+uDOde5CpDi0BcjQHknyQMRCjCbr0Wx9GVzRYj4G8JWWQXcQ1pzFFBjdS27L5dI+Vxj43HGHp7BoJC2SA=="
 | 
			
		||||
  },
 | 
			
		||||
  "emoji-index-pt-PT.json": {
 | 
			
		||||
    "url": "https://updates2.signal.org/static/android/emoji/search/13/pt.json",
 | 
			
		||||
    "size": 405039,
 | 
			
		||||
    "digest": "ZSp9Nev4nv+001uSQbXCbyw2TV9B96D2+aFdDUvgnOF31enZjPPPyRhdea4ax3Eyo177e5TNv6A43TfwPXOBXA=="
 | 
			
		||||
  },
 | 
			
		||||
  "emoji-index-de.json": {
 | 
			
		||||
    "url": "https://updates2.signal.org/static/android/emoji/search/13/de.json",
 | 
			
		||||
    "size": 399152,
 | 
			
		||||
    "digest": "yuycBAbdswH5gC+CwXL1V+NEIAHwMWG4hsQsPgRlqdG6fz6byEFE/HPdVtrGzHMA3VL25lNFGVhv1021iMWG5A=="
 | 
			
		||||
  },
 | 
			
		||||
  "emoji-index-id.json": {
 | 
			
		||||
    "url": "https://updates2.signal.org/static/android/emoji/search/13/in.json",
 | 
			
		||||
    "size": 398444,
 | 
			
		||||
    "digest": "4CibIEL6Ya9TGdh8Qp33v6yopn05x8xD+Eks/QGL3i7l6wm3fqMkHyO9xeZGA4rJHpuDZlgRxu3Q/JJg8Nbs2A=="
 | 
			
		||||
  },
 | 
			
		||||
  "emoji-index-fa-IR.json": {
 | 
			
		||||
    "url": "https://updates2.signal.org/static/android/emoji/search/13/fa.json",
 | 
			
		||||
    "size": 448610,
 | 
			
		||||
    "digest": "35MG1yAF3pfPCArKCo4OCje18mRXNGO37F2KtCrQjnEYfhKyhadrhU0GsODE/RVsjTklBNLDHqL9uaFL4bAM2w=="
 | 
			
		||||
  },
 | 
			
		||||
  "emoji-index-ur.json": {
 | 
			
		||||
    "url": "https://updates2.signal.org/static/android/emoji/search/13/ur.json",
 | 
			
		||||
    "size": 446079,
 | 
			
		||||
    "digest": "XCWNxs9DG2UV12f8CwDlXjPsU7GJgSkQdXtDXPvewGPTZjYhhOkGtkf5jJ+Bveu+InZj744OuTOehEnu558qyA=="
 | 
			
		||||
  },
 | 
			
		||||
  "emoji-index-fr.json": {
 | 
			
		||||
    "url": "https://updates2.signal.org/static/android/emoji/search/13/fr.json",
 | 
			
		||||
    "size": 405258,
 | 
			
		||||
    "digest": "3ca5YvikLc/uVSjhZt/xZDFdY0dAqW4GyYPvOvFhP1ZQYW0s+rJkyxVMheV6O5Jp/J6C7I/3pNh1ITevnyfWQQ=="
 | 
			
		||||
  },
 | 
			
		||||
  "emoji-index-gl-ES.json": {
 | 
			
		||||
    "url": "https://updates2.signal.org/static/android/emoji/search/13/gl.json",
 | 
			
		||||
    "size": 401820,
 | 
			
		||||
    "digest": "VeaMoig6TeRFp2jACd4ZvfNu0q3j8fn/Daugw0/orzkf2bM/FXNpglyI1dOljKytcViK8NuFfjR1yrgRX2shFA=="
 | 
			
		||||
  },
 | 
			
		||||
  "emoji-index-da.json": {
 | 
			
		||||
    "url": "https://updates2.signal.org/static/android/emoji/search/13/da.json",
 | 
			
		||||
    "size": 389097,
 | 
			
		||||
    "digest": "B2Dz/yCA/5p43bqtqZy8kfDMMU6wL0Bo5fXvJnkK6c3UcVILB8DhkJSTp3pJoGpJUBNwumNH9Xot9a56e7k5Gw=="
 | 
			
		||||
  },
 | 
			
		||||
  "emoji-index-bs-BA.json": {
 | 
			
		||||
    "url": "https://updates2.signal.org/static/android/emoji/search/13/bs.json",
 | 
			
		||||
    "size": 394827,
 | 
			
		||||
    "digest": "TyQ6Evk7BHZzHoPBhzw8P6V8yrdE2ZCF8T6WQ7yneHlhAqLdo6wVK/tc4jTlK/6cOsDEbxUqLBPhYlRD36w8Kw=="
 | 
			
		||||
  },
 | 
			
		||||
  "emoji-index-nb.json": {
 | 
			
		||||
    "url": "https://updates2.signal.org/static/android/emoji/search/13/nb.json",
 | 
			
		||||
    "size": 388450,
 | 
			
		||||
    "digest": "0s2sCsddq0YzP8coq0L/l2Nd0YToOnEMfdmgZezhxteTdJAepXCJGpNSBNScrYm2RfEaLxr80i5vImb1XYvlDA=="
 | 
			
		||||
  },
 | 
			
		||||
  "emoji-index-tl-PH.json": {
 | 
			
		||||
    "url": "https://updates2.signal.org/static/android/emoji/search/13/tl.json",
 | 
			
		||||
    "size": 400801,
 | 
			
		||||
    "digest": "Riypkbj6yk9f5Sxbzca9AKqRLbL2+fLRPV3x4+UUCV78VR4aCRDRxHcp2mxrDCautxMDItzKd1Rk9DWMywWTGw=="
 | 
			
		||||
  },
 | 
			
		||||
  "emoji-index-tr.json": {
 | 
			
		||||
    "url": "https://updates2.signal.org/static/android/emoji/search/13/tr.json",
 | 
			
		||||
    "size": 398976,
 | 
			
		||||
    "digest": "QGVU51dVQjDteek7nxPC3NvULJvzZRh7Wi/jxunXMZywK13reZCii3M79W/zbYnjV3r6J7F1uoMOjjSYO00TlQ=="
 | 
			
		||||
  },
 | 
			
		||||
  "emoji-index-sv.json": {
 | 
			
		||||
    "url": "https://updates2.signal.org/static/android/emoji/search/13/sv.json",
 | 
			
		||||
    "size": 393132,
 | 
			
		||||
    "digest": "/r//en2/db6FARxA5OYEGaZrIO0gzlSa8DtbXPnhKC+xwjMoAqrKIYU+BZn8Dvs+GlUzcvEkKQGy2BHFcLZQ6A=="
 | 
			
		||||
  },
 | 
			
		||||
  "emoji-index-kk-KZ.json": {
 | 
			
		||||
    "url": "https://updates2.signal.org/static/android/emoji/search/13/kk.json",
 | 
			
		||||
    "size": 473398,
 | 
			
		||||
    "digest": "0VAbJgweXv94jHQppDAxw45sN0oLdTNaUDuZ88Zvls99lCjCsqUH5JjBvi/uuZ+Jec9u2zRI/BnrPO3+c2wzqw=="
 | 
			
		||||
  },
 | 
			
		||||
  "emoji-index-sr.json": {
 | 
			
		||||
    "url": "https://updates2.signal.org/static/android/emoji/search/13/sr.json",
 | 
			
		||||
    "size": 476243,
 | 
			
		||||
    "digest": "OPckwWyTruHoqD1Ef7QSXkwJtwe0aoGHhJFq6GwV7jnki2uGiZXLAHuGZYlRQRu96t/UOwbBwr7Fwgna5zazFw=="
 | 
			
		||||
  },
 | 
			
		||||
  "emoji-index-bg-BG.json": {
 | 
			
		||||
    "url": "https://updates2.signal.org/static/android/emoji/search/13/bg.json",
 | 
			
		||||
    "size": 480607,
 | 
			
		||||
    "digest": "0gExbSWvTh5B43iQzFZHhevuBpY8rhXXuvF64lG2oGLzcEMMkA5MdCKIpovn+s2Wuo+CpLMuRCPQsbpsLu1VPg=="
 | 
			
		||||
  },
 | 
			
		||||
  "emoji-index-ms.json": {
 | 
			
		||||
    "url": "https://updates2.signal.org/static/android/emoji/search/13/ms.json",
 | 
			
		||||
    "size": 399653,
 | 
			
		||||
    "digest": "8zzR5xbPQlGvN24mQvfGrpO+bW63kcIiezqcJuiz6xVp7vuJRHZRfiMnguz0HOXC5xUL890torTlM1r9ebOK3A=="
 | 
			
		||||
  },
 | 
			
		||||
  "emoji-index-zh-CN.json": {
 | 
			
		||||
    "url": "https://updates2.signal.org/static/android/emoji/search/13/zh_CN.json",
 | 
			
		||||
    "size": 387896,
 | 
			
		||||
    "digest": "Gzm37NKMn7QPx2+1INSapW+0ttmFYLEpDAYev+Hjgccvhpf3p+WlYDamT3cZpWn1BuKwsSTaVqWCUuJ45DnWwA=="
 | 
			
		||||
  },
 | 
			
		||||
  "emoji-index-ca.json": {
 | 
			
		||||
    "url": "https://updates2.signal.org/static/android/emoji/search/13/ca.json",
 | 
			
		||||
    "size": 397974,
 | 
			
		||||
    "digest": "FhSmIge+dElK99HcNCjwJlRCGm/u4q2tbYgMq827Sol3cPDZigxltgTzXrjTaKxRWVDDzSNITWYQunt/bc4PHQ=="
 | 
			
		||||
  },
 | 
			
		||||
  "emoji-index-he.json": {
 | 
			
		||||
    "url": "https://updates2.signal.org/static/android/emoji/search/13/iw.json",
 | 
			
		||||
    "size": 455478,
 | 
			
		||||
    "digest": "4XPBr8X8Ip0aI67e1R1xjgrK9JJaTnoUC3zpxf84DaqDfpiMUrdOpkV3YYToAE8/k8BS9lQKzx4ZF5FNsK8Slw=="
 | 
			
		||||
  },
 | 
			
		||||
  "emoji-index-el.json": {
 | 
			
		||||
    "url": "https://updates2.signal.org/static/android/emoji/search/13/el.json",
 | 
			
		||||
    "size": 492200,
 | 
			
		||||
    "digest": "yilYdYAQTUmzRoGjDEDyuUzLuZ0aGVb2n/zMC8qNMyzcLit3uSd5TLuAnO821LUiwcpINdt3K5CTKhtCZCZTlw=="
 | 
			
		||||
  },
 | 
			
		||||
  "emoji-index-uk-UA.json": {
 | 
			
		||||
    "url": "https://updates2.signal.org/static/android/emoji/search/13/uk.json",
 | 
			
		||||
    "size": 493305,
 | 
			
		||||
    "digest": "VeDAEaMY/AIC1IqY+UvwFqAZizwk22vGbTrlXOxg5NN8qXrRr5z0E+BDaqIsbnBttMHKzuw+GA/DTlCJG3+s7w=="
 | 
			
		||||
  },
 | 
			
		||||
  "emoji-index-gu-IN.json": {
 | 
			
		||||
    "url": "https://updates2.signal.org/static/android/emoji/search/13/gu.json",
 | 
			
		||||
    "size": 529403,
 | 
			
		||||
    "digest": "Z+AsVURhpUvFLPP2sXNjwWF0qIz0SoMUnK42DTG2+ogx+h39GXMW3l4UBxlMabXaE347GUZ6hFerXbXBA68ZPA=="
 | 
			
		||||
  },
 | 
			
		||||
  "emoji-index-vi.json": {
 | 
			
		||||
    "url": "https://updates2.signal.org/static/android/emoji/search/13/vi.json",
 | 
			
		||||
    "size": 435169,
 | 
			
		||||
    "digest": "UjS9LG5BmtTvfhMLkyn79IzgjcX+kKeSjVI2u3hka3YjBGhVg1uUERPE3cVeZg1V4QkVZvq6EwvfP5QBU6MJwg=="
 | 
			
		||||
  },
 | 
			
		||||
  "emoji-index-my.json": {
 | 
			
		||||
    "url": "https://updates2.signal.org/static/android/emoji/search/13/my.json",
 | 
			
		||||
    "size": 590529,
 | 
			
		||||
    "digest": "cVlHU6KoDnmj+XImT39p6Hkbk9jADUmP0Ro5fsTNnC1D66wYw+FXEys/Ox75J7+nUy507jmFBzNVOqXmCzYUDw=="
 | 
			
		||||
  },
 | 
			
		||||
  "emoji-index-te-IN.json": {
 | 
			
		||||
    "url": "https://updates2.signal.org/static/android/emoji/search/13/te.json",
 | 
			
		||||
    "size": 573374,
 | 
			
		||||
    "digest": "8z4stnh1595Xxq+IDEIIHkCQjujyOmKiFe0Aj6iPHGGTZ5SEtigWsO8mKyOGd2u9ZQnxRIuUa0P73fQwXfvviQ=="
 | 
			
		||||
  },
 | 
			
		||||
  "emoji-index-af-ZA.json": {
 | 
			
		||||
    "url": "https://updates2.signal.org/static/android/emoji/search/13/af.json",
 | 
			
		||||
    "size": 387066,
 | 
			
		||||
    "digest": "Ic5nCozImF9ozH1FqjWAJj77dBvh4VimNod5Z6sulYT/RzSIgaPgwiD3/KJqiFf0c63BwPK+xPc/78zmIZrSGQ=="
 | 
			
		||||
  },
 | 
			
		||||
  "emoji-index-ky-KG.json": {
 | 
			
		||||
    "url": "https://updates2.signal.org/static/android/emoji/search/13/ky.json",
 | 
			
		||||
    "size": 470385,
 | 
			
		||||
    "digest": "O2R5Im8B9Q8HExPH6r4Tk2lWekiZNOyHnCXnKjtPDbFiTKtcyNW7qaIFvRdiEhgCSW789a59nQsIF+PjGF96uw=="
 | 
			
		||||
  },
 | 
			
		||||
  "emoji-index-az-AZ.json": {
 | 
			
		||||
    "url": "https://updates2.signal.org/static/android/emoji/search/13/az.json",
 | 
			
		||||
    "size": 401639,
 | 
			
		||||
    "digest": "FoijICivz2Ewr6dN3Dds2WCVkpy7+QI/BdxqkIEebm0KdEpFXwth7I1Nnm0bzTIx5BT6wsN4QMNyaQTNS2oFxA=="
 | 
			
		||||
  },
 | 
			
		||||
  "emoji-index-kn-IN.json": {
 | 
			
		||||
    "url": "https://updates2.signal.org/static/android/emoji/search/13/kn.json",
 | 
			
		||||
    "size": 565842,
 | 
			
		||||
    "digest": "esUkUfZcTTU4ZdzdXcGTcD5YyfM4vDASWWxklZVihggWgph+rZFP6u9TjNgv57+RkWcxkvp8B3gOP6yMj5rnBA=="
 | 
			
		||||
  },
 | 
			
		||||
  "emoji-index-ml-IN.json": {
 | 
			
		||||
    "url": "https://updates2.signal.org/static/android/emoji/search/13/ml.json",
 | 
			
		||||
    "size": 590912,
 | 
			
		||||
    "digest": "GNipi1zDRLaB2pzLVQVK7X4FBfo8c81qIRNB1c1DwRYEjQRJvb3AgsEc4tTDqZftueYq5Ed32i2sTHz0y4ahHA=="
 | 
			
		||||
  },
 | 
			
		||||
  "emoji-index-sk-SK.json": {
 | 
			
		||||
    "url": "https://updates2.signal.org/static/android/emoji/search/13/sk.json",
 | 
			
		||||
    "size": 402538,
 | 
			
		||||
    "digest": "Q/xFVaWSnPiN/NZVo7a2ddElKpawF+LG7yuSlvYQOpM7lByv0LxFxs1nIQ7xIo+QeYZRvjQ0l9o9zaTlVgtR1w=="
 | 
			
		||||
  },
 | 
			
		||||
  "emoji-index-eu.json": {
 | 
			
		||||
    "url": "https://updates2.signal.org/static/android/emoji/search/13/eu.json",
 | 
			
		||||
    "size": 405751,
 | 
			
		||||
    "digest": "ZchYHNd0QlyHHI57NjfaPlidsl9JqaJaH//4v3SxfyYxykgZSdfYgA040sV/na/muye4Aovdw1Qv3Et5wk85GA=="
 | 
			
		||||
  },
 | 
			
		||||
  "emoji-index-sl-SI.json": {
 | 
			
		||||
    "url": "https://updates2.signal.org/static/android/emoji/search/13/sl.json",
 | 
			
		||||
    "size": 398593,
 | 
			
		||||
    "digest": "SSqzn1EGtK35mOuRevmrhkC2ehgW5hV0JO8JWDdAS9m2rAYWrDi8LBI8G80qQR/BSSCParKp/YbSx/8cqhIkhA=="
 | 
			
		||||
  },
 | 
			
		||||
  "emoji-index-ko.json": {
 | 
			
		||||
    "url": "https://updates2.signal.org/static/android/emoji/search/13/ko.json",
 | 
			
		||||
    "size": 406752,
 | 
			
		||||
    "digest": "tbwJJ+1Hae32a+rceoX9V/OY7gOP+Ygx7Zl5b5Ev8ojuTaPHPTG8gPjD3sFoQqIYJOYAM6JeTgSJprh8gao1hA=="
 | 
			
		||||
  },
 | 
			
		||||
  "emoji-index-zh-Hant.json": {
 | 
			
		||||
    "url": "https://updates2.signal.org/static/android/emoji/search/13/zh_TW.json",
 | 
			
		||||
    "size": 383697,
 | 
			
		||||
    "digest": "4X/PPP2yjZvMuy4uxTy/9DJF8afqsH4H8Xnv1tyUb5XYAioukcRsTvQT2XT46bVnHc6QAOdKgX0L6lnqkHsZ1A=="
 | 
			
		||||
  },
 | 
			
		||||
  "emoji-index-mk-MK.json": {
 | 
			
		||||
    "url": "https://updates2.signal.org/static/android/emoji/search/13/mk.json",
 | 
			
		||||
    "size": 480649,
 | 
			
		||||
    "digest": "7oYHTZVcmJTxc9JIBKbRNAtjbR7Ddw89K+WNj1NdLPIT3J42iujrul6tFZcal2qV5hVeadoqKeOYfHAfJTFGaQ=="
 | 
			
		||||
  },
 | 
			
		||||
  "emoji-index-es.json": {
 | 
			
		||||
    "url": "https://updates2.signal.org/static/android/emoji/search/13/es.json",
 | 
			
		||||
    "size": 403651,
 | 
			
		||||
    "digest": "QHGbkfvuNrN472l0sYEknLOMseqTuCfCw/WsotcUPecnWY8WC8AKzjhR1OgsyGFXqkuhVCkrn0MrYYfcVEfP/w=="
 | 
			
		||||
  },
 | 
			
		||||
  "emoji-index-th.json": {
 | 
			
		||||
    "url": "https://updates2.signal.org/static/android/emoji/search/13/th.json",
 | 
			
		||||
    "size": 547303,
 | 
			
		||||
    "digest": "BC79X04SP17K94EVHFhgK8WPJCW9cYO7AwjR4iD7LFI+kuOeCFJAwWK+nIXBgSjfeAy5sP3XhlP1M+QGsjwoTA=="
 | 
			
		||||
  },
 | 
			
		||||
  "emoji-index-fi.json": {
 | 
			
		||||
    "url": "https://updates2.signal.org/static/android/emoji/search/13/fi.json",
 | 
			
		||||
    "size": 400663,
 | 
			
		||||
    "digest": "L5D1Wp9J9pu+jZDhzpKh2p+zbyQzRdVEc8OuEuJpAxIiXL1KKu2+5PU05giBiICi7BtMa4kfWVHkko8Vy3svow=="
 | 
			
		||||
  },
 | 
			
		||||
  "emoji-index-ug.json": {
 | 
			
		||||
    "url": "https://updates2.signal.org/static/android/emoji/search/13/ug.json",
 | 
			
		||||
    "size": 460146,
 | 
			
		||||
    "digest": "6PDZlF9jGYItoZzxjjfcmfAwrNDaPGtI/urFESQauCmTKbrtcqCjfie3MIB0S8L5rulJBIS4t5u+5BLVLNOSwg=="
 | 
			
		||||
  },
 | 
			
		||||
  "emoji-index-sq-AL.json": {
 | 
			
		||||
    "url": "https://updates2.signal.org/static/android/emoji/search/13/sq.json",
 | 
			
		||||
    "size": 405020,
 | 
			
		||||
    "digest": "byryE8y1c9jDhn25qobC6yxSOd5YpKmHBw8PPCqUL/Yb1BpuMKt5lbfh/WWgD3LI9UUkYiyBXiyOcj/X4V2jpg=="
 | 
			
		||||
  },
 | 
			
		||||
  "emoji-index-pa-IN.json": {
 | 
			
		||||
    "url": "https://updates2.signal.org/static/android/emoji/search/13/pa.json",
 | 
			
		||||
    "size": 513322,
 | 
			
		||||
    "digest": "lYu8oSdrHSSEek20xRprpu3pXVZPR92TN0B8DahXHYgrYrk7dU5qFfL57xnhOw+dTFmq6XoqVKOx3bPdUzyXPQ=="
 | 
			
		||||
  },
 | 
			
		||||
  "emoji-index-ja.json": {
 | 
			
		||||
    "url": "https://updates2.signal.org/static/android/emoji/search/13/ja.json",
 | 
			
		||||
    "size": 423808,
 | 
			
		||||
    "digest": "XRQDifFGd5epZaT/2iHQYV0pFF+0W0e+cIuZ2b3xOzgv5JVknXopDU/SVspPiiomET86QwtOEPDJSlRUvBN3tg=="
 | 
			
		||||
  },
 | 
			
		||||
  "emoji-index-ga-IE.json": {
 | 
			
		||||
    "url": "https://updates2.signal.org/static/android/emoji/search/13/ga.json",
 | 
			
		||||
    "size": 404268,
 | 
			
		||||
    "digest": "5jTb+2WZEFi1kksKFZ/krRoyD1/VGsMVVJqRJoRe7MCkjdAmr1fFuCRcf0Kujxu2WDpWpB2bRijRuBpv9LXfgA=="
 | 
			
		||||
  },
 | 
			
		||||
  "emoji-index-ro-RO.json": {
 | 
			
		||||
    "url": "https://updates2.signal.org/static/android/emoji/search/13/ro.json",
 | 
			
		||||
    "size": 403632,
 | 
			
		||||
    "digest": "EGVjdUCizJ/qgUZ01fiMHl9AdLtfN9qmqoZdmLXsqlouzZ1jlUP+1lTIYC9j2RpR9VWH3b+//Y8CxdaqaSeH4w=="
 | 
			
		||||
  },
 | 
			
		||||
  "emoji-index-it.json": {
 | 
			
		||||
    "url": "https://updates2.signal.org/static/android/emoji/search/13/it.json",
 | 
			
		||||
    "size": 403536,
 | 
			
		||||
    "digest": "BTb24WzM17Xi0yiT2rjH30G0VyXU+s68qb09z2cV20qByclBo1Nba2ftwcPGhTkg8XAdky7EcrdGSQKFgtXgJw=="
 | 
			
		||||
  },
 | 
			
		||||
  "emoji-index-hi-IN.json": {
 | 
			
		||||
    "url": "https://updates2.signal.org/static/android/emoji/search/13/hi.json",
 | 
			
		||||
    "size": 538940,
 | 
			
		||||
    "digest": "8gvvXYgs8FzukH+tkdn1VPu2xt+ooWneoYqT+WmAQkq43C4aNxv9hko1jgq9kD+Ms+0aqu0Pl/4qfUNZos1QUw=="
 | 
			
		||||
  },
 | 
			
		||||
  "emoji-index-km-KH.json": {
 | 
			
		||||
    "url": "https://updates2.signal.org/static/android/emoji/search/13/km.json",
 | 
			
		||||
    "size": 559762,
 | 
			
		||||
    "digest": "tg/UYo0154HpfsZfcESEPWHigECsHW9ekzEUjipdU/voo12zpBczwZSCAYU9DawPQV7IeaDF3ZpJgtocHvrELQ=="
 | 
			
		||||
  },
 | 
			
		||||
  "emoji-index-ka-GE.json": {
 | 
			
		||||
    "url": "https://updates2.signal.org/static/android/emoji/search/13/ka.json",
 | 
			
		||||
    "size": 580052,
 | 
			
		||||
    "digest": "d9H+bKUoUHUwP1EstnTGWxHgOL8RXgTj9u/CpB644Qj0DqippPuTLvqAeRO0gheaGZ0Uv4m6TVEE09NwwyjNTQ=="
 | 
			
		||||
  },
 | 
			
		||||
  "emoji-index-nl.json": {
 | 
			
		||||
    "url": "https://updates2.signal.org/static/android/emoji/search/13/nl.json",
 | 
			
		||||
    "size": 392291,
 | 
			
		||||
    "digest": "kaFKhMK+kWbsq+NFhexL8G5uPOEi2ATVVbZ7Q8925yOlHlZ6KNNHZtR2BDhVirbErXflbs7Fh+b4qWWtL5lnrg=="
 | 
			
		||||
  },
 | 
			
		||||
  "emoji-index-et-EE.json": {
 | 
			
		||||
    "url": "https://updates2.signal.org/static/android/emoji/search/13/et.json",
 | 
			
		||||
    "size": 393026,
 | 
			
		||||
    "digest": "3lBTCEdqw4vTuk/KaD6QDxtXpzc7zKs7ZiobcxS5ZISjs8iC3cuPDz77n/E2zEVwluv0mWTPOLV0DNWBG8s7Jg=="
 | 
			
		||||
  },
 | 
			
		||||
  "emoji-index-zh-HK.json": {
 | 
			
		||||
    "url": "https://updates2.signal.org/static/android/emoji/search/13/zh_HK.json",
 | 
			
		||||
    "size": 383653,
 | 
			
		||||
    "digest": "Mnt9AjeQfbU0Czae8QhtnV4naPGWTDy7EniLrv397gTpZWqX5n396NLA09n3ZgMLv0AmZd3+nCgZdHlaVZMQ4w=="
 | 
			
		||||
  },
 | 
			
		||||
  "emoji-index-pl.json": {
 | 
			
		||||
    "url": "https://updates2.signal.org/static/android/emoji/search/13/pl.json",
 | 
			
		||||
    "size": 405256,
 | 
			
		||||
    "digest": "q2CxPVCrGtKIVVHlls4GRGUsxTzHe6cwG6XdIr3Iu55F4bYlrdw6TrBOWY3D/6k1XCtTqbGtOIyWhUJHdomViA=="
 | 
			
		||||
  },
 | 
			
		||||
  "emoji-index-ru.json": {
 | 
			
		||||
    "url": "https://updates2.signal.org/static/android/emoji/search/13/ru.json",
 | 
			
		||||
    "size": 489997,
 | 
			
		||||
    "digest": "I4BbmcE7dYVpAda7zH1qaErrBppZ3cq1Nw58SYB9q4sjdZ8xPPRSfBUoGZlc4YXQwvpPJ75pEok49jD9O7xN6Q=="
 | 
			
		||||
  },
 | 
			
		||||
  "emoji-index-pt-BR.json": {
 | 
			
		||||
    "url": "https://updates2.signal.org/static/android/emoji/search/13/pt_BR.json",
 | 
			
		||||
    "size": 405896,
 | 
			
		||||
    "digest": "X20ND4rjd0wG5IyqqtDwynGhZxQ8i6JAd0BPjsOLM/GkqW2HLCPJRdwYC+TDtuJa0cn9YmVsadu+ty0vLgojaA=="
 | 
			
		||||
  },
 | 
			
		||||
  "emoji-index-ta-IN.json": {
 | 
			
		||||
    "url": "https://updates2.signal.org/static/android/emoji/search/13/ta.json",
 | 
			
		||||
    "size": 588808,
 | 
			
		||||
    "digest": "ifOYhbzJRh9sOyDv4333AlwrL1UD+Z7pE/Z92rjXb9IoGz65UxXm+D893moJ/ceJtOpjGfH+T84xOEkn19frBA=="
 | 
			
		||||
  },
 | 
			
		||||
  "emoji-index-bn-BD.json": {
 | 
			
		||||
    "url": "https://updates2.signal.org/static/android/emoji/search/13/bn.json",
 | 
			
		||||
    "size": 537619,
 | 
			
		||||
    "digest": "3Hnk3I4RTFy0xbJjQC8QBWlj5gCt9nEKGTOQVxu1H9WfQxkBs4sAJYVjwqD9TT6rZPIX0uW3a7JGXpT5Dv7V+A=="
 | 
			
		||||
  },
 | 
			
		||||
  "emoji-index-mr-IN.json": {
 | 
			
		||||
    "url": "https://updates2.signal.org/static/android/emoji/search/13/mr.json",
 | 
			
		||||
    "size": 529450,
 | 
			
		||||
    "digest": "yNLiRFp70o6JfErdbDUM5odixVOvqM3spmD//80Q0gwGQfBjy0MuVA2p8BHjoPQJ1kxZIVpCToLexaSTkM/Ufw=="
 | 
			
		||||
  },
 | 
			
		||||
  "emoji-index-lt-LT.json": {
 | 
			
		||||
    "url": "https://updates2.signal.org/static/android/emoji/search/13/lt.json",
 | 
			
		||||
    "size": 417252,
 | 
			
		||||
    "digest": "IUKfMAIywuj6frUBYec1uqW6fjtmqpPYKahYNCzTC8fw15LgGN+rjhHIn7pBppsnOdiKPlGTpLmKboML0Ide0w=="
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -23,9 +23,10 @@
 | 
			
		|||
    "build-release": "yarn run build",
 | 
			
		||||
    "sign-release": "node ts/updater/generateSignature.js",
 | 
			
		||||
    "notarize": "echo 'No longer necessary'",
 | 
			
		||||
    "get-strings": "ts-node ts/scripts/get-strings.ts && ts-node ts/scripts/gen-nsis-script.ts && ts-node ts/scripts/gen-locales-config.ts && run-p get-strings:locales get-strings:countries mark-unusued-strings-deleted",
 | 
			
		||||
    "get-strings": "ts-node ts/scripts/get-strings.ts && ts-node ts/scripts/gen-nsis-script.ts && ts-node ts/scripts/gen-locales-config.ts && run-p get-strings:locales get-strings:countries get-strings:emoji mark-unusued-strings-deleted",
 | 
			
		||||
    "get-strings:locales": "ts-node ./ts/scripts/build-localized-display-names.ts locales ts/scripts/locale-data/locale-display-names.csv build/locale-display-names.json",
 | 
			
		||||
    "get-strings:countries": "ts-node ./ts/scripts/build-localized-display-names.ts countries ts/scripts/locale-data/country-display-names.csv build/country-display-names.json",
 | 
			
		||||
    "get-strings:emoji": "ts-node ./ts/scripts/get-emoji-locales.ts",
 | 
			
		||||
    "push-strings": "node ts/scripts/remove-strings.js && node ts/scripts/push-strings.js",
 | 
			
		||||
    "mark-unusued-strings-deleted": "ts-node ./ts/scripts/mark-unused-strings-deleted.ts",
 | 
			
		||||
    "get-expire-time": "node ts/scripts/get-expire-time.js",
 | 
			
		||||
| 
						 | 
				
			
			@ -516,6 +517,7 @@
 | 
			
		|||
      "build/locale-display-names.json",
 | 
			
		||||
      "build/country-display-names.json",
 | 
			
		||||
      "build/dns-fallback.json",
 | 
			
		||||
      "build/optional-resources.json",
 | 
			
		||||
      "node_modules/**",
 | 
			
		||||
      "!node_modules/underscore/**",
 | 
			
		||||
      "!node_modules/emoji-datasource/emoji_pretty.json",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -52,6 +52,7 @@ import { isNotNil } from '../util/isNotNil';
 | 
			
		|||
import * as log from '../logging/log';
 | 
			
		||||
import * as Errors from '../types/errors';
 | 
			
		||||
import { useRefMerger } from '../hooks/useRefMerger';
 | 
			
		||||
import { useEmojiSearch } from '../hooks/useEmojiSearch';
 | 
			
		||||
import type { LinkPreviewType } from '../types/message/LinkPreviews';
 | 
			
		||||
import { StagedLinkPreview } from './conversation/StagedLinkPreview';
 | 
			
		||||
import type { DraftEditMessageType } from '../model-types.d';
 | 
			
		||||
| 
						 | 
				
			
			@ -688,6 +689,8 @@ export function CompositionInput(props: Props): React.ReactElement {
 | 
			
		|||
  const callbacksRef = React.useRef(unstaleCallbacks);
 | 
			
		||||
  callbacksRef.current = unstaleCallbacks;
 | 
			
		||||
 | 
			
		||||
  const search = useEmojiSearch(i18n.getLocale());
 | 
			
		||||
 | 
			
		||||
  const reactQuill = React.useMemo(
 | 
			
		||||
    () => {
 | 
			
		||||
      const delta = generateDelta(draftText || '', draftBodyRanges || []);
 | 
			
		||||
| 
						 | 
				
			
			@ -739,6 +742,7 @@ export function CompositionInput(props: Props): React.ReactElement {
 | 
			
		|||
              onPickEmoji: (emoji: EmojiPickDataType) =>
 | 
			
		||||
                callbacksRef.current.onPickEmoji(emoji),
 | 
			
		||||
              skinTone,
 | 
			
		||||
              search,
 | 
			
		||||
            },
 | 
			
		||||
            autoSubstituteAsciiEmojis: {
 | 
			
		||||
              skinTone,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -21,10 +21,11 @@ import {
 | 
			
		|||
import FocusTrap from 'focus-trap-react';
 | 
			
		||||
 | 
			
		||||
import { Emoji } from './Emoji';
 | 
			
		||||
import { dataByCategory, search } from './lib';
 | 
			
		||||
import { dataByCategory } from './lib';
 | 
			
		||||
import type { LocalizerType } from '../../types/Util';
 | 
			
		||||
import { isSingleGrapheme } from '../../util/grapheme';
 | 
			
		||||
import { missingCaseError } from '../../util/missingCaseError';
 | 
			
		||||
import { useEmojiSearch } from '../../hooks/useEmojiSearch';
 | 
			
		||||
 | 
			
		||||
export type EmojiPickDataType = {
 | 
			
		||||
  skinTone?: number;
 | 
			
		||||
| 
						 | 
				
			
			@ -108,6 +109,8 @@ export const EmojiPicker = React.memo(
 | 
			
		|||
      const [scrollToRow, setScrollToRow] = React.useState(0);
 | 
			
		||||
      const [selectedTone, setSelectedTone] = React.useState(skinTone);
 | 
			
		||||
 | 
			
		||||
      const search = useEmojiSearch(i18n.getLocale());
 | 
			
		||||
 | 
			
		||||
      const handleToggleSearch = React.useCallback(
 | 
			
		||||
        (
 | 
			
		||||
          e:
 | 
			
		||||
| 
						 | 
				
			
			@ -261,10 +264,7 @@ export const EmojiPicker = React.memo(
 | 
			
		|||
 | 
			
		||||
      const emojiGrid = React.useMemo(() => {
 | 
			
		||||
        if (searchText) {
 | 
			
		||||
          return chunk(
 | 
			
		||||
            search(searchText).map(e => e.short_name),
 | 
			
		||||
            COL_COUNT
 | 
			
		||||
          );
 | 
			
		||||
          return chunk(search(searchText), COL_COUNT);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const chunks = flatMap(renderableCategories, cat =>
 | 
			
		||||
| 
						 | 
				
			
			@ -275,7 +275,7 @@ export const EmojiPicker = React.memo(
 | 
			
		|||
        );
 | 
			
		||||
 | 
			
		||||
        return [...chunk(firstRecent, COL_COUNT), ...chunks];
 | 
			
		||||
      }, [firstRecent, renderableCategories, searchText]);
 | 
			
		||||
      }, [firstRecent, renderableCategories, searchText, search]);
 | 
			
		||||
 | 
			
		||||
      const rowCount = emojiGrid.length;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -23,6 +23,7 @@ import { getOwn } from '../../util/getOwn';
 | 
			
		|||
import * as log from '../../logging/log';
 | 
			
		||||
import { MINUTE } from '../../util/durations';
 | 
			
		||||
import { drop } from '../../util/drop';
 | 
			
		||||
import type { LocaleEmojiType } from '../../types/emoji';
 | 
			
		||||
 | 
			
		||||
export const skinTones = ['1F3FB', '1F3FC', '1F3FD', '1F3FE', '1F3FF'];
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -218,34 +219,69 @@ export function getImagePath(
 | 
			
		|||
  return makeImagePath(emojiData.image);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const fuse = new Fuse(data, {
 | 
			
		||||
export type SearchFnType = (query: string, count?: number) => Array<string>;
 | 
			
		||||
 | 
			
		||||
export type SearchEmojiListType = ReadonlyArray<
 | 
			
		||||
  Pick<LocaleEmojiType, 'shortName' | 'rank' | 'tags'>
 | 
			
		||||
>;
 | 
			
		||||
 | 
			
		||||
type CachedSearchFnType = Readonly<{
 | 
			
		||||
  localeEmoji: SearchEmojiListType;
 | 
			
		||||
  fn: SearchFnType;
 | 
			
		||||
}>;
 | 
			
		||||
 | 
			
		||||
let cachedSearchFn: CachedSearchFnType | undefined;
 | 
			
		||||
 | 
			
		||||
export function createSearch(localeEmoji: SearchEmojiListType): SearchFnType {
 | 
			
		||||
  if (cachedSearchFn && cachedSearchFn.localeEmoji === localeEmoji) {
 | 
			
		||||
    return cachedSearchFn.fn;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const fuse = new Fuse(localeEmoji, {
 | 
			
		||||
    shouldSort: true,
 | 
			
		||||
    threshold: 0.2,
 | 
			
		||||
    minMatchCharLength: 1,
 | 
			
		||||
  keys: ['short_name', 'short_names'],
 | 
			
		||||
    keys: ['shortName', 'tags'],
 | 
			
		||||
    includeScore: true,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
const fuseExactPrefix = new Fuse(data, {
 | 
			
		||||
  const fuseExactPrefix = new Fuse(localeEmoji, {
 | 
			
		||||
    shouldSort: true,
 | 
			
		||||
    threshold: 0, // effectively a prefix search
 | 
			
		||||
    minMatchCharLength: 2,
 | 
			
		||||
  keys: ['short_name', 'short_names'],
 | 
			
		||||
    keys: ['shortName', 'tags'],
 | 
			
		||||
    includeScore: true,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
export function search(query: string, count = 0): Array<EmojiData> {
 | 
			
		||||
  const fn = (query: string, count = 0): Array<string> => {
 | 
			
		||||
    // when we only have 2 characters, do an exact prefix match
 | 
			
		||||
    // to avoid matching on emoticon, like :-P
 | 
			
		||||
    const fuseIndex = query.length === 2 ? fuseExactPrefix : fuse;
 | 
			
		||||
 | 
			
		||||
  const results = fuseIndex
 | 
			
		||||
    .search(query.substr(0, 32))
 | 
			
		||||
    .map(result => result.item);
 | 
			
		||||
    const rawResults = fuseIndex.search(query.substr(0, 32));
 | 
			
		||||
 | 
			
		||||
    const rankedResults = rawResults.map(entry => {
 | 
			
		||||
      const rank = entry.item.rank || 1e9;
 | 
			
		||||
 | 
			
		||||
      return {
 | 
			
		||||
        score: (entry.score ?? 0) + rank / localeEmoji.length,
 | 
			
		||||
        item: entry.item,
 | 
			
		||||
      };
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    const results = rankedResults
 | 
			
		||||
      .sort((a, b) => a.score - b.score)
 | 
			
		||||
      .map(result => result.item.shortName);
 | 
			
		||||
 | 
			
		||||
    if (count) {
 | 
			
		||||
      return take(results, count);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return results;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  cachedSearchFn = { localeEmoji, fn };
 | 
			
		||||
  return fn;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const shortNames = new Set([
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										63
									
								
								ts/hooks/useEmojiSearch.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								ts/hooks/useEmojiSearch.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,63 @@
 | 
			
		|||
// Copyright 2024 Signal Messenger, LLC
 | 
			
		||||
// SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		||||
 | 
			
		||||
import { useEffect, useCallback, useRef } from 'react';
 | 
			
		||||
import data from 'emoji-datasource';
 | 
			
		||||
 | 
			
		||||
import { createSearch } from '../components/emoji/lib';
 | 
			
		||||
import type { SearchEmojiListType } from '../components/emoji/lib';
 | 
			
		||||
import { drop } from '../util/drop';
 | 
			
		||||
import * as log from '../logging/log';
 | 
			
		||||
 | 
			
		||||
const uninitialized: SearchEmojiListType = data.map(
 | 
			
		||||
  ({ short_name: shortName, short_names: shortNames }) => {
 | 
			
		||||
    return {
 | 
			
		||||
      shortName,
 | 
			
		||||
      rank: 0,
 | 
			
		||||
      tags: shortNames,
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
const defaultSearch = createSearch(uninitialized);
 | 
			
		||||
 | 
			
		||||
export function useEmojiSearch(
 | 
			
		||||
  locale: string
 | 
			
		||||
): ReturnType<typeof createSearch> {
 | 
			
		||||
  const searchRef = useRef(defaultSearch);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    let canceled = false;
 | 
			
		||||
 | 
			
		||||
    async function run() {
 | 
			
		||||
      let result: SearchEmojiListType | undefined;
 | 
			
		||||
      try {
 | 
			
		||||
        result = await window.SignalContext.getLocalizedEmojiList(locale);
 | 
			
		||||
      } catch (error) {
 | 
			
		||||
        log.error(`Failed to get localized emoji list for ${locale}`, error);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // Fallback
 | 
			
		||||
      if (result === undefined) {
 | 
			
		||||
        try {
 | 
			
		||||
          result = await window.SignalContext.getLocalizedEmojiList('en');
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
          log.error('Failed to get fallback localized emoji list');
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (!canceled && result !== undefined) {
 | 
			
		||||
        searchRef.current = createSearch(result);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    drop(run());
 | 
			
		||||
 | 
			
		||||
    return () => {
 | 
			
		||||
      canceled = true;
 | 
			
		||||
    };
 | 
			
		||||
  }, [locale]);
 | 
			
		||||
 | 
			
		||||
  return useCallback((...args) => {
 | 
			
		||||
    return searchRef.current?.(...args);
 | 
			
		||||
  }, []);
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -10,13 +10,8 @@ import { Popper } from 'react-popper';
 | 
			
		|||
import classNames from 'classnames';
 | 
			
		||||
import { createPortal } from 'react-dom';
 | 
			
		||||
import type { VirtualElement } from '@popperjs/core';
 | 
			
		||||
import type { EmojiData } from '../../components/emoji/lib';
 | 
			
		||||
import {
 | 
			
		||||
  search,
 | 
			
		||||
  convertShortName,
 | 
			
		||||
  isShortName,
 | 
			
		||||
  convertShortNameToData,
 | 
			
		||||
} from '../../components/emoji/lib';
 | 
			
		||||
import { convertShortName, isShortName } from '../../components/emoji/lib';
 | 
			
		||||
import type { SearchFnType } from '../../components/emoji/lib';
 | 
			
		||||
import { Emoji } from '../../components/emoji/Emoji';
 | 
			
		||||
import type { EmojiPickDataType } from '../../components/emoji/EmojiPicker';
 | 
			
		||||
import { getBlotTextPartitions, matchBlotTextPartitions } from '../util';
 | 
			
		||||
| 
						 | 
				
			
			@ -29,10 +24,11 @@ type EmojiPickerOptions = {
 | 
			
		|||
  onPickEmoji: (emoji: EmojiPickDataType) => void;
 | 
			
		||||
  setEmojiPickerElement: (element: JSX.Element | null) => void;
 | 
			
		||||
  skinTone: number;
 | 
			
		||||
  search: SearchFnType;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export class EmojiCompletion {
 | 
			
		||||
  results: Array<EmojiData>;
 | 
			
		||||
  results: Array<string>;
 | 
			
		||||
 | 
			
		||||
  index: number;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -132,8 +128,8 @@ export class EmojiCompletion {
 | 
			
		|||
    const [leftTokenTextMatch, rightTokenTextMatch] = matchBlotTextPartitions(
 | 
			
		||||
      blot,
 | 
			
		||||
      index,
 | 
			
		||||
      /(?<=^|\s):([-+0-9a-zA-Z_]*)(:?)$/,
 | 
			
		||||
      /^([-+0-9a-zA-Z_]*):/
 | 
			
		||||
      /(?<=^|\s):([-+0-9\p{Alpha}_]*)(:?)$/iu,
 | 
			
		||||
      /^([-+0-9\p{Alpha}_]*):/iu
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    if (leftTokenTextMatch) {
 | 
			
		||||
| 
						 | 
				
			
			@ -141,54 +137,39 @@ export class EmojiCompletion {
 | 
			
		|||
 | 
			
		||||
      if (isSelfClosing || justPressedColon) {
 | 
			
		||||
        if (isShortName(leftTokenText)) {
 | 
			
		||||
          const emojiData = convertShortNameToData(
 | 
			
		||||
            leftTokenText,
 | 
			
		||||
            this.options.skinTone
 | 
			
		||||
          );
 | 
			
		||||
 | 
			
		||||
          const numberOfColons = isSelfClosing ? 2 : 1;
 | 
			
		||||
 | 
			
		||||
          if (emojiData) {
 | 
			
		||||
          this.insertEmoji(
 | 
			
		||||
              emojiData,
 | 
			
		||||
            leftTokenText,
 | 
			
		||||
            range.index - leftTokenText.length - numberOfColons,
 | 
			
		||||
            leftTokenText.length + numberOfColons
 | 
			
		||||
          );
 | 
			
		||||
          return INTERCEPT;
 | 
			
		||||
        }
 | 
			
		||||
        } else {
 | 
			
		||||
        this.reset();
 | 
			
		||||
        return PASS_THROUGH;
 | 
			
		||||
      }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (rightTokenTextMatch) {
 | 
			
		||||
        const [, rightTokenText] = rightTokenTextMatch;
 | 
			
		||||
        const tokenText = leftTokenText + rightTokenText;
 | 
			
		||||
 | 
			
		||||
        if (isShortName(tokenText)) {
 | 
			
		||||
          const emojiData = convertShortNameToData(
 | 
			
		||||
            tokenText,
 | 
			
		||||
            this.options.skinTone
 | 
			
		||||
          );
 | 
			
		||||
 | 
			
		||||
          if (emojiData) {
 | 
			
		||||
          this.insertEmoji(
 | 
			
		||||
              emojiData,
 | 
			
		||||
            tokenText,
 | 
			
		||||
            range.index - leftTokenText.length - 1,
 | 
			
		||||
            tokenText.length + 2
 | 
			
		||||
          );
 | 
			
		||||
          return INTERCEPT;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (leftTokenText.length < 2) {
 | 
			
		||||
        this.reset();
 | 
			
		||||
        return PASS_THROUGH;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const showEmojiResults = search(leftTokenText, 10);
 | 
			
		||||
      const showEmojiResults = this.options.search(leftTokenText, 10);
 | 
			
		||||
 | 
			
		||||
      if (showEmojiResults.length > 0) {
 | 
			
		||||
        this.results = showEmojiResults;
 | 
			
		||||
| 
						 | 
				
			
			@ -223,7 +204,7 @@ export class EmojiCompletion {
 | 
			
		|||
    const emoji = this.results[this.index];
 | 
			
		||||
    const [leafText] = this.getCurrentLeafTextPartitions();
 | 
			
		||||
 | 
			
		||||
    const tokenTextMatch = /:([-+0-9a-z_]*)(:?)$/.exec(leafText);
 | 
			
		||||
    const tokenTextMatch = /:([-+0-9\p{Alpha}_]*)(:?)$/iu.exec(leafText);
 | 
			
		||||
 | 
			
		||||
    if (tokenTextMatch == null) {
 | 
			
		||||
      return;
 | 
			
		||||
| 
						 | 
				
			
			@ -240,12 +221,12 @@ export class EmojiCompletion {
 | 
			
		|||
  }
 | 
			
		||||
 | 
			
		||||
  insertEmoji(
 | 
			
		||||
    emojiData: EmojiData,
 | 
			
		||||
    shortName: string,
 | 
			
		||||
    index: number,
 | 
			
		||||
    range: number,
 | 
			
		||||
    withTrailingSpace = false
 | 
			
		||||
  ): void {
 | 
			
		||||
    const emoji = convertShortName(emojiData.short_name, this.options.skinTone);
 | 
			
		||||
    const emoji = convertShortName(shortName, this.options.skinTone);
 | 
			
		||||
 | 
			
		||||
    const delta = new Delta()
 | 
			
		||||
      .retain(index)
 | 
			
		||||
| 
						 | 
				
			
			@ -265,7 +246,7 @@ export class EmojiCompletion {
 | 
			
		|||
    }
 | 
			
		||||
 | 
			
		||||
    this.options.onPickEmoji({
 | 
			
		||||
      shortName: emojiData.short_name,
 | 
			
		||||
      shortName,
 | 
			
		||||
      skinTone: this.options.skinTone,
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -344,17 +325,15 @@ export class EmojiCompletion {
 | 
			
		|||
            role="listbox"
 | 
			
		||||
            aria-expanded
 | 
			
		||||
            aria-activedescendant={`emoji-result--${
 | 
			
		||||
              emojiResults.length
 | 
			
		||||
                ? emojiResults[emojiResultsIndex].short_name
 | 
			
		||||
                : ''
 | 
			
		||||
              emojiResults.length ? emojiResults[emojiResultsIndex] : ''
 | 
			
		||||
            }`}
 | 
			
		||||
            tabIndex={0}
 | 
			
		||||
          >
 | 
			
		||||
            {emojiResults.map((emoji, index) => (
 | 
			
		||||
              <button
 | 
			
		||||
                type="button"
 | 
			
		||||
                key={emoji.short_name}
 | 
			
		||||
                id={`emoji-result--${emoji.short_name}`}
 | 
			
		||||
                key={emoji}
 | 
			
		||||
                id={`emoji-result--${emoji}`}
 | 
			
		||||
                role="option button"
 | 
			
		||||
                aria-selected={emojiResultsIndex === index}
 | 
			
		||||
                onClick={() => {
 | 
			
		||||
| 
						 | 
				
			
			@ -369,12 +348,12 @@ export class EmojiCompletion {
 | 
			
		|||
                )}
 | 
			
		||||
              >
 | 
			
		||||
                <Emoji
 | 
			
		||||
                  shortName={emoji.short_name}
 | 
			
		||||
                  shortName={emoji}
 | 
			
		||||
                  size={16}
 | 
			
		||||
                  skinTone={this.options.skinTone}
 | 
			
		||||
                />
 | 
			
		||||
                <div className="module-composition-input__suggestions__row__short-name">
 | 
			
		||||
                  :{emoji.short_name}:
 | 
			
		||||
                  :{emoji}:
 | 
			
		||||
                </div>
 | 
			
		||||
              </button>
 | 
			
		||||
            ))}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										100
									
								
								ts/scripts/get-emoji-locales.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										100
									
								
								ts/scripts/get-emoji-locales.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,100 @@
 | 
			
		|||
// Copyright 2024 Signal Messenger, LLC
 | 
			
		||||
// SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		||||
 | 
			
		||||
import { writeFile, readFile } from 'node:fs/promises';
 | 
			
		||||
import { createHash } from 'node:crypto';
 | 
			
		||||
import { join } from 'node:path';
 | 
			
		||||
import z from 'zod';
 | 
			
		||||
import prettier from 'prettier';
 | 
			
		||||
 | 
			
		||||
import type { OptionalResourceType } from '../types/OptionalResource';
 | 
			
		||||
import { OptionalResourcesDictSchema } from '../types/OptionalResource';
 | 
			
		||||
 | 
			
		||||
const MANIFEST_URL =
 | 
			
		||||
  'https://updates.signal.org/dynamic/android/emoji/search/manifest.json';
 | 
			
		||||
 | 
			
		||||
const ManifestSchema = z.object({
 | 
			
		||||
  version: z.number(),
 | 
			
		||||
  languages: z.string().array(),
 | 
			
		||||
  languageToSmartlingLocale: z.record(z.string(), z.string()),
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
async function fetchJSON(url: string): Promise<unknown> {
 | 
			
		||||
  const res = await fetch(url);
 | 
			
		||||
  if (!res.ok) {
 | 
			
		||||
    throw new Error(`Failed to fetch ${url}`);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return res.json();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function main(): Promise<void> {
 | 
			
		||||
  const manifest = ManifestSchema.parse(await fetchJSON(MANIFEST_URL));
 | 
			
		||||
 | 
			
		||||
  // eslint-disable-next-line dot-notation
 | 
			
		||||
  manifest.languageToSmartlingLocale['zh_TW'] = 'zh-Hant';
 | 
			
		||||
  // eslint-disable-next-line dot-notation
 | 
			
		||||
  manifest.languageToSmartlingLocale['sr'] = 'sr';
 | 
			
		||||
 | 
			
		||||
  const extraResources = new Map<string, OptionalResourceType>();
 | 
			
		||||
 | 
			
		||||
  await Promise.all(
 | 
			
		||||
    manifest.languages.map(async language => {
 | 
			
		||||
      const langUrl =
 | 
			
		||||
        'https://updates.signal.org/static/android/' +
 | 
			
		||||
        `emoji/search/${manifest.version}/${language}.json`;
 | 
			
		||||
 | 
			
		||||
      const res = await fetch(langUrl);
 | 
			
		||||
      if (!res.ok) {
 | 
			
		||||
        throw new Error(`Failed to fetch ${langUrl}`);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const data = Buffer.from(await res.arrayBuffer());
 | 
			
		||||
 | 
			
		||||
      const digest = createHash('sha512').update(data).digest('base64');
 | 
			
		||||
 | 
			
		||||
      let locale = manifest.languageToSmartlingLocale[language] ?? language;
 | 
			
		||||
      locale = locale.replace(/_/g, '-');
 | 
			
		||||
 | 
			
		||||
      const pinnedUrl =
 | 
			
		||||
        'https://updates2.signal.org/static/android/' +
 | 
			
		||||
        `emoji/search/${manifest.version}/${language}.json`;
 | 
			
		||||
 | 
			
		||||
      extraResources.set(locale, {
 | 
			
		||||
        url: pinnedUrl,
 | 
			
		||||
        size: data.length,
 | 
			
		||||
        digest,
 | 
			
		||||
      });
 | 
			
		||||
    })
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const resourcesPath = join(
 | 
			
		||||
    __dirname,
 | 
			
		||||
    '..',
 | 
			
		||||
    '..',
 | 
			
		||||
    'build',
 | 
			
		||||
    'optional-resources.json'
 | 
			
		||||
  );
 | 
			
		||||
  const resources = OptionalResourcesDictSchema.parse(
 | 
			
		||||
    JSON.parse(await readFile(resourcesPath, 'utf8'))
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  for (const [locale, resource] of extraResources) {
 | 
			
		||||
    resources[`emoji-index-${locale}.json`] = resource;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const prettierConfig = await prettier.resolveConfig(
 | 
			
		||||
    join(__dirname, '..', '..', 'build')
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const output = prettier.format(JSON.stringify(resources, null, 2), {
 | 
			
		||||
    ...prettierConfig,
 | 
			
		||||
    filepath: resourcesPath,
 | 
			
		||||
  });
 | 
			
		||||
  await writeFile(resourcesPath, output);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
main().catch(err => {
 | 
			
		||||
  console.error(err);
 | 
			
		||||
  process.exit(1);
 | 
			
		||||
});
 | 
			
		||||
| 
						 | 
				
			
			@ -5,7 +5,7 @@ import { assert } from 'chai';
 | 
			
		|||
import sinon from 'sinon';
 | 
			
		||||
 | 
			
		||||
import { EmojiCompletion } from '../../../quill/emoji/completion';
 | 
			
		||||
import type { EmojiData } from '../../../components/emoji/lib';
 | 
			
		||||
import { createSearch } from '../../../components/emoji/lib';
 | 
			
		||||
 | 
			
		||||
describe('emojiCompletion', () => {
 | 
			
		||||
  let emojiCompletion: EmojiCompletion;
 | 
			
		||||
| 
						 | 
				
			
			@ -27,6 +27,10 @@ describe('emojiCompletion', () => {
 | 
			
		|||
      onPickEmoji: sinon.stub(),
 | 
			
		||||
      setEmojiPickerElement: sinon.stub(),
 | 
			
		||||
      skinTone: 0,
 | 
			
		||||
      search: createSearch([
 | 
			
		||||
        { shortName: 'smile', tags: [], rank: 0 },
 | 
			
		||||
        { shortName: 'smile_cat', tags: [], rank: 0 },
 | 
			
		||||
      ]),
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
 | 
			
		||||
| 
						 | 
				
			
			@ -52,13 +56,12 @@ describe('emojiCompletion', () => {
 | 
			
		|||
 | 
			
		||||
  describe('onTextChange', () => {
 | 
			
		||||
    let insertEmojiStub: sinon.SinonStub<
 | 
			
		||||
      [EmojiData, number, number, (boolean | undefined)?],
 | 
			
		||||
      [string, number, number, (boolean | undefined)?],
 | 
			
		||||
      void
 | 
			
		||||
    >;
 | 
			
		||||
 | 
			
		||||
    beforeEach(() => {
 | 
			
		||||
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
 | 
			
		||||
      emojiCompletion.results = [{ short_name: 'joy' } as any];
 | 
			
		||||
      emojiCompletion.results = ['joy'];
 | 
			
		||||
      emojiCompletion.index = 5;
 | 
			
		||||
      insertEmojiStub = sinon
 | 
			
		||||
        .stub(emojiCompletion, 'insertEmoji')
 | 
			
		||||
| 
						 | 
				
			
			@ -165,7 +168,7 @@ describe('emojiCompletion', () => {
 | 
			
		|||
      });
 | 
			
		||||
 | 
			
		||||
      it('stores the results and renders', () => {
 | 
			
		||||
        assert.equal(emojiCompletion.results.length, 10);
 | 
			
		||||
        assert.equal(emojiCompletion.results.length, 2);
 | 
			
		||||
        assert.equal((emojiCompletion.render as sinon.SinonStub).called, true);
 | 
			
		||||
      });
 | 
			
		||||
    });
 | 
			
		||||
| 
						 | 
				
			
			@ -193,7 +196,7 @@ describe('emojiCompletion', () => {
 | 
			
		|||
        it('inserts the emoji at the current cursor position', () => {
 | 
			
		||||
          const [emoji, index, range] = insertEmojiStub.args[0];
 | 
			
		||||
 | 
			
		||||
          assert.equal(emoji.short_name, 'smile');
 | 
			
		||||
          assert.equal(emoji, 'smile');
 | 
			
		||||
          assert.equal(index, 0);
 | 
			
		||||
          assert.equal(range, 7);
 | 
			
		||||
        });
 | 
			
		||||
| 
						 | 
				
			
			@ -222,7 +225,7 @@ describe('emojiCompletion', () => {
 | 
			
		|||
        it('inserts the emoji at the current cursor position', () => {
 | 
			
		||||
          const [emoji, index, range] = insertEmojiStub.args[0];
 | 
			
		||||
 | 
			
		||||
          assert.equal(emoji.short_name, 'smile');
 | 
			
		||||
          assert.equal(emoji, 'smile');
 | 
			
		||||
          assert.equal(index, 7);
 | 
			
		||||
          assert.equal(range, 7);
 | 
			
		||||
        });
 | 
			
		||||
| 
						 | 
				
			
			@ -282,7 +285,7 @@ describe('emojiCompletion', () => {
 | 
			
		|||
        it('inserts the emoji at the current cursor position', () => {
 | 
			
		||||
          const [emoji, index, range] = insertEmojiStub.args[0];
 | 
			
		||||
 | 
			
		||||
          assert.equal(emoji.short_name, 'smile');
 | 
			
		||||
          assert.equal(emoji, 'smile');
 | 
			
		||||
          assert.equal(index, 0);
 | 
			
		||||
          assert.equal(range, validEmoji.length);
 | 
			
		||||
        });
 | 
			
		||||
| 
						 | 
				
			
			@ -331,7 +334,7 @@ describe('emojiCompletion', () => {
 | 
			
		|||
        it('inserts the emoji at the current cursor position', () => {
 | 
			
		||||
          const [emoji, index, range] = insertEmojiStub.args[0];
 | 
			
		||||
 | 
			
		||||
          assert.equal(emoji.short_name, 'smile');
 | 
			
		||||
          assert.equal(emoji, 'smile');
 | 
			
		||||
          assert.equal(index, 0);
 | 
			
		||||
          assert.equal(range, 6);
 | 
			
		||||
        });
 | 
			
		||||
| 
						 | 
				
			
			@ -345,17 +348,12 @@ describe('emojiCompletion', () => {
 | 
			
		|||
 | 
			
		||||
  describe('completeEmoji', () => {
 | 
			
		||||
    let insertEmojiStub: sinon.SinonStub<
 | 
			
		||||
      [EmojiData, number, number, (boolean | undefined)?],
 | 
			
		||||
      [string, number, number, (boolean | undefined)?],
 | 
			
		||||
      void
 | 
			
		||||
    >;
 | 
			
		||||
 | 
			
		||||
    beforeEach(() => {
 | 
			
		||||
      emojiCompletion.results = [
 | 
			
		||||
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
 | 
			
		||||
        { short_name: 'smile' } as any,
 | 
			
		||||
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
 | 
			
		||||
        { short_name: 'smile_cat' } as any,
 | 
			
		||||
      ];
 | 
			
		||||
      emojiCompletion.results = ['smile', 'smile_cat'];
 | 
			
		||||
      emojiCompletion.index = 1;
 | 
			
		||||
      insertEmojiStub = sinon.stub(emojiCompletion, 'insertEmoji');
 | 
			
		||||
    });
 | 
			
		||||
| 
						 | 
				
			
			@ -381,7 +379,7 @@ describe('emojiCompletion', () => {
 | 
			
		|||
      it('inserts the currently selected emoji at the current cursor position', () => {
 | 
			
		||||
        const [emoji, insertIndex, range] = insertEmojiStub.args[0];
 | 
			
		||||
 | 
			
		||||
        assert.equal(emoji.short_name, 'smile_cat');
 | 
			
		||||
        assert.equal(emoji, 'smile_cat');
 | 
			
		||||
        assert.equal(insertIndex, 0);
 | 
			
		||||
        assert.equal(range, text.length);
 | 
			
		||||
      });
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										21
									
								
								ts/types/OptionalResource.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								ts/types/OptionalResource.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,21 @@
 | 
			
		|||
// Copyright 2024 Signal Messenger, LLC
 | 
			
		||||
// SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		||||
 | 
			
		||||
import z from 'zod';
 | 
			
		||||
 | 
			
		||||
export const OptionalResourceSchema = z.object({
 | 
			
		||||
  digest: z.string(),
 | 
			
		||||
  url: z.string(),
 | 
			
		||||
  size: z.number(),
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export type OptionalResourceType = z.infer<typeof OptionalResourceSchema>;
 | 
			
		||||
 | 
			
		||||
export const OptionalResourcesDictSchema = z.record(
 | 
			
		||||
  z.string(),
 | 
			
		||||
  OptionalResourceSchema
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
export type OptionalResourcesDictType = z.infer<
 | 
			
		||||
  typeof OptionalResourcesDictSchema
 | 
			
		||||
>;
 | 
			
		||||
							
								
								
									
										17
									
								
								ts/types/emoji.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								ts/types/emoji.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,17 @@
 | 
			
		|||
// Copyright 2024 Signal Messenger, LLC
 | 
			
		||||
// SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		||||
 | 
			
		||||
import z from 'zod';
 | 
			
		||||
 | 
			
		||||
export const LocaleEmojiSchema = z.object({
 | 
			
		||||
  emoji: z.string(),
 | 
			
		||||
  shortName: z.string(),
 | 
			
		||||
  tags: z.string().array(),
 | 
			
		||||
  rank: z.number(),
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export type LocaleEmojiType = z.infer<typeof LocaleEmojiSchema>;
 | 
			
		||||
 | 
			
		||||
export const LocaleEmojiListSchema = LocaleEmojiSchema.array();
 | 
			
		||||
 | 
			
		||||
export type LocaleEmojiListType = z.infer<typeof LocaleEmojiListSchema>;
 | 
			
		||||
| 
						 | 
				
			
			@ -3691,6 +3691,14 @@
 | 
			
		|||
    "updated": "2022-06-14T22:04:43.988Z",
 | 
			
		||||
    "reasonDetail": "Handling outside click"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "rule": "React-useRef",
 | 
			
		||||
    "path": "ts/hooks/useEmojiSearch.ts",
 | 
			
		||||
    "line": "  const searchRef = useRef(defaultSearch);",
 | 
			
		||||
    "reasonCategory": "usageTrusted",
 | 
			
		||||
    "updated": "2024-03-16T18:34:38.165Z",
 | 
			
		||||
    "reasonDetail": "Quill requires an immutable reference to the search function"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "rule": "React-useRef",
 | 
			
		||||
    "path": "ts/hooks/useIntersectionObserver.ts",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -10,6 +10,7 @@ import {
 | 
			
		|||
  createCachedIntl as createCachedIntlMain,
 | 
			
		||||
  setupI18n as setupI18nMain,
 | 
			
		||||
} from './setupI18nMain';
 | 
			
		||||
import type { SetupI18nOptionsType } from './setupI18nMain';
 | 
			
		||||
import { strictAssert } from './assert';
 | 
			
		||||
 | 
			
		||||
export { isLocaleMessageType } from './setupI18nMain';
 | 
			
		||||
| 
						 | 
				
			
			@ -30,7 +31,8 @@ export function createCachedIntl(
 | 
			
		|||
 | 
			
		||||
export function setupI18n(
 | 
			
		||||
  locale: string,
 | 
			
		||||
  messages: LocaleMessagesType
 | 
			
		||||
  messages: LocaleMessagesType,
 | 
			
		||||
  options: Omit<SetupI18nOptionsType, 'renderEmojify'> = {}
 | 
			
		||||
): LocalizerType {
 | 
			
		||||
  return setupI18nMain(locale, messages, { renderEmojify });
 | 
			
		||||
  return setupI18nMain(locale, messages, { ...options, renderEmojify });
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -22,6 +22,7 @@ import { initialize as initializeLogging } from '../logging/set_up_renderer_logg
 | 
			
		|||
import { MinimalSignalContext } from './minimalContext';
 | 
			
		||||
import type { LocaleDirection } from '../../app/locale';
 | 
			
		||||
import type { HourCyclePreference } from '../types/I18N';
 | 
			
		||||
import type { LocaleEmojiListType } from '../types/emoji';
 | 
			
		||||
 | 
			
		||||
strictAssert(Boolean(window.SignalContext), 'context must be defined');
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -48,6 +49,9 @@ export type MinimalSignalContextType = {
 | 
			
		|||
  getResolvedMessagesLocale: () => string;
 | 
			
		||||
  getPreferredSystemLocales: () => Array<string>;
 | 
			
		||||
  getLocaleOverride: () => string | null;
 | 
			
		||||
  getLocalizedEmojiList: (
 | 
			
		||||
    locale: string
 | 
			
		||||
  ) => Promise<LocaleEmojiListType | undefined>;
 | 
			
		||||
  getMainWindowStats: () => Promise<MainWindowStatsType>;
 | 
			
		||||
  getMenuOptions: () => Promise<MenuOptionsType>;
 | 
			
		||||
  getNodeVersion: () => string;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -5,6 +5,8 @@ import type { MenuItemConstructorOptions } from 'electron';
 | 
			
		|||
import { ipcRenderer } from 'electron';
 | 
			
		||||
 | 
			
		||||
import type { MenuOptionsType } from '../types/menu';
 | 
			
		||||
import type { LocaleEmojiListType } from '../types/emoji';
 | 
			
		||||
import { LocaleEmojiListSchema } from '../types/emoji';
 | 
			
		||||
import type { MainWindowStatsType, MinimalSignalContextType } from './context';
 | 
			
		||||
import { activeWindowService } from '../context/activeWindowService';
 | 
			
		||||
import { config } from '../context/config';
 | 
			
		||||
| 
						 | 
				
			
			@ -18,6 +20,8 @@ import {
 | 
			
		|||
} from '../context/localeMessages';
 | 
			
		||||
import { waitForSettingsChange } from '../context/waitForSettingsChange';
 | 
			
		||||
 | 
			
		||||
const emojiListCache = new Map<string, LocaleEmojiListType>();
 | 
			
		||||
 | 
			
		||||
export const MinimalSignalContext: MinimalSignalContextType = {
 | 
			
		||||
  activeWindowService,
 | 
			
		||||
  config,
 | 
			
		||||
| 
						 | 
				
			
			@ -40,6 +44,21 @@ export const MinimalSignalContext: MinimalSignalContextType = {
 | 
			
		|||
  async getMenuOptions(): Promise<MenuOptionsType> {
 | 
			
		||||
    return ipcRenderer.invoke('getMenuOptions');
 | 
			
		||||
  },
 | 
			
		||||
  async getLocalizedEmojiList(locale: string) {
 | 
			
		||||
    const cached = emojiListCache.get(locale);
 | 
			
		||||
    if (cached) {
 | 
			
		||||
      return cached;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const buf = await ipcRenderer.invoke(
 | 
			
		||||
      'OptionalResourceService:getData',
 | 
			
		||||
      `emoji-index-${locale}.json`
 | 
			
		||||
    );
 | 
			
		||||
    const json = JSON.parse(Buffer.from(buf).toString());
 | 
			
		||||
    const result = LocaleEmojiListSchema.parse(json);
 | 
			
		||||
    emojiListCache.set(locale, result);
 | 
			
		||||
    return result;
 | 
			
		||||
  },
 | 
			
		||||
  getI18nAvailableLocales: () => config.availableLocales,
 | 
			
		||||
  getI18nLocale: () => config.resolvedTranslationsLocale,
 | 
			
		||||
  getI18nLocaleMessages: () => localeMessages,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue