diff --git a/app/OptionalResourceService.ts b/app/OptionalResourceService.ts new file mode 100644 index 0000000000..dff21cea82 --- /dev/null +++ b/app/OptionalResourceService.ts @@ -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({ + max: MAX_CACHE_SIZE, + + length: buf => buf.length, + }); + + private readonly fileQueues = new Map(); + + 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 { + 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 { + 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; + 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( + filePath: string, + body: () => Promise + ): Promise { + 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 { + 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; + } +} diff --git a/app/main.ts b/app/main.ts index 45c6831a81..cffa5ecabe 100644 --- a/app/main.ts +++ b/app/main.ts @@ -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) { diff --git a/build/optional-resources.json b/build/optional-resources.json new file mode 100644 index 0000000000..4c83f5b9c9 --- /dev/null +++ b/build/optional-resources.json @@ -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==" + } +} diff --git a/package.json b/package.json index bba24e1f22..deab533d2f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/ts/components/CompositionInput.tsx b/ts/components/CompositionInput.tsx index 697d42ed6b..6bbdb94b5f 100644 --- a/ts/components/CompositionInput.tsx +++ b/ts/components/CompositionInput.tsx @@ -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, diff --git a/ts/components/emoji/EmojiPicker.tsx b/ts/components/emoji/EmojiPicker.tsx index eff800ee6e..92509f0111 100644 --- a/ts/components/emoji/EmojiPicker.tsx +++ b/ts/components/emoji/EmojiPicker.tsx @@ -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; diff --git a/ts/components/emoji/lib.ts b/ts/components/emoji/lib.ts index 9f7de8b33c..b7e6fe98f9 100644 --- a/ts/components/emoji/lib.ts +++ b/ts/components/emoji/lib.ts @@ -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, { - shouldSort: true, - threshold: 0.2, - minMatchCharLength: 1, - keys: ['short_name', 'short_names'], -}); +export type SearchFnType = (query: string, count?: number) => Array; -const fuseExactPrefix = new Fuse(data, { - shouldSort: true, - threshold: 0, // effectively a prefix search - minMatchCharLength: 2, - keys: ['short_name', 'short_names'], -}); +export type SearchEmojiListType = ReadonlyArray< + Pick +>; -export function search(query: string, count = 0): Array { - // 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; +type CachedSearchFnType = Readonly<{ + localeEmoji: SearchEmojiListType; + fn: SearchFnType; +}>; - const results = fuseIndex - .search(query.substr(0, 32)) - .map(result => result.item); +let cachedSearchFn: CachedSearchFnType | undefined; - if (count) { - return take(results, count); +export function createSearch(localeEmoji: SearchEmojiListType): SearchFnType { + if (cachedSearchFn && cachedSearchFn.localeEmoji === localeEmoji) { + return cachedSearchFn.fn; } - return results; + const fuse = new Fuse(localeEmoji, { + shouldSort: true, + threshold: 0.2, + minMatchCharLength: 1, + keys: ['shortName', 'tags'], + includeScore: true, + }); + + const fuseExactPrefix = new Fuse(localeEmoji, { + shouldSort: true, + threshold: 0, // effectively a prefix search + minMatchCharLength: 2, + keys: ['shortName', 'tags'], + includeScore: true, + }); + + const fn = (query: string, count = 0): Array => { + // 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 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([ diff --git a/ts/hooks/useEmojiSearch.ts b/ts/hooks/useEmojiSearch.ts new file mode 100644 index 0000000000..d19126b47c --- /dev/null +++ b/ts/hooks/useEmojiSearch.ts @@ -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 { + 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); + }, []); +} diff --git a/ts/quill/emoji/completion.tsx b/ts/quill/emoji/completion.tsx index fc885789e6..d16f7d86e0 100644 --- a/ts/quill/emoji/completion.tsx +++ b/ts/quill/emoji/completion.tsx @@ -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; + results: Array; 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,25 +137,17 @@ 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, - range.index - leftTokenText.length - numberOfColons, - leftTokenText.length + numberOfColons - ); - return INTERCEPT; - } - } else { - this.reset(); - return PASS_THROUGH; + this.insertEmoji( + leftTokenText, + range.index - leftTokenText.length - numberOfColons, + leftTokenText.length + numberOfColons + ); + return INTERCEPT; } + this.reset(); + return PASS_THROUGH; } if (rightTokenTextMatch) { @@ -167,19 +155,12 @@ export class EmojiCompletion { const tokenText = leftTokenText + rightTokenText; if (isShortName(tokenText)) { - const emojiData = convertShortNameToData( + this.insertEmoji( tokenText, - this.options.skinTone + range.index - leftTokenText.length - 1, + tokenText.length + 2 ); - - if (emojiData) { - this.insertEmoji( - emojiData, - range.index - leftTokenText.length - 1, - tokenText.length + 2 - ); - return INTERCEPT; - } + return INTERCEPT; } } @@ -188,7 +169,7 @@ export class EmojiCompletion { 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) => ( ))} diff --git a/ts/scripts/get-emoji-locales.ts b/ts/scripts/get-emoji-locales.ts new file mode 100644 index 0000000000..885a08eda6 --- /dev/null +++ b/ts/scripts/get-emoji-locales.ts @@ -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 { + const res = await fetch(url); + if (!res.ok) { + throw new Error(`Failed to fetch ${url}`); + } + + return res.json(); +} + +async function main(): Promise { + 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(); + + 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); +}); diff --git a/ts/test-electron/quill/emoji/completion_test.tsx b/ts/test-electron/quill/emoji/completion_test.tsx index 72a6f9cb6d..07775da304 100644 --- a/ts/test-electron/quill/emoji/completion_test.tsx +++ b/ts/test-electron/quill/emoji/completion_test.tsx @@ -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); }); diff --git a/ts/types/OptionalResource.ts b/ts/types/OptionalResource.ts new file mode 100644 index 0000000000..2a40446f0c --- /dev/null +++ b/ts/types/OptionalResource.ts @@ -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; + +export const OptionalResourcesDictSchema = z.record( + z.string(), + OptionalResourceSchema +); + +export type OptionalResourcesDictType = z.infer< + typeof OptionalResourcesDictSchema +>; diff --git a/ts/types/emoji.ts b/ts/types/emoji.ts new file mode 100644 index 0000000000..05c46eca73 --- /dev/null +++ b/ts/types/emoji.ts @@ -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; + +export const LocaleEmojiListSchema = LocaleEmojiSchema.array(); + +export type LocaleEmojiListType = z.infer; diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 36ad06d65b..471c3e473a 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -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", diff --git a/ts/util/setupI18n.tsx b/ts/util/setupI18n.tsx index f16a46dc36..c18a92785a 100644 --- a/ts/util/setupI18n.tsx +++ b/ts/util/setupI18n.tsx @@ -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 = {} ): LocalizerType { - return setupI18nMain(locale, messages, { renderEmojify }); + return setupI18nMain(locale, messages, { ...options, renderEmojify }); } diff --git a/ts/windows/context.ts b/ts/windows/context.ts index 70ca5778ac..b0b99fd6a4 100644 --- a/ts/windows/context.ts +++ b/ts/windows/context.ts @@ -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; getLocaleOverride: () => string | null; + getLocalizedEmojiList: ( + locale: string + ) => Promise; getMainWindowStats: () => Promise; getMenuOptions: () => Promise; getNodeVersion: () => string; diff --git a/ts/windows/minimalContext.ts b/ts/windows/minimalContext.ts index 2e3f3e5839..5936bca4db 100644 --- a/ts/windows/minimalContext.ts +++ b/ts/windows/minimalContext.ts @@ -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(); + export const MinimalSignalContext: MinimalSignalContextType = { activeWindowService, config, @@ -40,6 +44,21 @@ export const MinimalSignalContext: MinimalSignalContextType = { async getMenuOptions(): Promise { 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,