From 29de50c12a264084848a51f6d3b4738f45b5d7d3 Mon Sep 17 00:00:00 2001 From: Ken Powers Date: Thu, 16 May 2019 15:32:11 -0700 Subject: [PATCH] Stickers Co-authored-by: scott@signal.org Co-authored-by: ken@signal.org --- _locales/en/messages.json | 104 ++ app/attachment_channel.js | 18 +- app/attachments.js | 53 +- app/sql.js | 527 +++++++++- app/sql_channel.js | 8 +- background.html | 3 +- fixtures/512x515-thumbs-up-lincoln.webp | Bin 0 -> 38226 bytes fixtures/kitten-1-64-64.jpg | Bin 0 -> 1476 bytes fixtures/kitten-2-64-64.jpg | Bin 0 -> 8817 bytes fixtures/kitten-3-64-64.jpg | Bin 0 -> 9212 bytes images/badge-filled-16.svg | 7 + images/check-circle-filled-16.svg | 11 + images/chevron-left-12.svg | 6 + images/chevron-right-12.svg | 6 + images/more-h.svg | 8 + images/plus-20.svg | 6 + images/recent-outline.svg | 7 + images/sticker-filled.svg | 10 + js/background.js | 126 ++- js/message_controller.js | 4 + js/models/conversations.js | 78 +- js/models/messages.js | 129 ++- js/modules/attachment_downloads.js | 44 +- js/modules/crypto.js | 33 + js/modules/data.d.ts | 18 + js/modules/data.js | 51 + js/modules/link_previews.js | 32 +- js/modules/signal.js | 42 + js/modules/stickers.d.ts | 1 + js/modules/stickers.js | 495 +++++++++ js/modules/types/attachment.js | 2 +- js/modules/types/conversation.js | 11 +- js/modules/types/message.js | 93 +- js/modules/web_api.js | 130 ++- js/storage.js | 14 + js/views/conversation_view.js | 299 ++++-- js/views/inbox_view.js | 14 + js/views/message_view.js | 18 +- js/views/react_wrapper_view.js | 13 +- libtextsecure/crypto.js | 7 +- libtextsecure/message_receiver.js | 35 + libtextsecure/protobufs.js | 1 + libtextsecure/sendmessage.js | 128 ++- main.js | 39 +- package.json | 7 +- preload.js | 13 +- protos/SignalService.proto | 37 +- protos/Stickers.proto | 13 + styleguide.config.js | 7 +- stylesheets/_conversation.scss | 5 + stylesheets/_global.scss | 4 + stylesheets/_ios.scss | 12 +- stylesheets/_mixins.scss | 6 +- stylesheets/_modules.scss | 994 +++++++++++++++++- stylesheets/_theme_dark.scss | 6 +- stylesheets/_variables.scss | 1 + ts/components/ConfirmationDialog.md | 16 + ts/components/ConfirmationDialog.tsx | 117 +++ ts/components/ConfirmationModal.tsx | 89 ++ ts/components/conversation/ExpireTimer.tsx | 7 +- ts/components/conversation/Image.md | 48 + ts/components/conversation/Image.tsx | 31 +- ts/components/conversation/ImageGrid.md | 23 + ts/components/conversation/ImageGrid.tsx | 25 +- ts/components/conversation/Message.md | 576 ++++++++-- ts/components/conversation/Message.tsx | 92 +- ts/components/conversation/Timestamp.tsx | 5 +- ts/components/stickers/StickerButton.md | 271 +++++ ts/components/stickers/StickerButton.tsx | 255 +++++ ts/components/stickers/StickerManager.md | 178 ++++ ts/components/stickers/StickerManager.tsx | 107 ++ .../stickers/StickerManagerPackRow.tsx | 129 +++ .../stickers/StickerPackInstallButton.tsx | 29 + ts/components/stickers/StickerPicker.md | 321 ++++++ ts/components/stickers/StickerPicker.tsx | 282 +++++ ts/components/stickers/StickerPreviewModal.md | 29 + .../stickers/StickerPreviewModal.tsx | 165 +++ ts/shims/storage.ts | 9 + ts/shims/textsecure.ts | 77 ++ ts/state/actions.ts | 16 +- ts/state/ducks/conversations.ts | 8 +- ts/state/ducks/items.ts | 121 +++ ts/state/ducks/search.ts | 8 +- ts/state/ducks/stickers.ts | 463 ++++++++ ts/state/ducks/user.ts | 9 +- ts/state/reducer.ts | 37 +- ts/state/roots/createStickerButton.tsx | 16 + ts/state/roots/createStickerManager.tsx | 16 + ts/state/roots/createStickerPreviewModal.tsx | 16 + ts/state/selectors/stickers.ts | 206 ++++ ts/state/selectors/user.ts | 10 + ts/state/smart/StickerButton.tsx | 48 + ts/state/smart/StickerManager.tsx | 28 + ts/state/smart/StickerPreviewModal.tsx | 54 + ts/styleguide/StyleGuideUtil.ts | 22 + ts/types/MIME.ts | 1 + ts/util/lint/exceptions.json | 486 ++++----- ts/util/lint/linter.ts | 1 + tslint.json | 3 + yarn.lock | 79 +- 100 files changed, 7572 insertions(+), 693 deletions(-) create mode 100644 fixtures/512x515-thumbs-up-lincoln.webp create mode 100644 fixtures/kitten-1-64-64.jpg create mode 100644 fixtures/kitten-2-64-64.jpg create mode 100644 fixtures/kitten-3-64-64.jpg create mode 100644 images/badge-filled-16.svg create mode 100644 images/check-circle-filled-16.svg create mode 100644 images/chevron-left-12.svg create mode 100644 images/chevron-right-12.svg create mode 100644 images/more-h.svg create mode 100644 images/plus-20.svg create mode 100644 images/recent-outline.svg create mode 100644 images/sticker-filled.svg create mode 100644 js/modules/stickers.d.ts create mode 100644 js/modules/stickers.js create mode 100644 protos/Stickers.proto create mode 100644 ts/components/ConfirmationDialog.md create mode 100644 ts/components/ConfirmationDialog.tsx create mode 100644 ts/components/ConfirmationModal.tsx create mode 100644 ts/components/stickers/StickerButton.md create mode 100644 ts/components/stickers/StickerButton.tsx create mode 100644 ts/components/stickers/StickerManager.md create mode 100644 ts/components/stickers/StickerManager.tsx create mode 100644 ts/components/stickers/StickerManagerPackRow.tsx create mode 100644 ts/components/stickers/StickerPackInstallButton.tsx create mode 100644 ts/components/stickers/StickerPicker.md create mode 100644 ts/components/stickers/StickerPicker.tsx create mode 100644 ts/components/stickers/StickerPreviewModal.md create mode 100644 ts/components/stickers/StickerPreviewModal.tsx create mode 100644 ts/shims/storage.ts create mode 100644 ts/shims/textsecure.ts create mode 100644 ts/state/ducks/items.ts create mode 100644 ts/state/ducks/stickers.ts create mode 100644 ts/state/roots/createStickerButton.tsx create mode 100644 ts/state/roots/createStickerManager.tsx create mode 100644 ts/state/roots/createStickerPreviewModal.tsx create mode 100644 ts/state/selectors/stickers.ts create mode 100644 ts/state/smart/StickerButton.tsx create mode 100644 ts/state/smart/StickerManager.tsx create mode 100644 ts/state/smart/StickerPreviewModal.tsx diff --git a/_locales/en/messages.json b/_locales/en/messages.json index e67f64278..2e7f67311 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1730,5 +1730,109 @@ "example": "Alice, Bob" } } + }, + "message--getNotificationText--stickers": { + "message": "Sticker message", + "description": + "Shown in notifications and in the left pane instead of sticker image." + }, + "stickers--toast--InstallFailed": { + "message": "Sticker pack could not be installed", + "description": + "Shown in a toast if the user attempts to install a sticker pack and it fails" + }, + "stickers--StickerManager--InstalledPacks": { + "message": "Installed Stickers", + "description": + "Shown in the sticker pack manager above your installed sticker packs." + }, + "stickers--StickerManager--InstalledPacks--Empty": { + "message": "No stickers installed", + "description": + "Shown in the sticker pack manager when you don't have any installed sticker packs." + }, + "stickers--StickerManager--BlessedPacks": { + "message": "Signal Artist Series", + "description": + "Shown in the sticker pack manager above the default sticker packs." + }, + "stickers--StickerManager--BlessedPacks--Empty": { + "message": "No Signal Artist stickers available", + "description": + "Shown in the sticker pack manager when there are no blessed sticker packs available." + }, + "stickers--StickerManager--ReceivedPacks": { + "message": "Stickers You Received", + "description": + "Shown in the sticker pack manager above sticker packs which you have received in messages." + }, + "stickers--StickerManager--ReceivedPacks--Empty": { + "message": "Stickers from incoming messages will appear here", + "description": + "Shown in the sticker pack manager when you have not received any sticker packs in messages." + }, + "stickers--StickerManager--Install": { + "message": "Install", + "description": + "Shown in the sticker pack manager next to sticker packs which can be installed." + }, + "stickers--StickerManager--Uninstall": { + "message": "Uninstall", + "description": + "Shown in the sticker pack manager next to sticker packs which are already installed." + }, + "stickers--StickerManager--UninstallWarning": { + "message": + "You may not be able to re-install this sticker pack if you no longer have the source message.", + "description": + "Shown in the sticker pack manager next to sticker packs which are already installed." + }, + "stickers--StickerManager--Introduction--Title": { + "message": "Introducing Stickers", + "description": + "Shown as the title on a tooltip when the user upgrades to a version of Signal supporting stickers." + }, + "stickers--StickerManager--Introduction--Body": { + "message": "Why use words when you can use stickers?", + "description": + "Shown as the body on a tooltip when the user upgrades to a version of Signal supporting stickers." + }, + "stickers--StickerPicker--DownloadError": { + "message": "Some stickers could not be downloaded.", + "description": + "Shown in the sticker picker when one or more stickers could not be downloaded." + }, + "stickers--StickerPicker--DownloadPending": { + "message": "Installing sticker pack...", + "description": + "Shown in the sticker picker when one or more stickers are still downloading." + }, + "stickers--StickerPicker--Empty": { + "message": "No stickers found", + "description": + "Shown in the sticker picker when there are no stickers to show." + }, + "stickers--StickerPicker--Hint": { + "message": "New sticker packs from your messages are available to install", + "description": + "Shown in the sticker picker the first time you have received new packs you can install." + }, + "stickers--StickerPicker--NoPacks": { + "message": "No sticker packs found", + "description": + "Shown in the sticker picker when there are no installed sticker packs." + }, + "stickers--StickerPicker--NoRecents": { + "message": "Recently used stickers will appear here.", + "description": + "Shown in the sticker picker when there are no recent stickers to show." + }, + "stickers--StickerPreview--Title": { + "message": "Sticker Pack", + "description": "The title that appears in the sticker pack preview modal." + }, + "confirmation-dialog--Cancel": { + "message": "Cancel", + "description": "Appears on the cancel button in confirmation dialogs." } } diff --git a/app/attachment_channel.js b/app/attachment_channel.js index c5e229751..672b531a3 100644 --- a/app/attachment_channel.js +++ b/app/attachment_channel.js @@ -11,6 +11,7 @@ module.exports = { let initialized = false; const ERASE_ATTACHMENTS_KEY = 'erase-attachments'; +const ERASE_STICKERS_KEY = 'erase-stickers'; const CLEANUP_ORPHANED_ATTACHMENTS_KEY = 'cleanup-orphaned-attachments'; async function initialize({ configDir, cleanupOrphanedAttachments }) { @@ -19,12 +20,10 @@ async function initialize({ configDir, cleanupOrphanedAttachments }) { } initialized = true; - console.log('Ensure attachments directory exists'); - await Attachments.ensureDirectory(configDir); - const attachmentsDir = Attachments.getPath(configDir); + const stickersDir = Attachments.getStickersPath(configDir); - ipcMain.on(ERASE_ATTACHMENTS_KEY, async event => { + ipcMain.on(ERASE_ATTACHMENTS_KEY, event => { try { rimraf.sync(attachmentsDir); event.sender.send(`${ERASE_ATTACHMENTS_KEY}-done`); @@ -35,6 +34,17 @@ async function initialize({ configDir, cleanupOrphanedAttachments }) { } }); + ipcMain.on(ERASE_STICKERS_KEY, event => { + try { + rimraf.sync(stickersDir); + event.sender.send(`${ERASE_STICKERS_KEY}-done`); + } catch (error) { + const errorForDisplay = error && error.stack ? error.stack : error; + console.log(`erase stickers error: ${errorForDisplay}`); + event.sender.send(`${ERASE_STICKERS_KEY}-done`, error); + } + }); + ipcMain.on(CLEANUP_ORPHANED_ATTACHMENTS_KEY, async event => { try { await cleanupOrphanedAttachments(); diff --git a/app/attachments.js b/app/attachments.js index f8b8627fe..f6ea69f65 100644 --- a/app/attachments.js +++ b/app/attachments.js @@ -8,6 +8,7 @@ const toArrayBuffer = require('to-arraybuffer'); const { map, isArrayBuffer, isString } = require('lodash'); const PATH = 'attachments.noindex'; +const STICKER_PATH = 'stickers.noindex'; exports.getAllAttachments = async userDataPath => { const dir = exports.getPath(userDataPath); @@ -17,6 +18,14 @@ exports.getAllAttachments = async userDataPath => { return map(files, file => path.relative(dir, file)); }; +exports.getAllStickers = async userDataPath => { + const dir = exports.getStickersPath(userDataPath); + const pattern = path.join(dir, '**', '*'); + + const files = await pify(glob)(pattern, { nodir: true }); + return map(files, file => path.relative(dir, file)); +}; + // getPath :: AbsolutePath -> AbsolutePath exports.getPath = userDataPath => { if (!isString(userDataPath)) { @@ -25,12 +34,12 @@ exports.getPath = userDataPath => { return path.join(userDataPath, PATH); }; -// ensureDirectory :: AbsolutePath -> IO Unit -exports.ensureDirectory = async userDataPath => { +// getStickersPath :: AbsolutePath -> AbsolutePath +exports.getStickersPath = userDataPath => { if (!isString(userDataPath)) { throw new TypeError("'userDataPath' must be a string"); } - await fse.ensureDir(exports.getPath(userDataPath)); + return path.join(userDataPath, STICKER_PATH); }; // createReader :: AttachmentsPath -> @@ -56,6 +65,30 @@ exports.createReader = root => { }; }; +exports.copyIntoAttachmentsDirectory = root => { + if (!isString(root)) { + throw new TypeError("'root' must be a path"); + } + + return async sourcePath => { + if (!isString(sourcePath)) { + throw new TypeError('sourcePath must be a string'); + } + + const name = exports.createName(); + const relativePath = exports.getRelativePath(name); + const absolutePath = path.join(root, relativePath); + const normalized = path.normalize(absolutePath); + if (!normalized.startsWith(root)) { + throw new Error('Invalid relative path'); + } + + await fse.ensureFile(normalized); + await fse.copy(sourcePath, normalized); + return relativePath; + }; +}; + // createWriterForNew :: AttachmentsPath -> // ArrayBuffer -> // IO (Promise RelativePath) @@ -142,6 +175,20 @@ exports.deleteAll = async ({ userDataPath, attachments }) => { console.log(`deleteAll: deleted ${attachments.length} files`); }; +exports.deleteAllStickers = async ({ userDataPath, stickers }) => { + const deleteFromDisk = exports.createDeleter( + exports.getStickersPath(userDataPath) + ); + + for (let index = 0, max = stickers.length; index < max; index += 1) { + const file = stickers[index]; + // eslint-disable-next-line no-await-in-loop + await deleteFromDisk(file); + } + + console.log(`deleteAllStickers: deleted ${stickers.length} files`); +}; + // createName :: Unit -> IO String exports.createName = () => { const buffer = crypto.randomBytes(32); diff --git a/app/sql.js b/app/sql.js index 29a34535e..2197d32fb 100644 --- a/app/sql.js +++ b/app/sql.js @@ -1,4 +1,4 @@ -const path = require('path'); +const { join } = require('path'); const mkdirp = require('mkdirp'); const rimraf = require('rimraf'); const sql = require('@journeyapps/sqlcipher'); @@ -8,7 +8,15 @@ const { remove: removeUserConfig } = require('./user_config'); const pify = require('pify'); const uuidv4 = require('uuid/v4'); -const { map, isObject, isString, fromPairs, forEach, last } = require('lodash'); +const { + forEach, + fromPairs, + isNumber, + isObject, + isString, + last, + map, +} = require('lodash'); // To get long stack traces // https://github.com/mapbox/node-sqlite3/wiki/API#sqlite3verbose @@ -104,6 +112,17 @@ module.exports = { removeAttachmentDownloadJob, removeAllAttachmentDownloadJobs, + createOrUpdateStickerPack, + updateStickerPackStatus, + createOrUpdateSticker, + updateStickerLastUsed, + addStickerPackReference, + deleteStickerPackReference, + deleteStickerPack, + getAllStickerPacks, + getAllStickers, + getRecentStickers, + removeAll, removeAllConfiguration, @@ -112,6 +131,7 @@ module.exports = { getMessagesWithFileAttachments, removeKnownAttachments, + removeKnownStickers, }; function generateUUID() { @@ -179,6 +199,9 @@ async function setupSQLCipher(instance, { key }) { // https://www.zetetic.net/sqlcipher/sqlcipher-api/#key await instance.run(`PRAGMA key = "x'${key}'";`); + + // Because foreign key support is not enabled by default! + await instance.run('PRAGMA foreign_keys = ON;'); } async function updateToSchemaVersion1(currentVersion, instance) { @@ -635,6 +658,83 @@ async function updateToSchemaVersion11(currentVersion, instance) { console.log('updateToSchemaVersion11: success!'); } +async function updateToSchemaVersion12(currentVersion, instance) { + if (currentVersion >= 12) { + return; + } + + console.log('updateToSchemaVersion12: starting...'); + await instance.run('BEGIN TRANSACTION;'); + + await instance.run(`CREATE TABLE sticker_packs( + id TEXT PRIMARY KEY, + key TEXT NOT NULL, + + author STRING, + coverStickerId INTEGER, + createdAt INTEGER, + downloadAttempts INTEGER, + installedAt INTEGER, + lastUsed INTEGER, + status STRING, + stickerCount INTEGER, + title STRING + );`); + + await instance.run(`CREATE TABLE stickers( + id INTEGER NOT NULL, + packId TEXT NOT NULL, + + emoji STRING, + height INTEGER, + isCoverOnly INTEGER, + lastUsed INTEGER, + path STRING, + width INTEGER, + + PRIMARY KEY (id, packId), + CONSTRAINT stickers_fk + FOREIGN KEY (packId) + REFERENCES sticker_packs(id) + ON DELETE CASCADE + );`); + + await instance.run(`CREATE INDEX stickers_recents + ON stickers ( + lastUsed + ) WHERE lastUsed IS NOT NULL;`); + + await instance.run(`CREATE TABLE sticker_references( + messageId STRING, + packId TEXT, + CONSTRAINT sticker_references_fk + FOREIGN KEY(packId) + REFERENCES sticker_packs(id) + ON DELETE CASCADE + );`); + + await instance.run('PRAGMA schema_version = 12;'); + await instance.run('COMMIT TRANSACTION;'); + console.log('updateToSchemaVersion12: success!'); +} + +async function updateToSchemaVersion13(currentVersion, instance) { + if (currentVersion >= 13) { + return; + } + + console.log('updateToSchemaVersion13: starting...'); + await instance.run('BEGIN TRANSACTION;'); + + await instance.run( + 'ALTER TABLE sticker_packs ADD COLUMN attemptedStatus STRING;' + ); + + await instance.run('PRAGMA schema_version = 13;'); + await instance.run('COMMIT TRANSACTION;'); + console.log('updateToSchemaVersion13: success!'); +} + const SCHEMA_VERSIONS = [ updateToSchemaVersion1, updateToSchemaVersion2, @@ -647,6 +747,8 @@ const SCHEMA_VERSIONS = [ updateToSchemaVersion9, updateToSchemaVersion10, updateToSchemaVersion11, + updateToSchemaVersion12, + updateToSchemaVersion13, ]; async function updateSchema(instance) { @@ -689,12 +791,12 @@ async function initialize({ configDir, key, messages }) { throw new Error('initialize: message is required!'); } - indexedDBPath = path.join(configDir, 'IndexedDB'); + indexedDBPath = join(configDir, 'IndexedDB'); - const dbDir = path.join(configDir, 'sql'); + const dbDir = join(configDir, 'sql'); mkdirp.sync(dbDir); - filePath = path.join(dbDir, 'db.sqlite'); + filePath = join(dbDir, 'db.sqlite'); try { const sqlInstance = await openDatabase(filePath); @@ -773,7 +875,7 @@ async function removeIndexedDBFiles() { ); } - const pattern = path.join(indexedDBPath, '*.leveldb'); + const pattern = join(indexedDBPath, '*.leveldb'); rimraf.sync(pattern); indexedDBPath = null; } @@ -1507,6 +1609,7 @@ async function getOutgoingWithoutExpiresAt() { } async function getNextExpiringMessage() { + // Note: we avoid 'IS NOT NULL' here because it does seem to bypass our index const rows = await db.all(` SELECT json FROM messages WHERE expires_at > 0 @@ -1658,6 +1761,8 @@ async function removeAllUnprocessed() { await db.run('DELETE FROM unprocessed;'); } +// Attachment Downloads + const ATTACHMENT_DOWNLOADS_TABLE = 'attachment_downloads'; async function getNextAttachmentDownloadJobs(limit, options = {}) { const timestamp = options.timestamp || Date.now(); @@ -1724,6 +1829,359 @@ async function removeAllAttachmentDownloadJobs() { return removeAllFromTable(ATTACHMENT_DOWNLOADS_TABLE); } +// Stickers + +async function createOrUpdateStickerPack(pack) { + const { + attemptedStatus, + author, + coverStickerId, + createdAt, + downloadAttempts, + id, + installedAt, + key, + lastUsed, + status, + stickerCount, + title, + } = pack; + if (!id) { + throw new Error( + 'createOrUpdateStickerPack: Provided data did not have a truthy id' + ); + } + + await db.run( + `INSERT OR REPLACE INTO sticker_packs ( + attemptedStatus, + author, + coverStickerId, + createdAt, + downloadAttempts, + id, + installedAt, + key, + lastUsed, + status, + stickerCount, + title + ) values ( + $attemptedStatus, + $author, + $coverStickerId, + $createdAt, + $downloadAttempts, + $id, + $installedAt, + $key, + $lastUsed, + $status, + $stickerCount, + $title + )`, + { + $attemptedStatus: attemptedStatus, + $author: author, + $coverStickerId: coverStickerId, + $createdAt: createdAt || Date.now(), + $downloadAttempts: downloadAttempts || 1, + $id: id, + $installedAt: installedAt, + $key: key, + $lastUsed: lastUsed || null, + $status: status, + $stickerCount: stickerCount, + $title: title, + } + ); +} +async function updateStickerPackStatus(id, status, options) { + // Strange, but an undefined parameter gets coerced into null via ipc + const timestamp = (options || {}).timestamp || Date.now(); + const installedAt = status === 'installed' ? timestamp : null; + + await db.run( + `UPDATE sticker_packs + SET status = $status, installedAt = $installedAt + WHERE id = $id; + )`, + { + $id: id, + $status: status, + $installedAt: installedAt, + } + ); +} +async function createOrUpdateSticker(sticker) { + const { + emoji, + height, + id, + isCoverOnly, + lastUsed, + packId, + path, + width, + } = sticker; + if (!isNumber(id)) { + throw new Error( + 'createOrUpdateSticker: Provided data did not have a numeric id' + ); + } + if (!packId) { + throw new Error( + 'createOrUpdateSticker: Provided data did not have a truthy id' + ); + } + + await db.run( + `INSERT OR REPLACE INTO stickers ( + emoji, + height, + id, + isCoverOnly, + lastUsed, + packId, + path, + width + ) values ( + $emoji, + $height, + $id, + $isCoverOnly, + $lastUsed, + $packId, + $path, + $width + )`, + { + $emoji: emoji, + $height: height, + $id: id, + $isCoverOnly: isCoverOnly, + $lastUsed: lastUsed, + $packId: packId, + $path: path, + $width: width, + } + ); +} +async function updateStickerLastUsed(packId, stickerId, lastUsed) { + await db.run( + `UPDATE stickers + SET lastUsed = $lastUsed + WHERE id = $id AND packId = $packId;`, + { + $id: stickerId, + $packId: packId, + $lastUsed: lastUsed, + } + ); + await db.run( + `UPDATE sticker_packs + SET lastUsed = $lastUsed + WHERE id = $id;`, + { + $id: packId, + $lastUsed: lastUsed, + } + ); +} +async function addStickerPackReference(messageId, packId) { + if (!messageId) { + throw new Error( + 'addStickerPackReference: Provided data did not have a truthy messageId' + ); + } + if (!packId) { + throw new Error( + 'addStickerPackReference: Provided data did not have a truthy packId' + ); + } + + await db.run( + `INSERT OR REPLACE INTO sticker_references ( + messageId, + packId + ) values ( + $messageId, + $packId + )`, + { + $messageId: messageId, + $packId: packId, + } + ); +} +async function deleteStickerPackReference(messageId, packId) { + if (!messageId) { + throw new Error( + 'addStickerPackReference: Provided data did not have a truthy messageId' + ); + } + if (!packId) { + throw new Error( + 'addStickerPackReference: Provided data did not have a truthy packId' + ); + } + + try { + // We use an immediate transaction here to immediately acquire an exclusive lock, + // which would normally only happen when we did our first write. + + // We need this to ensure that our five queries are all atomic, with no other changes + // happening while we do it: + // 1. Delete our target messageId/packId references + // 2. Check the number of references still pointing at packId + // 3. If that number is zero, get pack from sticker_packs database + // 4. If it's not installed, then grab all of its sticker paths + // 5. If it's not installed, then sticker pack (which cascades to all stickers and + // references) + await db.run('BEGIN IMMEDIATE TRANSACTION;'); + + await db.run( + `DELETE FROM sticker_references + WHERE messageId = $messageId AND packId = $packId;`, + { + $messageId: messageId, + $packId: packId, + } + ); + + const countRow = await db.get( + `SELECT count(*) FROM sticker_references + WHERE packId = $packId;`, + { $packId: packId } + ); + if (!countRow) { + throw new Error( + 'deleteStickerPackReference: Unable to get count of references' + ); + } + const count = countRow['count(*)']; + if (count > 0) { + await db.run('COMMIT TRANSACTION'); + return null; + } + + const packRow = await db.get( + `SELECT status FROM sticker_packs + WHERE id = $packId;`, + { $packId: packId } + ); + if (!packRow) { + console.log('deleteStickerPackReference: did not find referenced pack'); + await db.run('COMMIT TRANSACTION'); + return null; + } + const { status } = packRow; + + if (status === 'installed') { + await db.run('COMMIT TRANSACTION'); + return null; + } + + const stickerPathRows = await db.all( + `SELECT path FROM stickers + WHERE packId = $packId;`, + { + $packId: packId, + } + ); + await db.run( + `DELETE FROM sticker_packs + WHERE id = $packId;`, + { $packId: packId } + ); + + await db.run('COMMIT TRANSACTION;'); + + return (stickerPathRows || []).map(row => row.path); + } catch (error) { + await db.run('ROLLBACK;'); + throw error; + } +} +async function deleteStickerPack(packId) { + if (!packId) { + throw new Error( + 'deleteStickerPack: Provided data did not have a truthy packId' + ); + } + + try { + // We use an immediate transaction here to immediately acquire an exclusive lock, + // which would normally only happen when we did our first write. + + // We need this to ensure that our two queries are atomic, with no other changes + // happening while we do it: + // 1. Grab all of target pack's sticker paths + // 2. Delete sticker pack (which cascades to all stickers and references) + await db.run('BEGIN IMMEDIATE TRANSACTION;'); + + const stickerPathRows = await db.all( + `SELECT path FROM stickers + WHERE packId = $packId;`, + { + $packId: packId, + } + ); + await db.run( + `DELETE FROM sticker_packs + WHERE id = $packId;`, + { $packId: packId } + ); + + await db.run('COMMIT TRANSACTION;'); + + return (stickerPathRows || []).map(row => row.path); + } catch (error) { + await db.run('ROLLBACK;'); + throw error; + } +} +async function getStickerCount() { + const row = await db.get('SELECT count(*) from stickers;'); + + if (!row) { + throw new Error('getStickerCount: Unable to get count of stickers'); + } + + return row['count(*)']; +} +async function getAllStickerPacks() { + const rows = await db.all( + `SELECT * FROM sticker_packs + ORDER BY installedAt DESC, createdAt DESC` + ); + + return rows || []; +} +async function getAllStickers() { + const rows = await db.all( + `SELECT * FROM stickers + ORDER BY packId ASC, id ASC` + ); + + return rows || []; +} +async function getRecentStickers({ limit } = {}) { + // Note: we avoid 'IS NOT NULL' here because it does seem to bypass our index + const rows = await db.all( + `SELECT stickers.* FROM stickers + JOIN sticker_packs on stickers.packId = sticker_packs.id + WHERE stickers.lastUsed > 0 AND sticker_packs.status = 'installed' + ORDER BY stickers.lastUsed DESC + LIMIT $limit`, + { + $limit: limit || 24, + } + ); + + return rows || []; +} + // All data in database async function removeAll() { let promise; @@ -1741,6 +2199,9 @@ async function removeAll() { db.run('DELETE FROM unprocessed;'), db.run('DELETE FROM attachment_downloads;'), db.run('DELETE FROM messages_fts;'), + db.run('DELETE FROM stickers;'), + db.run('DELETE FROM sticker_packs;'), + db.run('DELETE FROM sticker_references;'), db.run('COMMIT TRANSACTION;'), ]); }); @@ -1818,7 +2279,7 @@ async function getMessagesWithFileAttachments(conversationId, { limit }) { } function getExternalFilesForMessage(message) { - const { attachments, contact, quote, preview } = message; + const { attachments, contact, quote, preview, sticker } = message; const files = []; forEach(attachments, attachment => { @@ -1866,6 +2327,14 @@ function getExternalFilesForMessage(message) { }); } + if (sticker && sticker.data && sticker.data.path) { + files.push(sticker.data.path); + + if (sticker.data.thumbnail && sticker.data.thumbnail.path) { + files.push(sticker.data.thumbnail.path); + } + } + return files; } @@ -1972,3 +2441,47 @@ async function removeKnownAttachments(allAttachments) { return Object.keys(lookup); } + +async function removeKnownStickers(allStickers) { + const lookup = fromPairs(map(allStickers, file => [file, true])); + const chunkSize = 50; + + const total = await getStickerCount(); + console.log( + `removeKnownStickers: About to iterate through ${total} stickers` + ); + + let count = 0; + let complete = false; + let rowid = 0; + + while (!complete) { + // eslint-disable-next-line no-await-in-loop + const rows = await db.all( + `SELECT rowid, path FROM stickers + WHERE rowid > $rowid + ORDER BY rowid ASC + LIMIT $chunkSize;`, + { + $rowid: rowid, + $chunkSize: chunkSize, + } + ); + + const files = map(rows, row => row.path); + forEach(files, file => { + delete lookup[file]; + }); + + const lastSticker = last(rows); + if (lastSticker) { + ({ rowid } = lastSticker); + } + complete = rows.length < chunkSize; + count += rows.length; + } + + console.log(`removeKnownStickers: Done processing ${count} stickers`); + + return Object.keys(lookup); +} diff --git a/app/sql_channel.js b/app/sql_channel.js index 128ab3a59..d781b9ad6 100644 --- a/app/sql_channel.js +++ b/app/sql_channel.js @@ -1,4 +1,5 @@ const electron = require('electron'); +const Queue = require('p-queue'); const sql = require('./sql'); const { remove: removeUserConfig } = require('./user_config'); const { remove: removeEphemeralConfig } = require('./ephemeral_config'); @@ -14,6 +15,8 @@ let initialized = false; const SQL_CHANNEL_KEY = 'sql-channel'; const ERASE_SQL_KEY = 'erase-sql-key'; +const queue = new Queue({ concurrency: 1 }); + function initialize() { if (initialized) { throw new Error('sqlChannels: already initialized!'); @@ -29,7 +32,10 @@ function initialize() { ); } - const result = await fn(...args); + // Note: we queue here to keep multi-query operations atomic. Without it, any + // multistage data operation (even within a BEGIN/COMMIT) can become interleaved, + // since all requests share one database connection. + const result = await queue.add(() => fn(...args)); event.sender.send(`${SQL_CHANNEL_KEY}-done`, jobId, null, result); } catch (error) { const errorForDisplay = error && error.stack ? error.stack : error; diff --git a/background.html b/background.html index d8ffd6392..ced91eeb6 100644 --- a/background.html +++ b/background.html @@ -119,6 +119,7 @@
+
@@ -524,7 +525,7 @@ - +
diff --git a/fixtures/512x515-thumbs-up-lincoln.webp b/fixtures/512x515-thumbs-up-lincoln.webp new file mode 100644 index 0000000000000000000000000000000000000000..b5df889adbbc093d657c3b0675ebed88b350b139 GIT binary patch literal 38226 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 literal 0 HcmV?d00001 diff --git a/fixtures/kitten-2-64-64.jpg b/fixtures/kitten-2-64-64.jpg new file mode 100644 index 0000000000000000000000000000000000000000..2585e8e58f3657a09356f64fd3f33f65c0caa639 GIT binary patch literal 8817 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

(9%NGQP{RbeBgQEq@xlSj^mWLqm1>Uu~6l z>q>&H&a-EKh&^{LV^wqSQa9tSuUqCOch0wAjMx9c_Hh5!(5c&*cPo55zO?%5-y2;= zZ^+1myQmkbbIbp~^g}!5hi@-N~fk+Fg#xVJZ|=f{f{g@mS{gb+WIa}WP*?= zyVlOq2#NH0u~tG= zbhe5Z^O*zo`dJlK-@Bf^vOKu@{=zSA?H`|gK0IyxLw&g$F;O}b&ut42-e@>2pk}M& z%Ra+j;j3=^XuExEK3DjWOLud3ihcgnqAaf=d-9dD==r$pr~dtNT9^0uFOb#x6p^>2 zb!uY91mzsA|tdGa8f8N-C=J)Mdr-TO@tEW^Z`NZDs@#pYQO7B^4+t9^Q9*J`iu Tw!8gd)8ytn&dm5d|Nl(@Zb}lY literal 0 HcmV?d00001 diff --git a/fixtures/kitten-3-64-64.jpg b/fixtures/kitten-3-64-64.jpg new file mode 100644 index 0000000000000000000000000000000000000000..f9d3ef8389e682054f5c5baafd0fa3be272f0a1a GIT binary patch literal 9212 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 UsWW+GW`CP1e=D<&d*T0^0BU00ga7~l literal 0 HcmV?d00001 diff --git a/images/badge-filled-16.svg b/images/badge-filled-16.svg new file mode 100644 index 000000000..ae9cd05ad --- /dev/null +++ b/images/badge-filled-16.svg @@ -0,0 +1,7 @@ + + + + + diff --git a/images/check-circle-filled-16.svg b/images/check-circle-filled-16.svg new file mode 100644 index 000000000..1683ea0bd --- /dev/null +++ b/images/check-circle-filled-16.svg @@ -0,0 +1,11 @@ + + + + + + + diff --git a/images/chevron-left-12.svg b/images/chevron-left-12.svg new file mode 100644 index 000000000..ae4e9ecf1 --- /dev/null +++ b/images/chevron-left-12.svg @@ -0,0 +1,6 @@ + + + + + diff --git a/images/chevron-right-12.svg b/images/chevron-right-12.svg new file mode 100644 index 000000000..9c39ac443 --- /dev/null +++ b/images/chevron-right-12.svg @@ -0,0 +1,6 @@ + + + + + diff --git a/images/more-h.svg b/images/more-h.svg new file mode 100644 index 000000000..ecd45dec7 --- /dev/null +++ b/images/more-h.svg @@ -0,0 +1,8 @@ + + + + + + + diff --git a/images/plus-20.svg b/images/plus-20.svg new file mode 100644 index 000000000..340d982d6 --- /dev/null +++ b/images/plus-20.svg @@ -0,0 +1,6 @@ + + + + + diff --git a/images/recent-outline.svg b/images/recent-outline.svg new file mode 100644 index 000000000..24c26edab --- /dev/null +++ b/images/recent-outline.svg @@ -0,0 +1,7 @@ + + + + + diff --git a/images/sticker-filled.svg b/images/sticker-filled.svg new file mode 100644 index 000000000..b647a4138 --- /dev/null +++ b/images/sticker-filled.svg @@ -0,0 +1,10 @@ + + + + + diff --git a/js/background.js b/js/background.js index 8b45fe824..cdc937810 100644 --- a/js/background.js +++ b/js/background.js @@ -296,6 +296,24 @@ // Shut down the data interface cleanly await window.Signal.Data.shutdown(); }, + + installStickerPack: async (id, key) => { + const status = window.Signal.Stickers.getStickerPackStatus(id); + + if (status === 'installed') { + return; + } + + if (status === 'advertised') { + await window.reduxActions.stickers.installStickerPack(id, key, { + fromSync: true, + }); + } else { + await window.Signal.Stickers.downloadStickerPack(id, key, { + finalStatus: 'installed', + }); + } + }, }; const currentVersion = window.getVersion(); @@ -303,18 +321,23 @@ newVersion = !lastVersion || currentVersion !== lastVersion; await storage.put('version', currentVersion); - if (newVersion) { - if ( - lastVersion && - window.isBeforeVersion(lastVersion, 'v1.15.0-beta.5') - ) { - await window.Signal.Logs.deleteAll(); - window.restart(); - } - + if (newVersion && lastVersion) { window.log.info( `New version detected: ${currentVersion}; previous: ${lastVersion}` ); + + if (window.isBeforeVersion(lastVersion, 'v1.25.0')) { + // Stickers flags + await Promise.all([ + storage.put('showStickersIntroduction', true), + storage.put('showStickerPickerHint', true), + ]); + } + + if (window.isBeforeVersion(lastVersion, 'v1.15.0-beta.5')) { + await window.Signal.Logs.deleteAll(); + window.restart(); + } } if (isIndexedDBPresent) { @@ -395,6 +418,7 @@ try { await Promise.all([ ConversationController.load(), + Signal.Stickers.load(), textsecure.storage.protocol.hydrateCaches(), ]); } catch (error) { @@ -418,7 +442,11 @@ conversations: { conversationLookup: Signal.Util.makeLookup(conversations, 'id'), }, + items: storage.getItemsState(), + stickers: Signal.Stickers.getInitialState(), user: { + attachmentsPath: window.baseAttachmentsPath, + stickersPath: window.baseStickersPath, regionCode: window.storage.get('regionCode'), ourNumber: textsecure.storage.user.getNumber(), i18n: window.i18n, @@ -437,10 +465,18 @@ Signal.State.Ducks.conversations.actions, store.dispatch ); + actions.items = Signal.State.bindActionCreators( + Signal.State.Ducks.items.actions, + store.dispatch + ); actions.user = Signal.State.bindActionCreators( Signal.State.Ducks.user.actions, store.dispatch ); + actions.stickers = Signal.State.bindActionCreators( + Signal.State.Ducks.stickers.actions, + store.dispatch + ); const { conversationAdded, @@ -759,6 +795,7 @@ messageReceiver.addEventListener('progress', onProgress); messageReceiver.addEventListener('configuration', onConfiguration); messageReceiver.addEventListener('typing', onTyping); + messageReceiver.addEventListener('sticker-pack', onStickerPack); window.Signal.AttachmentDownloads.start({ getMessageReceiver: () => messageReceiver, @@ -770,6 +807,10 @@ PASSWORD ); + if (connectCount === 1) { + window.Signal.Stickers.downloadQueuedPacks(); + } + // On startup after upgrading to a new version, request a contact sync // (but only if we're not the primary device) if ( @@ -831,11 +872,34 @@ Whisper.events.trigger('contactsync'); }); + const ourNumber = textsecure.storage.user.getNumber(); + const { wrap, sendOptions } = ConversationController.prepareForSend( + ourNumber, + { syncMessage: true } + ); + + const installedStickerPacks = window.Signal.Stickers.getInstalledStickerPacks(); + if (installedStickerPacks.length) { + const operations = installedStickerPacks.map(pack => ({ + packId: pack.id, + packKey: pack.key, + installed: true, + })); + + wrap( + window.textsecure.messaging.sendStickerPackSync( + operations, + sendOptions + ) + ).catch(error => { + window.log.error( + 'Failed to send installed sticker packs via sync message', + error && error.stack ? error.stack : error + ); + }); + } + if (Whisper.Import.isComplete()) { - const { wrap, sendOptions } = ConversationController.prepareForSend( - textsecure.storage.user.getNumber(), - { syncMessage: true } - ); wrap( textsecure.messaging.sendRequestConfigurationSyncMessage(sendOptions) ).catch(error => { @@ -942,6 +1006,42 @@ } } + async function onStickerPack(ev) { + const packs = ev.stickerPacks || []; + + packs.forEach(pack => { + const { id, key, isInstall, isRemove } = pack || {}; + + if (!id || !key || (!isInstall && !isRemove)) { + window.log.warn( + 'Received malformed sticker pack operation sync message' + ); + return; + } + + const status = window.Signal.Stickers.getStickerPackStatus(id); + + if (status === 'installed' && isRemove) { + window.reduxActions.stickers.uninstallStickerPack(id, key, { + fromSync: true, + }); + } else if (isInstall) { + if (status === 'advertised') { + window.reduxActions.stickers.installStickerPack(id, key, { + fromSync: true, + }); + } else { + window.Signal.Stickers.downloadStickerPack(id, key, { + finalStatus: 'installed', + fromSync: true, + }); + } + } + }); + + ev.confirm(); + } + async function onContactReceived(ev) { const details = ev.contactDetails; diff --git a/js/message_controller.js b/js/message_controller.js index 18d58cde9..d871bcc83 100644 --- a/js/message_controller.js +++ b/js/message_controller.js @@ -12,6 +12,10 @@ const HOUR = MINUTE * 60; function register(id, message) { + if (!id || !message) { + return message; + } + const existing = messageLookup[id]; if (existing) { messageLookup[id] = { diff --git a/js/models/conversations.js b/js/models/conversations.js index 5a0feec1d..c2a3bc895 100644 --- a/js/models/conversations.js +++ b/js/models/conversations.js @@ -35,12 +35,14 @@ PhoneNumber, } = window.Signal.Types; const { - upgradeMessageSchema, - loadAttachmentData, - getAbsoluteAttachmentPath, - writeNewAttachmentData, deleteAttachmentData, + getAbsoluteAttachmentPath, + loadAttachmentData, + readStickerData, + upgradeMessageSchema, + writeNewAttachmentData, } = window.Signal.Migrations; + const { addStickerPackReference } = window.Signal.Data; const COLORS = [ 'red', @@ -761,7 +763,7 @@ return _.without(this.get('members'), me); }, - async getQuoteAttachment(attachments, preview) { + async getQuoteAttachment(attachments, preview, sticker) { if (attachments && attachments.length) { return Promise.all( attachments @@ -817,6 +819,23 @@ ); } + if (sticker && sticker.data && sticker.data.path) { + const { path, contentType } = sticker.data; + + return [ + { + contentType, + // Our protos library complains about this field being undefined, so we + // force it to null + fileName: null, + thumbnail: { + ...(await loadAttachmentData(sticker.data)), + objectUrl: getAbsoluteAttachmentPath(path), + }, + }, + ]; + } + return []; }, @@ -825,6 +844,7 @@ const contact = quotedMessage.getContact(); const attachments = quotedMessage.get('attachments'); const preview = quotedMessage.get('preview'); + const sticker = quotedMessage.get('sticker'); const body = quotedMessage.get('body'); const embeddedContact = quotedMessage.get('contact'); @@ -837,11 +857,46 @@ author: contact.id, id: quotedMessage.get('sent_at'), text: body || embeddedContactName, - attachments: await this.getQuoteAttachment(attachments, preview), + attachments: await this.getQuoteAttachment( + attachments, + preview, + sticker + ), }; }, - sendMessage(body, attachments, quote, preview) { + async sendStickerMessage(packId, stickerId) { + const packData = window.Signal.Stickers.getStickerPack(packId); + const stickerData = window.Signal.Stickers.getSticker(packId, stickerId); + if (!stickerData || !packData) { + window.log.warn( + `Attempted to send nonexistent (${packId}, ${stickerId}) sticker!` + ); + return; + } + + const { key } = packData; + const { path, width, height } = stickerData; + const arrayBuffer = await readStickerData(path); + + const sticker = { + packId, + stickerId, + packKey: key, + data: { + size: arrayBuffer.byteLength, + data: arrayBuffer, + contentType: 'image/webp', + width, + height, + }, + }; + + this.sendMessage(null, [], null, [], sticker); + window.reduxActions.stickers.useSticker(packId, stickerId); + }, + + sendMessage(body, attachments, quote, preview, sticker) { this.clearTypingTimers(); const destination = this.id; @@ -863,6 +918,7 @@ now ); + // Here we move attachments to disk const messageWithSchema = await upgradeMessageSchema({ type: 'outgoing', body, @@ -874,6 +930,7 @@ received_at: now, expireTimer, recipients, + sticker, }); if (this.isPrivate()) { @@ -885,6 +942,9 @@ }; const model = this.addSingleMessage(attributes); + if (sticker) { + await addStickerPackReference(model.id, sticker.packId); + } const message = MessageController.register(model.id, model); await window.Signal.Data.saveMessage(message.attributes, { forceSave: true, @@ -935,6 +995,7 @@ finalAttachments, quote, preview, + sticker, now, expireTimer, profileKey @@ -955,6 +1016,7 @@ finalAttachments, quote, preview, + sticker, now, expireTimer, profileKey, @@ -968,6 +1030,7 @@ finalAttachments, quote, preview, + sticker, now, expireTimer, profileKey, @@ -1271,6 +1334,7 @@ [], null, [], + null, message.get('sent_at'), expireTimer, profileKey, diff --git a/js/models/messages.js b/js/models/messages.js index 36d6e1c76..c2b675f19 100644 --- a/js/models/messages.js +++ b/js/models/messages.js @@ -27,8 +27,16 @@ loadAttachmentData, loadQuoteData, loadPreviewData, + loadStickerData, upgradeMessageSchema, } = window.Signal.Migrations; + const { + copyStickerToAttachments, + deletePackReference, + downloadStickerPack, + getStickerPackStatus, + } = window.Signal.Stickers; + const { addStickerPackReference } = window.Signal.Data; const { bytesFromString } = window.Signal.Crypto; window.AccountCache = Object.create(null); @@ -389,6 +397,29 @@ // It doesn't need anything right now! return {}; }, + getAttachmentsForMessage() { + const sticker = this.get('sticker'); + if (sticker && sticker.data) { + const { data } = sticker; + + // We don't show anything if we're still loading a sticker + if (data.pending || !data.path) { + return []; + } + + return [ + { + ...data, + url: getAbsoluteAttachmentPath(data.path), + }, + ]; + } + + const attachments = this.get('attachments') || []; + return attachments + .filter(attachment => !attachment.error) + .map(attachment => this.getPropsForAttachment(attachment)); + }, getPropsForMessage() { const phoneNumber = this.getSource(); const contact = this.findAndFormatContact(phoneNumber); @@ -408,12 +439,13 @@ const conversation = this.getConversation(); const isGroup = conversation && !conversation.isPrivate(); - const attachments = this.get('attachments') || []; + const sticker = this.get('sticker'); return { text: this.createNonBreakingLastSeparator(this.get('body')), textPending: this.get('bodyPending'), id: this.id, + isSticker: Boolean(sticker), direction: this.isIncoming() ? 'incoming' : 'outgoing', timestamp: this.get('sent_at'), status: this.getMessagePropStatus(), @@ -423,9 +455,7 @@ authorProfileName: contact.profileName, authorPhoneNumber: contact.phoneNumber, conversationType: isGroup ? 'group' : 'direct', - attachments: attachments - .filter(attachment => !attachment.error) - .map(attachment => this.getPropsForAttachment(attachment)), + attachments: this.getAttachmentsForMessage(), previews: this.getPropsForPreview(), quote: this.getPropsForQuote(), authorAvatarPath, @@ -584,6 +614,7 @@ return previews.map(preview => ({ ...preview, + isStickerPack: window.Signal.LinkPreviews.isStickerPack(preview.url), domain: window.Signal.LinkPreviews.getDomain(preview.url), image: preview.image ? this.getPropsForAttachment(preview.image) : null, })); @@ -708,6 +739,9 @@ if (this.get('attachments').length > 0) { return i18n('mediaMessage'); } + if (this.get('sticker')) { + return i18n('message--getNotificationText--stickers'); + } if (this.isExpirationTimerUpdate()) { const { expireTimer } = this.get('expirationTimerUpdate'); if (!expireTimer) { @@ -775,6 +809,16 @@ MessageController.unregister(this.id); this.unload(); await deleteExternalMessageFiles(this.attributes); + + const sticker = this.get('sticker'); + if (!sticker) { + return; + } + + const { packId } = sticker; + if (packId) { + await deletePackReference(this.id, packId); + } }, unload() { if (this.quotedMessage) { @@ -968,6 +1012,7 @@ const quoteWithData = await loadQuoteData(this.get('quote')); const previewWithData = await loadPreviewData(this.get('preview')); + const stickerWithData = await loadStickerData(this.get('sticker')); // Special-case the self-send case - we send only a sync message if (recipients.length === 1 && recipients[0] === this.OUR_NUMBER) { @@ -978,6 +1023,7 @@ attachments, quoteWithData, previewWithData, + stickerWithData, this.get('sent_at'), this.get('expireTimer'), profileKey @@ -996,6 +1042,7 @@ attachments, quoteWithData, previewWithData, + stickerWithData, this.get('sent_at'), this.get('expireTimer'), profileKey, @@ -1013,6 +1060,7 @@ attachments, quote: quoteWithData, preview: previewWithData, + sticker: stickerWithData, needsSync: !this.get('synced'), expireTimer: this.get('expireTimer'), profileKey, @@ -1058,6 +1106,7 @@ const quoteWithData = await loadQuoteData(this.get('quote')); const previewWithData = await loadPreviewData(this.get('preview')); + const stickerWithData = await loadStickerData(this.get('sticker')); // Special-case the self-send case - we send only a sync message if (number === this.OUR_NUMBER) { @@ -1067,6 +1116,7 @@ attachments, quoteWithData, previewWithData, + stickerWithData, this.get('sent_at'), this.get('expireTimer'), profileKey @@ -1083,6 +1133,7 @@ attachments, quoteWithData, previewWithData, + stickerWithData, this.get('sent_at'), this.get('expireTimer'), profileKey, @@ -1405,8 +1456,62 @@ }; } + let sticker = this.get('sticker'); + if (sticker) { + count += 1; + const { packId, stickerId, packKey } = sticker; + + const status = getStickerPackStatus(packId); + let data; + + if (status && status !== 'pending' && status !== 'error') { + try { + const copiedSticker = await copyStickerToAttachments( + packId, + stickerId + ); + data = { + ...copiedSticker, + contentType: 'image/webp', + }; + } catch (error) { + window.log.error( + `Problem copying sticker (${packId}, ${stickerId}) to attachments:`, + error && error.stack ? error.stack : error + ); + } + } + if (!data) { + data = await window.Signal.AttachmentDownloads.addJob(sticker.data, { + messageId, + type: 'sticker', + index: 0, + }); + } + if (!status) { + // kick off the download without waiting + downloadStickerPack(packId, packKey, { messageId }); + } else { + await addStickerPackReference(messageId, packId); + } + + sticker = { + ...sticker, + packId, + data, + }; + } + if (count > 0) { - this.set({ bodyPending, attachments, preview, contact, quote, group }); + this.set({ + bodyPending, + attachments, + preview, + contact, + quote, + group, + sticker, + }); await window.Signal.Data.saveMessage(this.attributes, { Message: Whisper.Message, @@ -1481,7 +1586,6 @@ } const queryAttachments = queryMessage.get('attachments') || []; - if (queryAttachments.length > 0) { const queryFirst = queryAttachments[0]; const { thumbnail } = queryFirst; @@ -1507,6 +1611,14 @@ } } + const sticker = queryMessage.get('sticker'); + if (sticker && sticker.data && sticker.data.path) { + firstAttachment.thumbnail = { + ...sticker.data, + copied: true, + }; + } + return message; }, @@ -1617,9 +1729,10 @@ hasAttachments: dataMessage.hasAttachments, hasFileAttachments: dataMessage.hasFileAttachments, hasVisualMediaAttachments: dataMessage.hasVisualMediaAttachments, - quote: dataMessage.quote, preview, + quote: dataMessage.quote, schemaVersion: dataMessage.schemaVersion, + sticker: dataMessage.sticker, }); if (type === 'outgoing') { const receipts = Whisper.DeliveryReceipts.forMessage( @@ -1841,7 +1954,7 @@ Whisper.Message.LONG_MESSAGE_CONTENT_TYPE = 'text/x-signal-plain'; Whisper.Message.getLongMessageAttachment = ({ body, attachments, now }) => { - if (body.length <= 2048) { + if (!body || body.length <= 2048) { return { body, attachments, diff --git a/js/modules/attachment_downloads.js b/js/modules/attachment_downloads.js index e4356dc83..6b17e808f 100644 --- a/js/modules/attachment_downloads.js +++ b/js/modules/attachment_downloads.js @@ -1,6 +1,14 @@ -/* global Whisper, Signal, setTimeout, clearTimeout, MessageController */ +/* global + ConversationController, + Whisper, + Signal, + setTimeout, + clearTimeout, + MessageController +*/ const { isFunction, isNumber, omit } = require('lodash'); +const { computeHash } = require('./types/conversation'); const getGuid = require('uuid/v4'); const { getMessageById, @@ -356,17 +364,41 @@ async function _addAttachmentToMessage(message, attachment, { type, index }) { } if (type === 'group-avatar') { - const group = message.get('group'); - if (!group) { - throw new Error("_addAttachmentToMessage: group didn't exist"); + const conversationId = message.get('conversationid'); + const conversation = ConversationController.get(conversationId); + if (!conversation) { + logger.warn("_addAttachmentToMessage: conversation didn't exist"); } - const existingAvatar = group.avatar; + const existingAvatar = conversation.get('avatar'); if (existingAvatar && existingAvatar.path) { await Signal.Migrations.deleteAttachmentData(existingAvatar.path); } - _replaceAttachment(group, 'avatar', attachment, logPrefix); + const data = await Signal.Migrations.loadAttachmentData(attachment.path); + conversation.set({ + avatar: { + ...attachment, + hash: await computeHash(data), + }, + }); + await Signal.Data.updateConversation( + conversationId, + conversation.attributes, + { + Conversation: Whisper.Conversation, + } + ); + return; + } + + if (type === 'sticker') { + const sticker = message.get('sticker'); + if (!sticker) { + throw new Error("_addAttachmentToMessage: sticker didn't exist"); + } + + _replaceAttachment(sticker, 'data', attachment, logPrefix); return; } diff --git a/js/modules/crypto.js b/js/modules/crypto.js index 181d2d6f2..3bfb41678 100644 --- a/js/modules/crypto.js +++ b/js/modules/crypto.js @@ -7,6 +7,7 @@ module.exports = { arrayBufferToBase64, typedArrayToArrayBuffer, base64ToArrayBuffer, + bytesFromHexString, bytesFromString, concatenateBytes, constantTimeEqual, @@ -16,6 +17,7 @@ module.exports = { decryptFile, decryptSymmetric, deriveAccessKey, + deriveStickerPackKey, encryptAesCtr, encryptDeviceName, encryptAttachment, @@ -25,8 +27,10 @@ module.exports = { getAccessKeyVerifier, getFirstBytes, getRandomBytes, + getRandomValue, getViewOfArrayBuffer, getZeroes, + hexFromBytes, highBitsToInt, hmacSha256, intsToByteHighAndLow, @@ -58,6 +62,25 @@ function bytesFromString(string) { function stringFromBytes(buffer) { return dcodeIO.ByteBuffer.wrap(buffer).toString('utf8'); } +function hexFromBytes(buffer) { + return dcodeIO.ByteBuffer.wrap(buffer).toString('hex'); +} +function bytesFromHexString(string) { + return dcodeIO.ByteBuffer.wrap(string, 'hex').toArrayBuffer(); +} + +async function deriveStickerPackKey(packKey) { + const salt = getZeroes(32); + const info = bytesFromString('Sticker Pack'); + + const [part1, part2] = await libsignal.HKDF.deriveSecrets( + packKey, + salt, + info + ); + + return concatenateBytes(part1, part2); +} // High-level Operations @@ -366,6 +389,16 @@ function getRandomBytes(n) { return bytes; } +function getRandomValue(low, high) { + const diff = high - low; + const bytes = new Uint32Array(1); + window.crypto.getRandomValues(bytes); + + // Because high and low are inclusive + const mod = diff + 1; + return bytes[0] % mod + low; +} + function getZeroes(n) { const result = new Uint8Array(n); diff --git a/js/modules/data.d.ts b/js/modules/data.d.ts index 1cec9dcb9..92c613496 100644 --- a/js/modules/data.d.ts +++ b/js/modules/data.d.ts @@ -1,2 +1,20 @@ export function searchMessages(query: string): Promise>; export function searchConversations(query: string): Promise>; + +export function updateStickerLastUsed( + packId: string, + stickerId: number, + time: number +): Promise; +export function updateStickerPackStatus( + packId: string, + status: 'advertised' | 'installed' | 'error' | 'pending', + options?: { timestamp: number } +): Promise; + +export function getRecentStickers(): Promise< + Array<{ + id: number; + packId: string; + }> +>; diff --git a/js/modules/data.js b/js/modules/data.js index 973042e1c..b6db7200f 100644 --- a/js/modules/data.js +++ b/js/modules/data.js @@ -27,6 +27,7 @@ const DATABASE_UPDATE_TIMEOUT = 2 * 60 * 1000; // two minutes const SQL_CHANNEL_KEY = 'sql-channel'; const ERASE_SQL_KEY = 'erase-sql-key'; const ERASE_ATTACHMENTS_KEY = 'erase-attachments'; +const ERASE_STICKERS_KEY = 'erase-stickers'; const CLEANUP_ORPHANED_ATTACHMENTS_KEY = 'cleanup-orphaned-attachments'; const _jobs = Object.create(null); @@ -138,6 +139,17 @@ module.exports = { removeAttachmentDownloadJob, removeAllAttachmentDownloadJobs, + createOrUpdateStickerPack, + updateStickerPackStatus, + createOrUpdateSticker, + updateStickerLastUsed, + addStickerPackReference, + deleteStickerPackReference, + deleteStickerPack, + getAllStickerPacks, + getAllStickers, + getRecentStickers, + removeAll, removeAllConfiguration, @@ -884,6 +896,44 @@ async function removeAllAttachmentDownloadJobs() { await channels.removeAllAttachmentDownloadJobs(); } +// Stickers + +async function createOrUpdateStickerPack(pack) { + await channels.createOrUpdateStickerPack(pack); +} +async function updateStickerPackStatus(packId, status, options) { + await channels.updateStickerPackStatus(packId, status, options); +} +async function createOrUpdateSticker(sticker) { + await channels.createOrUpdateSticker(sticker); +} +async function updateStickerLastUsed(packId, stickerId, timestamp) { + await channels.updateStickerLastUsed(packId, stickerId, timestamp); +} +async function addStickerPackReference(messageId, packId) { + await channels.addStickerPackReference(messageId, packId); +} +async function deleteStickerPackReference(messageId, packId) { + const paths = await channels.deleteStickerPackReference(messageId, packId); + return paths; +} +async function deleteStickerPack(packId) { + const paths = await channels.deleteStickerPack(packId); + return paths; +} +async function getAllStickerPacks() { + const packs = await channels.getAllStickerPacks(); + return packs; +} +async function getAllStickers() { + const stickers = await channels.getAllStickers(); + return stickers; +} +async function getRecentStickers() { + const recentStickers = await channels.getRecentStickers(); + return recentStickers; +} + // Other async function removeAll() { @@ -903,6 +953,7 @@ async function removeOtherData() { await Promise.all([ callChannel(ERASE_SQL_KEY), callChannel(ERASE_ATTACHMENTS_KEY), + callChannel(ERASE_STICKERS_KEY), ]); } diff --git a/js/modules/link_previews.js b/js/modules/link_previews.js index 3ab33cfed..07246a6ad 100644 --- a/js/modules/link_previews.js +++ b/js/modules/link_previews.js @@ -18,6 +18,7 @@ module.exports = { isLinkInWhitelist, isMediaLinkInWhitelist, isLinkSneaky, + isStickerPack, }; const SUPPORTED_DOMAINS = [ @@ -37,7 +38,9 @@ const SUPPORTED_DOMAINS = [ 'pinterest.com', 'www.pinterest.com', 'pin.it', + 'signal.org', ]; + function isLinkInWhitelist(link) { try { const url = new URL(link); @@ -61,6 +64,10 @@ function isLinkInWhitelist(link) { } } +function isStickerPack(link) { + return (link || '').startsWith('https://signal.org/addstickers/'); +} + const SUPPORTED_MEDIA_DOMAINS = /^([^.]+\.)*(ytimg.com|cdninstagram.com|redd.it|imgur.com|fbcdn.net|pinimg.com)$/i; function isMediaLinkInWhitelist(link) { try { @@ -138,28 +145,33 @@ function getDomain(url) { const MB = 1024 * 1024; const KB = 1024; -function getChunkPattern(size) { +function getChunkPattern(size, initialOffset) { if (size > MB) { - return _getRequestPattern(size, MB); + return _getRequestPattern(size, MB, initialOffset); } else if (size > 500 * KB) { - return _getRequestPattern(size, 500 * KB); + return _getRequestPattern(size, 500 * KB, initialOffset); } else if (size > 100 * KB) { - return _getRequestPattern(size, 100 * KB); + return _getRequestPattern(size, 100 * KB, initialOffset); } else if (size > 50 * KB) { - return _getRequestPattern(size, 50 * KB); + return _getRequestPattern(size, 50 * KB, initialOffset); } else if (size > 10 * KB) { - return _getRequestPattern(size, 10 * KB); + return _getRequestPattern(size, 10 * KB, initialOffset); } else if (size > KB) { - return _getRequestPattern(size, KB); + return _getRequestPattern(size, KB, initialOffset); } - throw new Error(`getChunkPattern: Unsupported size: ${size}`); + return { + start: { + start: initialOffset, + end: size - 1, + }, + }; } -function _getRequestPattern(size, increment) { +function _getRequestPattern(size, increment, initialOffset) { const results = []; - let offset = 0; + let offset = initialOffset || 0; while (size - offset > increment) { results.push({ start: offset, diff --git a/js/modules/signal.js b/js/modules/signal.js index 7d81b01a9..0d816c4da 100644 --- a/js/modules/signal.js +++ b/js/modules/signal.js @@ -9,6 +9,7 @@ const Emoji = require('../../ts/util/emoji'); const IndexedDB = require('./indexeddb'); const Notifications = require('../../ts/notifications'); const OS = require('../../ts/OS'); +const Stickers = require('./stickers'); const Settings = require('./settings'); const Util = require('../../ts/util'); const { migrateToSQL } = require('./migrate_to_sql'); @@ -69,8 +70,20 @@ const { // State const { createLeftPane } = require('../../ts/state/roots/createLeftPane'); +const { + createStickerButton, +} = require('../../ts/state/roots/createStickerButton'); +const { + createStickerManager, +} = require('../../ts/state/roots/createStickerManager'); +const { + createStickerPreviewModal, +} = require('../../ts/state/roots/createStickerPreviewModal'); + const { createStore } = require('../../ts/state/createStore'); const conversationsDuck = require('../../ts/state/ducks/conversations'); +const itemsDuck = require('../../ts/state/ducks/items'); +const stickersDuck = require('../../ts/state/ducks/stickers'); const userDuck = require('../../ts/state/ducks/user'); // Migrations @@ -112,6 +125,7 @@ function initializeMigrations({ } const { getPath, + getStickersPath, createReader, createAbsolutePathGetter, createWriterForNew, @@ -130,25 +144,40 @@ function initializeMigrations({ const loadAttachmentData = Type.loadData(readAttachmentData); const loadPreviewData = MessageType.loadPreviewData(loadAttachmentData); const loadQuoteData = MessageType.loadQuoteData(loadAttachmentData); + const loadStickerData = MessageType.loadStickerData(loadAttachmentData); const getAbsoluteAttachmentPath = createAbsolutePathGetter(attachmentsPath); const deleteOnDisk = Attachments.createDeleter(attachmentsPath); const writeNewAttachmentData = createWriterForNew(attachmentsPath); + const copyIntoAttachmentsDirectory = Attachments.copyIntoAttachmentsDirectory( + attachmentsPath + ); + + const stickersPath = getStickersPath(userDataPath); + const writeNewStickerData = createWriterForNew(stickersPath); + const getAbsoluteStickerPath = createAbsolutePathGetter(stickersPath); + const deleteSticker = Attachments.createDeleter(stickersPath); + const readStickerData = createReader(stickersPath); return { attachmentsPath, + copyIntoAttachmentsDirectory, deleteAttachmentData: deleteOnDisk, deleteExternalMessageFiles: MessageType.deleteAllExternalFiles({ deleteAttachmentData: Type.deleteData(deleteOnDisk), deleteOnDisk, }), + deleteSticker, getAbsoluteAttachmentPath, + getAbsoluteStickerPath, getPlaceholderMigrations, getCurrentVersion, loadAttachmentData, loadMessage: MessageType.createAttachmentLoader(loadAttachmentData), loadPreviewData, loadQuoteData, + loadStickerData, readAttachmentData, + readStickerData, run, processNewAttachment: attachment => MessageType.processNewAttachment(attachment, { @@ -161,6 +190,13 @@ function initializeMigrations({ makeVideoScreenshot, logger, }), + processNewSticker: stickerData => + MessageType.processNewSticker(stickerData, { + writeNewStickerData, + getAbsoluteStickerPath, + getImageDimensions, + logger, + }), upgradeMessageSchema: (message, options = {}) => { const { maxVersion } = options; @@ -227,10 +263,15 @@ exports.setup = (options = {}) => { const Roots = { createLeftPane, + createStickerButton, + createStickerManager, + createStickerPreviewModal, }; const Ducks = { conversations: conversationsDuck, + items: itemsDuck, user: userDuck, + stickers: stickersDuck, }; const State = { bindActionCreators, @@ -278,6 +319,7 @@ exports.setup = (options = {}) => { RefreshSenderCertificate, Settings, State, + Stickers, Types, Util, Views, diff --git a/js/modules/stickers.d.ts b/js/modules/stickers.d.ts new file mode 100644 index 000000000..ced60aa43 --- /dev/null +++ b/js/modules/stickers.d.ts @@ -0,0 +1 @@ +export function maybeDeletePack(packId: string): Promise; diff --git a/js/modules/stickers.js b/js/modules/stickers.js new file mode 100644 index 000000000..9c8928013 --- /dev/null +++ b/js/modules/stickers.js @@ -0,0 +1,495 @@ +/* global + textsecure, + Signal, + log, + reduxStore, + reduxActions, + URL +*/ + +const BLESSED_PACKS = {}; + +const { isNumber, pick, reject, groupBy } = require('lodash'); +const pMap = require('p-map'); +const Queue = require('p-queue'); +const qs = require('qs'); + +const { makeLookup } = require('../../ts/util/makeLookup'); +const { base64ToArrayBuffer, deriveStickerPackKey } = require('./crypto'); +const { + addStickerPackReference, + createOrUpdateSticker, + createOrUpdateStickerPack, + deleteStickerPack, + deleteStickerPackReference, + getAllStickerPacks, + getAllStickers, + getRecentStickers, + updateStickerPackStatus, +} = require('./data'); + +module.exports = { + BLESSED_PACKS, + copyStickerToAttachments, + deletePack, + deletePackReference, + downloadStickerPack, + getDataFromLink, + getInitialState, + getInstalledStickerPacks, + getSticker, + getStickerPack, + getStickerPackStatus, + load, + maybeDeletePack, + downloadQueuedPacks, + redactPackId, +}; + +let initialState = null; +let packsToDownload = null; +const downloadQueue = new Queue({ concurrency: 1 }); + +async function load() { + const [packs, recentStickers] = await Promise.all([ + getPacksForRedux(), + getRecentStickersForRedux(), + ]); + + initialState = { + packs, + recentStickers, + blessedPacks: BLESSED_PACKS, + }; + + packsToDownload = capturePacksToDownload(packs); +} + +function getDataFromLink(link) { + const { hash } = new URL(link); + if (!hash) { + return null; + } + + const data = hash.slice(1); + const params = qs.parse(data); + + return { + id: params.pack_id, + key: params.pack_key, + }; +} + +function getInstalledStickerPacks() { + const state = reduxStore.getState(); + const { stickers } = state; + const { packs } = stickers; + if (!packs) { + return []; + } + + const values = Object.values(packs); + return values.filter(pack => pack.status === 'installed'); +} + +function downloadQueuedPacks() { + const ids = Object.keys(packsToDownload); + ids.forEach(id => { + const { key, status } = packsToDownload[id]; + + // The queuing is done inside this function, no need to await here + downloadStickerPack(id, key, { finalStatus: status }); + }); + + packsToDownload = {}; +} + +function capturePacksToDownload(existingPackLookup) { + const toDownload = Object.create(null); + + // First, ensure that blessed packs are in good shape + const blessedIds = Object.keys(BLESSED_PACKS); + blessedIds.forEach(id => { + const existing = existingPackLookup[id]; + if ( + !existing || + (existing.status !== 'advertised' && existing.status !== 'installed') + ) { + toDownload[id] = { + id, + ...BLESSED_PACKS[id], + }; + } + }); + + // Then, find error cases in packs we already know about + const existingIds = Object.keys(existingPackLookup); + existingIds.forEach(id => { + if (toDownload[id]) { + return; + } + + const existing = existingPackLookup[id]; + if (doesPackNeedDownload(existing)) { + toDownload[id] = { + id, + key: existing.key, + status: existing.attemptedStatus, + }; + } + }); + + return toDownload; +} + +function doesPackNeedDownload(pack) { + if (!pack) { + return true; + } + + const stickerCount = Object.keys(pack.stickers || {}).length; + return ( + !pack.status || + pack.status === 'error' || + pack.status === 'pending' || + !pack.stickerCount || + stickerCount < pack.stickerCount + ); +} + +async function getPacksForRedux() { + const [packs, stickers] = await Promise.all([ + getAllStickerPacks(), + getAllStickers(), + ]); + + const stickersByPack = groupBy(stickers, sticker => sticker.packId); + const fullSet = packs.map(pack => ({ + ...pack, + stickers: makeLookup(stickersByPack[pack.id] || [], 'id'), + })); + + return makeLookup(fullSet, 'id'); +} + +async function getRecentStickersForRedux() { + const recent = await getRecentStickers(); + return recent.map(sticker => ({ + packId: sticker.packId, + stickerId: sticker.id, + })); +} + +function getInitialState() { + return initialState; +} + +function redactPackId(packId) { + return `[REDACTED]${packId.slice(-3)}`; +} + +function getReduxStickerActions() { + const actions = reduxActions; + + if (actions && actions.stickers) { + return actions.stickers; + } + + return {}; +} + +async function decryptSticker(packKey, ciphertext) { + const binaryKey = base64ToArrayBuffer(packKey); + const derivedKey = await deriveStickerPackKey(binaryKey); + const plaintext = await textsecure.crypto.decryptAttachment( + ciphertext, + derivedKey + ); + + return plaintext; +} + +async function downloadSticker(packId, packKey, proto) { + const ciphertext = await textsecure.messaging.getSticker(packId, proto.id); + const plaintext = await decryptSticker(packKey, ciphertext); + const sticker = await Signal.Migrations.processNewSticker(plaintext); + + return { + ...pick(proto, ['id', 'emoji']), + ...sticker, + packId, + }; +} + +async function downloadStickerPack(packId, packKey, options = {}) { + // This will ensure that only one download process is in progress at any given time + return downloadQueue.add(async () => { + try { + await doDownloadStickerPack(packId, packKey, options); + } catch (error) { + log.error( + 'doDownloadStickerPack threw an error:', + error && error.stack ? error.stack : error + ); + } + }); +} + +async function doDownloadStickerPack(packId, packKey, options = {}) { + const { messageId, fromSync } = options; + const { + stickerAdded, + stickerPackAdded, + stickerPackUpdated, + installStickerPack, + } = getReduxStickerActions(); + + const finalStatus = options.finalStatus || 'advertised'; + + const existing = getStickerPack(packId); + if (!doesPackNeedDownload(existing)) { + log.warn( + `Download for pack ${redactPackId( + packId + )} requested, but it does not need re-download. Skipping.` + ); + return; + } + + const downloadAttempts = (existing ? existing.downloadAttempts || 0 : 0) + 1; + if (downloadAttempts > 3) { + log.warn( + `Refusing to attempt another download for pack ${redactPackId( + packId + )}, attempt number ${downloadAttempts}` + ); + + if (existing.status !== 'error') { + await updateStickerPackStatus(packId, 'error'); + stickerPackUpdated(packId, { + status: 'error', + }); + } + + return; + } + + let coverProto; + let coverStickerId; + let coverIncludedInList; + let nonCoverStickers; + + try { + const ciphertext = await textsecure.messaging.getStickerPackManifest( + packId + ); + const plaintext = await decryptSticker(packKey, ciphertext); + const proto = textsecure.protobuf.StickerPack.decode(plaintext); + const firstStickerProto = proto.stickers ? proto.stickers[0] : null; + const stickerCount = proto.stickers.length; + + coverProto = proto.cover || firstStickerProto; + coverStickerId = coverProto ? coverProto.id : null; + + if (!coverProto || !isNumber(coverStickerId)) { + throw new Error( + `Sticker pack ${redactPackId( + packId + )} is malformed - it has no cover, and no stickers` + ); + } + + nonCoverStickers = reject( + proto.stickers, + sticker => !isNumber(sticker.id) || sticker.id === coverStickerId + ); + + coverIncludedInList = nonCoverStickers.length < stickerCount; + + // status can be: + // - 'pending' + // - 'advertised' + // - 'error' + // - 'installed' + const pack = { + id: packId, + key: packKey, + attemptedStatus: finalStatus, + coverStickerId, + downloadAttempts, + stickerCount, + status: 'pending', + ...pick(proto, ['title', 'author']), + }; + await createOrUpdateStickerPack(pack); + stickerPackAdded(pack); + + if (messageId) { + await addStickerPackReference(messageId, packId); + } + } catch (error) { + log.error( + `Error downloading manifest for sticker pack ${redactPackId(packId)}:`, + error && error.stack ? error.stack : error + ); + + const pack = { + id: packId, + key: packKey, + attemptedStatus: finalStatus, + downloadAttempts, + status: 'error', + }; + await createOrUpdateStickerPack(pack); + stickerPackAdded(pack); + + return; + } + + // We have a separate try/catch here because we're starting to download stickers here + // and we want to preserve more of the pack on an error. + try { + const downloadStickerJob = async stickerProto => { + const stickerInfo = await downloadSticker(packId, packKey, stickerProto); + const sticker = { + ...stickerInfo, + isCoverOnly: !coverIncludedInList && stickerInfo.id === coverStickerId, + }; + await createOrUpdateSticker(sticker); + stickerAdded(sticker); + }; + + // Download the cover first + await downloadStickerJob(coverProto); + + // Then the rest + await pMap(nonCoverStickers, downloadStickerJob, { concurrency: 3 }); + + if (finalStatus === 'installed') { + await installStickerPack(packId, packKey, { fromSync }); + } else { + // Mark the pack as complete + await updateStickerPackStatus(packId, finalStatus); + stickerPackUpdated(packId, { + status: finalStatus, + }); + } + } catch (error) { + log.error( + `Error downloading stickers for sticker pack ${redactPackId(packId)}:`, + error && error.stack ? error.stack : error + ); + + const errorState = 'error'; + await updateStickerPackStatus(packId, errorState); + if (stickerPackUpdated) { + stickerPackUpdated(packId, { + state: errorState, + }); + } + } +} + +function getStickerPack(packId) { + const state = reduxStore.getState(); + const { stickers } = state; + const { packs } = stickers; + if (!packs) { + return null; + } + + return packs[packId]; +} + +function getStickerPackStatus(packId) { + const pack = getStickerPack(packId); + if (!pack) { + return null; + } + + return pack.status; +} + +function getSticker(packId, stickerId) { + const state = reduxStore.getState(); + const { stickers } = state; + const { packs } = stickers; + const pack = packs[packId]; + + if (!pack || !pack.stickers) { + return null; + } + + return pack.stickers[stickerId]; +} + +async function copyStickerToAttachments(packId, stickerId) { + const sticker = getSticker(packId, stickerId); + if (!sticker) { + return null; + } + + const { path } = sticker; + const absolutePath = Signal.Migrations.getAbsoluteStickerPath(path); + const newPath = await Signal.Migrations.copyIntoAttachmentsDirectory( + absolutePath + ); + + return { + ...sticker, + path: newPath, + }; +} + +// In the case where a sticker pack is uninstalled, we want to delete it if there are no +// more references left. We'll delete a nonexistent reference, then check if there are +// any references left, just like usual. +async function maybeDeletePack(packId) { + // This hardcoded string is fine because message ids are GUIDs + await deletePackReference('NOT-USED', packId); +} + +// We don't generally delete packs outright; we just remove references to them, and if +// the last reference is deleted, we finally then remove the pack itself from database +// and from disk. +async function deletePackReference(messageId, packId) { + const isBlessed = Boolean(BLESSED_PACKS[packId]); + if (isBlessed) { + return; + } + + // This call uses locking to prevent race conditions with other reference removals, + // or an incoming message creating a new message->pack reference + const paths = await deleteStickerPackReference(messageId, packId); + + // If we don't get a list of paths back, then the sticker pack was not deleted + if (!paths) { + return; + } + + const { removeStickerPack } = getReduxStickerActions(); + removeStickerPack(packId); + + await pMap(paths, Signal.Migrations.deleteSticker, { + concurrency: 3, + }); +} + +// The override; doesn't honor our ref-counting scheme - just deletes it all. +async function deletePack(packId) { + const isBlessed = Boolean(BLESSED_PACKS[packId]); + if (isBlessed) { + return; + } + + // This call uses locking to prevent race conditions with other reference removals, + // or an incoming message creating a new message->pack reference + const paths = await deleteStickerPack(packId); + + const { removeStickerPack } = getReduxStickerActions(); + removeStickerPack(packId); + + await pMap(paths, Signal.Migrations.deleteSticker, { + concurrency: 3, + }); +} diff --git a/js/modules/types/attachment.js b/js/modules/types/attachment.js index 48af3a17b..09b410416 100644 --- a/js/modules/types/attachment.js +++ b/js/modules/types/attachment.js @@ -179,7 +179,7 @@ exports.loadData = readAttachmentData => { } const data = await readAttachmentData(attachment.path); - return Object.assign({}, attachment, { data }); + return Object.assign({}, attachment, { data, size: data.byteLength }); }; }; diff --git a/js/modules/types/conversation.js b/js/modules/types/conversation.js index 29ec499f4..8607d99dc 100644 --- a/js/modules/types/conversation.js +++ b/js/modules/types/conversation.js @@ -140,11 +140,12 @@ async function deleteExternalFiles(conversation, options = {}) { } module.exports = { - deleteExternalFiles, - migrateConversation, - maybeUpdateAvatar, - maybeUpdateProfileAvatar, - createLastMessageUpdate, arrayBufferToBase64, base64ToArrayBuffer, + computeHash, + createLastMessageUpdate, + deleteExternalFiles, + maybeUpdateAvatar, + maybeUpdateProfileAvatar, + migrateConversation, }; diff --git a/js/modules/types/message.js b/js/modules/types/message.js index 382f899db..202080d58 100644 --- a/js/modules/types/message.js +++ b/js/modules/types/message.js @@ -308,7 +308,33 @@ const toVersion9 = exports._withSchemaVersion({ }); const toVersion10 = exports._withSchemaVersion({ schemaVersion: 10, - upgrade: exports._mapPreviewAttachments(Attachment.migrateDataToFileSystem), + upgrade: async (message, context) => { + const processPreviews = exports._mapPreviewAttachments( + Attachment.migrateDataToFileSystem + ); + const processSticker = async (stickerMessage, stickerContext) => { + const { sticker } = stickerMessage; + if (!sticker || !sticker.data || !sticker.data.data) { + return stickerMessage; + } + + return { + ...stickerMessage, + sticker: { + ...sticker, + data: await Attachment.migrateDataToFileSystem( + sticker.data, + stickerContext + ), + }, + }; + }; + + const previewProcessed = await processPreviews(message, context); + const stickerProcessed = await processSticker(previewProcessed, context); + + return stickerProcessed; + }, }); const VERSIONS = [ @@ -462,6 +488,44 @@ exports.processNewAttachment = async ( return finalAttachment; }; +exports.processNewSticker = async ( + stickerData, + { + writeNewStickerData, + getAbsoluteStickerPath, + getImageDimensions, + logger, + } = {} +) => { + if (!isFunction(writeNewStickerData)) { + throw new TypeError('context.writeNewStickerData is required'); + } + if (!isFunction(getAbsoluteStickerPath)) { + throw new TypeError('context.getAbsoluteStickerPath is required'); + } + if (!isFunction(getImageDimensions)) { + throw new TypeError('context.getImageDimensions is required'); + } + if (!isObject(logger)) { + throw new TypeError('context.logger is required'); + } + + const path = await writeNewStickerData(stickerData); + const absolutePath = await getAbsoluteStickerPath(path); + + const { width, height } = await getImageDimensions({ + objectUrl: absolutePath, + logger, + }); + + return { + contentType: 'image/webp', + path, + width, + height, + }; +}; + exports.createAttachmentLoader = loadAttachmentData => { if (!isFunction(loadAttachmentData)) { throw new TypeError( @@ -532,6 +596,23 @@ exports.loadPreviewData = loadAttachmentData => { }; }; +exports.loadStickerData = loadAttachmentData => { + if (!isFunction(loadAttachmentData)) { + throw new TypeError('loadStickerData: loadAttachmentData is required'); + } + + return async sticker => { + if (!sticker || !sticker.data) { + return null; + } + + return { + ...sticker, + data: await loadAttachmentData(sticker.data), + }; + }; +}; + exports.deleteAllExternalFiles = ({ deleteAttachmentData, deleteOnDisk }) => { if (!isFunction(deleteAttachmentData)) { throw new TypeError( @@ -546,7 +627,7 @@ exports.deleteAllExternalFiles = ({ deleteAttachmentData, deleteOnDisk }) => { } return async message => { - const { attachments, quote, contact, preview } = message; + const { attachments, quote, contact, preview, sticker } = message; if (attachments && attachments.length) { await Promise.all(attachments.map(deleteAttachmentData)); @@ -590,6 +671,14 @@ exports.deleteAllExternalFiles = ({ deleteAttachmentData, deleteOnDisk }) => { }) ); } + + if (sticker && sticker.data && sticker.data.path) { + await deleteOnDisk(sticker.data.path); + + if (sticker.data.thumbnail && sticker.data.thumbnail.path) { + await deleteOnDisk(sticker.data.thumbnail.path); + } + } }; }; diff --git a/js/modules/web_api.js b/js/modules/web_api.js index c864ce060..56fdd95a3 100644 --- a/js/modules/web_api.js +++ b/js/modules/web_api.js @@ -4,8 +4,9 @@ const ProxyAgent = require('proxy-agent'); const { Agent } = require('https'); const is = require('@sindresorhus/is'); +const { redactPackId } = require('./stickers'); -/* global Buffer, setTimeout, log, _, getGuid */ +/* global Signal, Buffer, setTimeout, log, _, getGuid */ /* eslint-disable more/no-then, no-bitwise, no-nested-ternary */ @@ -175,16 +176,12 @@ function getContentType(response) { function _promiseAjax(providedUrl, options) { return new Promise((resolve, reject) => { const url = providedUrl || `${options.host}/${options.path}`; - if (options.disableLogs) { - log.info( - `${options.type} [REDACTED_URL]${ - options.unauthenticated ? ' (unauth)' : '' - }` - ); + + const unauthLabel = options.unauthenticated ? ' (unauth)' : ''; + if (options.redactUrl) { + log.info(`${options.type} ${options.redactUrl(url)}${unauthLabel}`); } else { - log.info( - `${options.type} ${url}${options.unauthenticated ? ' (unauth)' : ''}` - ); + log.info(`${options.type} ${url}${unauthLabel}`); } const timeout = @@ -282,10 +279,10 @@ function _promiseAjax(providedUrl, options) { if (options.responseType === 'json') { if (options.validateResponse) { if (!_validateResponse(result, options.validateResponse)) { - if (options.disableLogs) { + if (options.redactUrl) { log.info( options.type, - '[REDACTED_URL]', + options.redactUrl(url), response.status, 'Error' ); @@ -304,10 +301,10 @@ function _promiseAjax(providedUrl, options) { } } if (response.status >= 0 && response.status < 400) { - if (options.disableLogs) { + if (options.redactUrl) { log.info( options.type, - '[REDACTED_URL]', + options.redactUrl(url), response.status, 'Success' ); @@ -324,8 +321,13 @@ function _promiseAjax(providedUrl, options) { return resolve(result, response.status); } - if (options.disableLogs) { - log.info(options.type, '[REDACTED_URL]', response.status, 'Error'); + if (options.redactUrl) { + log.info( + options.type, + options.redactUrl(url), + response.status, + 'Error' + ); } else { log.error(options.type, url, response.status, 'Error'); } @@ -340,8 +342,8 @@ function _promiseAjax(providedUrl, options) { }); }) .catch(e => { - if (options.disableLogs) { - log.error(options.type, '[REDACTED_URL]', 0, 'Error'); + if (options.redactUrl) { + log.error(options.type, options.redactUrl(url), 0, 'Error'); } else { log.error(options.type, url, 0, 'Error'); } @@ -435,6 +437,7 @@ function initialize({ function connect({ username: initialUsername, password: initialPassword }) { let username = initialUsername; let password = initialPassword; + const PARSE_RANGE_HEADER = /\/(\d+)$/; // Thanks, function hoisting! return { @@ -449,8 +452,9 @@ function initialize({ getProfile, getProfileUnauth, getProvisioningSocket, - getProxiedSize, getSenderCertificate, + getSticker, + getStickerPackManifest, makeProxiedRequest, putAttachment, registerKeys, @@ -834,6 +838,33 @@ function initialize({ }); } + function redactStickerUrl(stickerUrl) { + return stickerUrl.replace( + /(\/stickers\/)([^/]+)(\/)/, + (match, begin, packId, end) => `${begin}${redactPackId(packId)}${end}` + ); + } + + function getSticker(packId, stickerId) { + return _outerAjax(`${cdnUrl}/stickers/${packId}/full/${stickerId}`, { + certificateAuthority, + proxyUrl, + responseType: 'arraybuffer', + type: 'GET', + redactUrl: redactStickerUrl, + }); + } + + function getStickerPackManifest(packId) { + return _outerAjax(`${cdnUrl}/stickers/${packId}/manifest.proto`, { + certificateAuthority, + proxyUrl, + responseType: 'arraybuffer', + type: 'GET', + redactUrl: redactStickerUrl, + }); + } + async function getAttachment(id) { // This is going to the CDN, not the service, so we use _outerAjax return _outerAjax(`${cdnUrl}/attachments/${id}`, { @@ -918,45 +949,64 @@ function initialize({ return attachmentIdString; } - // eslint-disable-next-line no-shadow - async function getProxiedSize(url) { - const result = await _outerAjax(url, { - processData: false, - responseType: 'arraybufferwithdetails', - proxyUrl: contentProxyUrl, - type: 'HEAD', - disableLogs: true, - }); + function getHeaderPadding() { + const length = Signal.Crypto.getRandomValue(1, 64); + let characters = ''; - const { response } = result; - if (!response.headers || !response.headers.get) { - throw new Error('getProxiedSize: Problem retrieving header value'); + for (let i = 0, max = length; i < max; i += 1) { + characters += String.fromCharCode( + Signal.Crypto.getRandomValue(65, 122) + ); } - const size = response.headers.get('content-length'); - return parseInt(size, 10); + return characters; } // eslint-disable-next-line no-shadow - function makeProxiedRequest(url, options = {}) { + async function makeProxiedRequest(url, options = {}) { const { returnArrayBuffer, start, end } = options; - let headers; + const headers = { + 'X-SignalPadding': getHeaderPadding(), + }; if (_.isNumber(start) && _.isNumber(end)) { - headers = { - Range: `bytes=${start}-${end}`, - }; + headers.Range = `bytes=${start}-${end}`; } - return _outerAjax(url, { + const result = await _outerAjax(url, { processData: false, responseType: returnArrayBuffer ? 'arraybufferwithdetails' : null, proxyUrl: contentProxyUrl, type: 'GET', redirect: 'follow', - disableLogs: true, + redactUrl: () => '[REDACTED_URL]', headers, }); + + if (!returnArrayBuffer) { + return result; + } + + const { response } = result; + if (!response.headers || !response.headers.get) { + throw new Error('makeProxiedRequest: Problem retrieving header value'); + } + + const range = response.headers.get('content-range'); + const match = PARSE_RANGE_HEADER.exec(range); + + if (!match || !match[1]) { + throw new Error( + `makeProxiedRequest: Unable to parse total size from ${range}` + ); + } + + const totalSize = parseInt(match[1], 10); + + return { + totalSize, + result, + }; } function getMessageSocket() { diff --git a/js/storage.js b/js/storage.js index 017eee1b3..bea60767d 100644 --- a/js/storage.js +++ b/js/storage.js @@ -1,3 +1,4 @@ +/* global _ */ /* eslint-disable more/no-then */ // eslint-disable-next-line func-names @@ -24,6 +25,10 @@ items[key] = data; await window.Signal.Data.createOrUpdateItem(data); + + if (_.has(window, ['reduxActions', 'items', 'putItemExternal'])) { + window.reduxActions.items.putItemExternal(key, value); + } } function get(key, defaultValue) { @@ -46,6 +51,10 @@ delete items[key]; await window.Signal.Data.removeItemById(key); + + if (_.has(window, ['reduxActions', 'items', 'removeItemExternal'])) { + window.reduxActions.items.removeItemExternal(key); + } } function onready(callback) { @@ -77,6 +86,10 @@ callListeners(); } + function getItemsState() { + return _.clone(items); + } + function reset() { ready = false; items = Object.create(null); @@ -86,6 +99,7 @@ fetch, put, get, + getItemsState, remove, onready, reset, diff --git a/js/views/conversation_view.js b/js/views/conversation_view.js index 78025a9d5..42d1d121c 100644 --- a/js/views/conversation_view.js +++ b/js/views/conversation_view.js @@ -104,6 +104,9 @@ ); this.listenTo(this.model.messageCollection, 'force-send', this.forceSend); this.listenTo(this.model.messageCollection, 'delete', this.deleteMessage); + this.listenTo(this.model.messageCollection, 'height-changed', () => + this.view.scrollToBottomIfNeeded() + ); this.listenTo( this.model.messageCollection, 'scroll-to-message', @@ -276,15 +279,18 @@ this.$('.send-message').blur(this.unfocusBottomBar.bind(this)); this.$emojiPanelContainer = this.$('.emoji-panel-container'); + + this.setupStickerPickerButton(); }, events: { keydown: 'onKeyDown', - 'submit .send': 'checkUnverifiedSendMessage', + 'submit .send': 'clickSend', 'input .send-message': 'updateMessageFieldSize', 'keydown .send-message': 'updateMessageFieldSize', 'keyup .send-message': 'onKeyUp', click: 'onClick', + 'click .sticker-button-placeholder': 'onClickStickerButtonPlaceholder', 'click .bottom-bar': 'focusMessageField', 'click .capture-audio .microphone': 'captureAudio', 'click .module-scroll-down': 'scrollToBottom', @@ -308,6 +314,28 @@ paste: 'onPaste', }, + setupStickerPickerButton() { + const props = { + onClickAddPack: () => this.showStickerManager(), + onPickSticker: (packId, stickerId) => + this.sendStickerMessage({ packId, stickerId }), + }; + + this.stickerButtonView = new Whisper.ReactWrapperView({ + className: 'sticker-button-wrapper', + JSX: Signal.State.Roots.createStickerButton(window.reduxStore, props), + }); + + // Finally, add it to the DOM + this.$('.sticker-button-placeholder').append(this.stickerButtonView.el); + }, + + // We need this, or clicking the sticker button will submit the form and send any + // mid-composition message content. + onClickStickerButtonPlaceholder(e) { + e.preventDefault(); + }, + onChooseAttachment(e) { if (e) { e.stopPropagation(); @@ -366,6 +394,13 @@ this.fileInput.remove(); this.titleView.remove(); + if (this.stickerButtonView) { + this.stickerButtonView.remove(); + } + + if (this.stickerPreviewModalView) { + this.stickerPreviewModalView.remove(); + } if (this.captureAudioView) { this.captureAudioView.remove(); @@ -1282,6 +1317,26 @@ dialog.focusCancel(); }, + showStickerPackPreview(packId) { + const props = { + packId, + onClose: () => { + this.stickerPreviewModalView.remove(); + }, + }; + + this.stickerPreviewModalView = new Whisper.ReactWrapperView({ + className: 'sticker-preview-modal-wrapper', + JSX: Signal.State.Roots.createStickerPreviewModal( + window.reduxStore, + props + ), + onClose: () => { + this.stickerPreviewModalView = null; + }, + }); + }, + showLightbox({ attachment, messageId }) { const message = this.model.messageCollection.get(messageId); if (!message) { @@ -1289,6 +1344,13 @@ `showLightbox: did not find message for id ${messageId}` ); } + const sticker = message.get('sticker'); + if (sticker) { + const { packId } = sticker; + this.showStickerPackPreview(packId); + return; + } + const { contentType, path } = attachment; if ( @@ -1400,6 +1462,21 @@ view.render(); }, + showStickerManager() { + const view = new Whisper.ReactWrapperView({ + className: ['sticker-manager-wrapper', 'panel'].join(' '), + JSX: Signal.State.Roots.createStickerManager(window.reduxStore), + onClose: () => { + this.resetPanel(); + this.updateHeader(); + }, + }); + + this.listenBack(view); + this.updateHeader(); + view.render(); + }, + showContactDetail({ contact, signalAccount }) { const view = new Whisper.ReactWrapperView({ Component: Signal.Components.ContactDetail, @@ -1449,6 +1526,8 @@ if (this.panels.length === 0) { this.$el.trigger('force-resize'); + // Make sure poppers are positioned properly + window.dispatchEvent(new Event('resize')); } }, @@ -1482,99 +1561,121 @@ } }, - showSendConfirmationDialog(e, contacts) { - let message; - const isUnverified = this.model.isUnverified(); + showSendAnywayDialog(contacts) { + return new Promise(resolve => { + let message; + const isUnverified = this.model.isUnverified(); - if (contacts.length > 1) { - if (isUnverified) { - message = i18n('changedSinceVerifiedMultiple'); + if (contacts.length > 1) { + if (isUnverified) { + message = i18n('changedSinceVerifiedMultiple'); + } else { + message = i18n('changedRecentlyMultiple'); + } } else { - message = i18n('changedRecentlyMultiple'); + const contactName = contacts.at(0).getTitle(); + if (isUnverified) { + message = i18n('changedSinceVerified', [contactName, contactName]); + } else { + message = i18n('changedRecently', [contactName, contactName]); + } } - } else { - const contactName = contacts.at(0).getTitle(); - if (isUnverified) { - message = i18n('changedSinceVerified', [contactName, contactName]); - } else { - message = i18n('changedRecently', [contactName, contactName]); - } - } - const dialog = new Whisper.ConfirmationDialogView({ - message, - okText: i18n('sendAnyway'), - resolve: () => { - this.checkUnverifiedSendMessage(e, { force: true }); - }, - reject: () => { - this.focusMessageFieldAndClearDisabled(); - }, + const dialog = new Whisper.ConfirmationDialogView({ + message, + okText: i18n('sendAnyway'), + resolve: () => resolve(true), + reject: () => resolve(false), + }); + + this.$el.prepend(dialog.el); + dialog.focusCancel(); }); - - this.$el.prepend(dialog.el); - dialog.focusCancel(); }, - async checkUnverifiedSendMessage(e, options = {}) { + async clickSend(e, options) { e.preventDefault(); + this.sendStart = Date.now(); this.$messageField.attr('disabled', true); - _.defaults(options, { force: false }); - - // This will go to the trust store for the latest identity key information, - // and may result in the display of a new banner for this conversation. try { - await this.model.updateVerified(); - const contacts = this.model.getUnverified(); - if (!contacts.length) { - this.checkUntrustedSendMessage(e, options); + const contacts = await this.getUntrustedContacts(options); + + if (contacts && contacts.length) { + const sendAnyway = await this.showSendAnywayDialog(contacts); + if (sendAnyway) { + this.clickSend(e, { force: true }); + return; + } + + this.focusMessageFieldAndClearDisabled(); return; } - if (options.force) { - await this.markAllAsVerifiedDefault(contacts); - this.checkUnverifiedSendMessage(e, options); - return; - } - - this.showSendConfirmationDialog(e, contacts); + this.sendMessage(e); } catch (error) { this.focusMessageFieldAndClearDisabled(); window.log.error( - 'checkUnverifiedSendMessage error:', + 'clickSend error:', error && error.stack ? error.stack : error ); } }, - async checkUntrustedSendMessage(e, options = {}) { - _.defaults(options, { force: false }); - + async sendStickerMessage(options = {}) { try { - const contacts = await this.model.getUntrusted(); - if (!contacts.length) { - this.sendMessage(e); + const contacts = await this.getUntrustedContacts(options); + + if (contacts && contacts.length) { + const sendAnyway = await this.showSendAnywayDialog(contacts); + if (sendAnyway) { + this.sendStickerMessage({ ...options, force: true }); + } + return; } - if (options.force) { - await this.markAllAsApproved(contacts); - this.sendMessage(e); - return; - } - - this.showSendConfirmationDialog(e, contacts); + const { packId, stickerId } = options; + this.model.sendStickerMessage(packId, stickerId); } catch (error) { - this.focusMessageFieldAndClearDisabled(); window.log.error( - 'checkUntrustedSendMessage error:', + 'clickSend error:', error && error.stack ? error.stack : error ); } }, + async getUntrustedContacts(options = {}) { + // This will go to the trust store for the latest identity key information, + // and may result in the display of a new banner for this conversation. + await this.model.updateVerified(); + const unverifiedContacts = this.model.getUnverified(); + + if (options.force) { + if (unverifiedContacts.length) { + await this.markAllAsVerifiedDefault(unverifiedContacts); + // We only want force to break us through one layer of checks + // eslint-disable-next-line no-param-reassign + options.force = false; + } + } else if (unverifiedContacts.length) { + return unverifiedContacts; + } + + const untrustedContacts = await this.model.getUntrusted(); + + if (options.force) { + if (untrustedContacts.length) { + await this.markAllAsApproved(untrustedContacts); + } + } else if (untrustedContacts.length) { + return untrustedContacts; + } + + return null; + }, + toggleEmojiPanel(e) { e.preventDefault(); if (!this.emojiPanel) { @@ -1839,14 +1940,29 @@ async makeChunkedRequest(url) { const PARALLELISM = 3; - const size = await textsecure.messaging.getProxiedSize(url); - const chunks = await Signal.LinkPreviews.getChunkPattern(size); + const first = await textsecure.messaging.makeProxiedRequest(url, { + start: 0, + end: Signal.Crypto.getRandomValue(1023, 2047), + returnArrayBuffer: true, + }); + const { totalSize, result } = first; + const initialOffset = result.data.byteLength; + const firstChunk = { + start: 0, + end: initialOffset, + ...result, + }; + + const chunks = await Signal.LinkPreviews.getChunkPattern( + totalSize, + initialOffset + ); let results = []; const jobs = chunks.map(chunk => async () => { const { start, end } = chunk; - const result = await textsecure.messaging.makeProxiedRequest(url, { + const jobResult = await textsecure.messaging.makeProxiedRequest(url, { start, end, returnArrayBuffer: true, @@ -1854,7 +1970,7 @@ return { ...chunk, - ...result, + ...jobResult.result, }; }); @@ -1878,7 +1994,9 @@ } const { contentType } = results[0]; - const data = Signal.LinkPreviews.assembleChunks(results); + const data = Signal.LinkPreviews.assembleChunks( + [firstChunk].concat(results) + ); return { contentType, @@ -1886,7 +2004,58 @@ }; }, + async getStickerPackPreview(url) { + const isPackValid = pack => + pack && (pack.status === 'advertised' || pack.status === 'installed'); + + try { + const { id, key } = window.Signal.Stickers.getDataFromLink(url); + const keyBytes = window.Signal.Crypto.bytesFromHexString(key); + const keyBase64 = window.Signal.Crypto.arrayBufferToBase64(keyBytes); + + const existing = window.Signal.Stickers.getStickerPack(id); + if (!isPackValid(existing)) { + await window.Signal.Stickers.downloadStickerPack(id, keyBase64); + } + + const pack = window.Signal.Stickers.getStickerPack(id); + if (!isPackValid(pack)) { + return null; + } + if (pack.key !== keyBase64) { + return null; + } + + const { title, coverStickerId } = pack; + const sticker = pack.stickers[coverStickerId]; + const data = await window.Signal.Migrations.readStickerData( + sticker.path + ); + + return { + title, + url, + image: { + ...sticker, + data, + size: data.byteLength, + contentType: 'image/webp', + }, + }; + } catch (error) { + window.log.error( + 'getStickerPackPreview error:', + error && error.stack ? error.stack : error + ); + return null; + } + }, + async getPreview(url) { + if (window.Signal.LinkPreviews.isStickerPack(url)) { + return this.getStickerPackPreview(url); + } + let html; try { html = await textsecure.messaging.makeProxiedRequest(url); diff --git a/js/views/inbox_view.js b/js/views/inbox_view.js index 07df7db3c..a372c01ce 100644 --- a/js/views/inbox_view.js +++ b/js/views/inbox_view.js @@ -13,6 +13,12 @@ window.Whisper = window.Whisper || {}; + Whisper.StickerPackInstallFailedToast = Whisper.ToastView.extend({ + render_attributes() { + return { toastMessage: i18n('stickers--toast--InstallFailed') }; + }, + }); + Whisper.ConversationStack = Whisper.View.extend({ className: 'conversation-stack', open(conversation) { @@ -36,6 +42,8 @@ $el.prependTo(this.el); } conversation.trigger('opened'); + // Make sure poppers are positioned properly + window.dispatchEvent(new Event('resize')); }, }); @@ -92,6 +100,12 @@ this.$el.addClass('expired'); } + Whisper.events.on('pack-install-failed', () => { + const toast = new Whisper.StickerPackInstallFailedToast(); + toast.$el.appendTo(this.$el); + toast.render(); + }); + this.setupLeftPane(); }, render_attributes: { diff --git a/js/views/message_view.js b/js/views/message_view.js index 1f82933f8..ef1b26d57 100644 --- a/js/views/message_view.js +++ b/js/views/message_view.js @@ -16,6 +16,12 @@ this.listenTo(this.model, 'destroy', this.onDestroy); this.listenTo(this.model, 'unload', this.onUnload); this.listenTo(this.model, 'expired', this.onExpired); + + this.updateHiddenSticker(); + }, + updateHiddenSticker() { + const sticker = this.model.get('sticker'); + this.isHiddenSticker = sticker && (!sticker.data || !sticker.data.path); }, onChange() { this.addId(); @@ -94,7 +100,17 @@ const update = () => { const info = this.getRenderInfo(); - this.childView.update(info.props); + this.childView.update(info.props, () => { + if (!this.isHiddenSticker) { + return; + } + + this.updateHiddenSticker(); + + if (!this.isHiddenSticker) { + this.model.trigger('height-changed'); + } + }); }; this.listenTo(this.model, 'change', update); diff --git a/js/views/react_wrapper_view.js b/js/views/react_wrapper_view.js index a256fff18..7d108fb15 100644 --- a/js/views/react_wrapper_view.js +++ b/js/views/react_wrapper_view.js @@ -38,12 +38,23 @@ this.hasRendered = false; }, - update(props) { + update(props, cb) { const updatedProps = this.augmentProps(props); const reactElement = this.JSX ? this.JSX : React.createElement(this.Component, updatedProps); ReactDOM.render(reactElement, this.el, () => { + if (cb) { + try { + cb(); + } catch (error) { + window.log.error( + 'ReactWrapperView.update error:', + error && error.stack ? error.stack : error + ); + } + } + if (this.hasRendered) { return; } diff --git a/libtextsecure/crypto.js b/libtextsecure/crypto.js index 7183330a9..82c52547a 100644 --- a/libtextsecure/crypto.js +++ b/libtextsecure/crypto.js @@ -90,10 +90,11 @@ return verifyMAC(ivAndCiphertext, macKey, mac, 32) .then(() => { - if (!theirDigest) { - throw new Error('Failure: Ask sender to update Signal and resend.'); + if (theirDigest) { + return verifyDigest(encryptedBin, theirDigest); } - return verifyDigest(encryptedBin, theirDigest); + + return null; }) .then(() => decrypt(aesKey, ciphertext, iv)); }, diff --git a/libtextsecure/message_receiver.js b/libtextsecure/message_receiver.js index 4974d416d..01e2f6cf9 100644 --- a/libtextsecure/message_receiver.js +++ b/libtextsecure/message_receiver.js @@ -1110,6 +1110,11 @@ MessageReceiver.prototype.extend({ return this.handleVerified(envelope, syncMessage.verified); } else if (syncMessage.configuration) { return this.handleConfiguration(envelope, syncMessage.configuration); + } else if (syncMessage.stickerPackOperation) { + return this.handleStickerPackOperation( + envelope, + syncMessage.stickerPackOperation + ); } throw new Error('Got empty SyncMessage'); }, @@ -1120,6 +1125,19 @@ MessageReceiver.prototype.extend({ ev.configuration = configuration; return this.dispatchAndWait(ev); }, + handleStickerPackOperation(envelope, operations) { + const ENUM = textsecure.protobuf.SyncMessage.StickerPackOperation.Type; + window.log.info('got sticker pack operation sync message'); + const ev = new Event('sticker-pack'); + ev.confirm = this.removeFromCache.bind(this, envelope); + ev.stickerPacks = operations.map(operation => ({ + id: operation.packId ? operation.packId.toString('hex') : null, + key: operation.packKey ? operation.packKey.toString('base64') : null, + isInstall: operation.type === ENUM.INSTALL, + isRemove: operation.type === ENUM.REMOVE, + })); + return this.dispatchAndWait(ev); + }, handleVerified(envelope, verified) { const ev = new Event('verified'); ev.confirm = this.removeFromCache.bind(this, envelope); @@ -1231,6 +1249,10 @@ MessageReceiver.prototype.extend({ const encrypted = await this.server.getAttachment(attachment.id); const { key, digest, size } = attachment; + if (!digest) { + throw new Error('Failure: Ask sender to update Signal and resend.'); + } + const data = await textsecure.crypto.decryptAttachment( encrypted, window.Signal.Crypto.base64ToArrayBuffer(key), @@ -1400,6 +1422,19 @@ MessageReceiver.prototype.extend({ ); } + const { sticker } = decrypted; + if (sticker) { + if (sticker.packId) { + sticker.packId = sticker.packId.toString('hex'); + } + if (sticker.packKey) { + sticker.packKey = sticker.packKey.toString('base64'); + } + if (sticker.data) { + sticker.data = this.cleanAttachment(sticker.data); + } + } + return Promise.all(promises).then(() => decrypted); /* eslint-enable no-bitwise, no-param-reassign */ }, diff --git a/libtextsecure/protobufs.js b/libtextsecure/protobufs.js index 80c91e9e5..71c63f823 100644 --- a/libtextsecure/protobufs.js +++ b/libtextsecure/protobufs.js @@ -35,6 +35,7 @@ loadProtoBufs('SignalService.proto'); loadProtoBufs('SubProtocol.proto'); loadProtoBufs('DeviceMessages.proto'); + loadProtoBufs('Stickers.proto'); // Just for encrypting device names loadProtoBufs('DeviceName.proto'); diff --git a/libtextsecure/sendmessage.js b/libtextsecure/sendmessage.js index df6e114be..9490f9f19 100644 --- a/libtextsecure/sendmessage.js +++ b/libtextsecure/sendmessage.js @@ -1,4 +1,4 @@ -/* global _, textsecure, WebAPI, libsignal, OutgoingMessage, window */ +/* global _, textsecure, WebAPI, libsignal, OutgoingMessage, window, dcodeIO */ /* eslint-disable more/no-then, no-bitwise */ @@ -13,18 +13,26 @@ function stringToArrayBuffer(str) { } return res; } +function hexStringToArrayBuffer(string) { + return dcodeIO.ByteBuffer.wrap(string, 'hex').toArrayBuffer(); +} +function base64ToArrayBuffer(string) { + return dcodeIO.ByteBuffer.wrap(string, 'base64').toArrayBuffer(); +} function Message(options) { - this.body = options.body; this.attachments = options.attachments || []; - this.quote = options.quote; - this.preview = options.preview; - this.group = options.group; - this.flags = options.flags; - this.recipients = options.recipients; - this.timestamp = options.timestamp; + this.body = options.body; this.expireTimer = options.expireTimer; + this.flags = options.flags; + this.group = options.group; + this.needsSync = options.needsSync; + this.preview = options.preview; this.profileKey = options.profileKey; + this.quote = options.quote; + this.recipients = options.recipients; + this.sticker = options.sticker; + this.timestamp = options.timestamp; if (!(this.recipients instanceof Array)) { throw new Error('Invalid recipient list'); @@ -102,6 +110,16 @@ Message.prototype = { proto.group.id = stringToArrayBuffer(this.group.id); proto.group.type = this.group.type; } + if (this.sticker) { + proto.sticker = new textsecure.protobuf.DataMessage.Sticker(); + proto.sticker.packId = hexStringToArrayBuffer(this.sticker.packId); + proto.sticker.packKey = base64ToArrayBuffer(this.sticker.packKey); + proto.sticker.stickerId = this.sticker.stickerId; + + if (this.sticker.attachmentPointer) { + proto.sticker.data = this.sticker.attachmentPointer; + } + } if (Array.isArray(this.preview)) { proto.preview = this.preview.map(preview => { const item = new textsecure.protobuf.DataMessage.Preview(); @@ -154,8 +172,6 @@ function MessageSender(username, password) { this.pendingMessages = {}; } -const DISABLE_PADDING = true; - MessageSender.prototype = { constructor: MessageSender, @@ -166,8 +182,8 @@ MessageSender.prototype = { ); }, - getPaddedAttachment(data) { - if (DISABLE_PADDING) { + getPaddedAttachment(data, shouldPad) { + if (!shouldPad) { return data; } @@ -178,7 +194,7 @@ MessageSender.prototype = { return window.Signal.Crypto.concatenateBytes(data, padding); }, - async makeAttachmentPointer(attachment) { + async makeAttachmentPointer(attachment, shouldPad = false) { if (typeof attachment !== 'object' || attachment == null) { return Promise.resolve(undefined); } @@ -197,7 +213,7 @@ MessageSender.prototype = { ); } - const padded = this.getPaddedAttachment(data); + const padded = this.getPaddedAttachment(data, shouldPad); const key = libsignal.crypto.getRandomBytes(64); const iv = libsignal.crypto.getRandomBytes(16); @@ -286,6 +302,32 @@ MessageSender.prototype = { } }, + async uploadSticker(message) { + try { + const { sticker } = message; + + if (!sticker || !sticker.data) { + return; + } + + const shouldPad = true; + // eslint-disable-next-line no-param-reassign + message.sticker = { + ...sticker, + attachmentPointer: await this.makeAttachmentPointer( + sticker.data, + shouldPad + ), + }; + } catch (error) { + if (error instanceof Error && error.name === 'HTTPError') { + throw new textsecure.MessageError(message, error); + } else { + throw error; + } + } + }, + uploadThumbnails(message) { const makePointer = this.makeAttachmentPointer.bind(this); const { quote } = message; @@ -323,6 +365,7 @@ MessageSender.prototype = { this.uploadAttachments(message), this.uploadThumbnails(message), this.uploadLinkPreviews(message), + this.uploadSticker(message), ]).then( () => new Promise((resolve, reject) => { @@ -510,6 +553,13 @@ MessageSender.prototype = { return this.server.getAvatar(path); }, + getSticker(packId, stickerId) { + return this.server.getSticker(packId, stickerId); + }, + getStickerPackManifest(packId) { + return this.server.getStickerPackManifest(packId); + }, + sendRequestConfigurationSyncMessage(options) { const myNumber = textsecure.storage.user.getNumber(); const myDevice = textsecure.storage.user.getDeviceId(); @@ -698,6 +748,41 @@ MessageSender.prototype = { return Promise.resolve(); }, + async sendStickerPackSync(operations, options) { + const myDevice = textsecure.storage.user.getDeviceId(); + if (myDevice === 1 || myDevice === '1') { + return null; + } + + const myNumber = textsecure.storage.user.getNumber(); + const ENUM = textsecure.protobuf.SyncMessage.StickerPackOperation.Type; + + const packOperations = operations.map(item => { + const { packId, packKey, installed } = item; + + const operation = new textsecure.protobuf.SyncMessage.StickerPackOperation(); + operation.packId = hexStringToArrayBuffer(packId); + operation.packKey = base64ToArrayBuffer(packKey); + operation.type = installed ? ENUM.INSTALL : ENUM.REMOVE; + + return operation; + }); + + const syncMessage = this.createSyncMessage(); + syncMessage.stickerPackOperation = packOperations; + + const contentMessage = new textsecure.protobuf.Content(); + contentMessage.syncMessage = syncMessage; + + const silent = true; + return this.sendIndividualProto( + myNumber, + contentMessage, + Date.now(), + silent, + options + ); + }, syncVerification(destination, state, identityKey, options) { const myNumber = textsecure.storage.user.getNumber(); const myDevice = textsecure.storage.user.getDeviceId(); @@ -795,6 +880,7 @@ MessageSender.prototype = { attachments, quote, preview, + sticker, timestamp, expireTimer, profileKey, @@ -807,6 +893,7 @@ MessageSender.prototype = { attachments, quote, preview, + sticker, expireTimer, profileKey, flags, @@ -821,6 +908,7 @@ MessageSender.prototype = { this.uploadAttachments(message), this.uploadThumbnails(message), this.uploadLinkPreviews(message), + this.uploadSticker(message), ]); return message.toArrayBuffer(); @@ -832,6 +920,7 @@ MessageSender.prototype = { attachments, quote, preview, + sticker, timestamp, expireTimer, profileKey, @@ -845,6 +934,7 @@ MessageSender.prototype = { attachments, quote, preview, + sticker, expireTimer, profileKey, }, @@ -928,6 +1018,7 @@ MessageSender.prototype = { attachments, quote, preview, + sticker, timestamp, expireTimer, profileKey, @@ -942,6 +1033,7 @@ MessageSender.prototype = { attachments, quote, preview, + sticker, expireTimer, profileKey, group: { @@ -1098,9 +1190,6 @@ MessageSender.prototype = { makeProxiedRequest(url, options) { return this.server.makeProxiedRequest(url, options); }, - getProxiedSize(url) { - return this.server.getProxiedSize(url); - }, }; window.textsecure = window.textsecure || {}; @@ -1142,10 +1231,11 @@ textsecure.MessageSender = function MessageSenderWrapper(username, password) { this.sendDeliveryReceipt = sender.sendDeliveryReceipt.bind(sender); this.sendReadReceipts = sender.sendReadReceipts.bind(sender); this.makeProxiedRequest = sender.makeProxiedRequest.bind(sender); - this.getProxiedSize = sender.getProxiedSize.bind(sender); this.getMessageProto = sender.getMessageProto.bind(sender); - this._getAttachmentSizeBucket = sender._getAttachmentSizeBucket.bind(sender); + this.getSticker = sender.getSticker.bind(sender); + this.getStickerPackManifest = sender.getStickerPackManifest.bind(sender); + this.sendStickerPackSync = sender.sendStickerPackSync.bind(sender); }; textsecure.MessageSender.prototype = { diff --git a/main.js b/main.js index 35395ab36..b1a06001e 100644 --- a/main.js +++ b/main.js @@ -5,6 +5,7 @@ const url = require('url'); const os = require('os'); const fs = require('fs'); const crypto = require('crypto'); +const qs = require('qs'); const _ = require('lodash'); const pify = require('pify'); @@ -398,13 +399,16 @@ ipc.on('show-window', () => { showWindow(); }); -let updatesStarted = false; -ipc.on('ready-for-updates', async () => { - if (updatesStarted) { - return; +ipc.once('ready-for-updates', async () => { + // First, install requested sticker pack + if (process.argv.length > 1) { + const [incomingUrl] = process.argv; + if (incomingUrl.startsWith('sgnl://')) { + handleSgnlLink(incomingUrl); + } } - updatesStarted = true; + // Second, start checking for app updates try { await updater.start(getMainWindow, locale.messages, logger); } catch (error) { @@ -714,6 +718,13 @@ app.on('ready', async () => { userDataPath, attachments: orphanedAttachments, }); + + const allStickers = await attachments.getAllStickers(userDataPath); + const orphanedStickers = await sql.removeKnownStickers(allStickers); + await attachments.deleteAllStickers({ + userDataPath, + stickers: orphanedStickers, + }); } await attachmentChannel.initialize({ @@ -840,6 +851,12 @@ app.on('web-contents-created', (createEvent, contents) => { }); }); +app.setAsDefaultProtocolClient('sgnl'); +app.on('open-url', (event, incomingUrl) => { + event.preventDefault(); + handleSgnlLink(incomingUrl); +}); + ipc.on('set-badge-count', (event, count) => { app.setBadgeCount(count); }); @@ -1011,3 +1028,15 @@ function installSettingsSetter(name) { } }); } + +function handleSgnlLink(incomingUrl) { + const { host: command, query } = url.parse(incomingUrl); + const args = qs.parse(query); + if (command === 'addstickers' && mainWindow && mainWindow.webContents) { + const { pack_id: packId, pack_key: packKeyHex } = args; + const packKey = Buffer.from(packKeyHex, 'hex').toString('base64'); + mainWindow.webContents.send('add-sticker-pack', { packId, packKey }); + } else { + console.error('Unhandled sgnl link'); + } +} diff --git a/package.json b/package.json index e1fdd8aae..b71dfdc35 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "bunyan": "1.8.12", "classnames": "2.2.5", "config": "1.28.1", - "electron-context-menu": "^0.11.0", + "electron-context-menu": "0.11.0", "electron-editor-context-menu": "1.1.1", "electron-is-dev": "0.3.0", "emoji-datasource": "4.0.0", @@ -80,12 +80,16 @@ "node-gyp": "3.8.0", "node-sass": "4.9.3", "os-locale": "2.1.0", + "p-map": "2.1.0", + "p-queue": "5.0.0", "pify": "3.0.0", "protobufjs": "6.8.6", "proxy-agent": "3.0.3", + "qs": "6.5.1", "react": "16.8.3", "react-contextmenu": "2.11.0", "react-dom": "16.8.3", + "react-popper": "^1.3.3", "react-redux": "6.0.1", "react-virtualized": "9.21.0", "read-last-lines": "1.3.0", @@ -158,7 +162,6 @@ "node-sass-import-once": "1.2.0", "nyc": "11.4.1", "prettier": "1.12.0", - "qs": "6.5.1", "react-docgen-typescript": "1.2.6", "react-styleguidist": "7.0.1", "sinon": "4.4.2", diff --git a/preload.js b/preload.js index 2efbcc3d3..48a516565 100644 --- a/preload.js +++ b/preload.js @@ -150,6 +150,14 @@ ipc.on('delete-all-data', () => { } }); +ipc.on('add-sticker-pack', (_event, info) => { + const { packId, packKey } = info; + const { installStickerPack } = window.Events; + if (installStickerPack) { + installStickerPack(packId, packKey); + } +}); + ipc.on('get-ready-for-shutdown', async () => { const { shutdown } = window.Events || {}; if (!shutdown) { @@ -271,9 +279,12 @@ window.moment.updateLocale(locale, { }); window.moment.locale(locale); +const userDataPath = app.getPath('userData'); +window.baseAttachmentsPath = Attachments.getPath(userDataPath); +window.baseStickersPath = Attachments.getStickersPath(userDataPath); window.Signal = Signal.setup({ Attachments, - userDataPath: app.getPath('userData'), + userDataPath, getRegionCode: () => window.storage.get('regionCode'), logger: window.log, }); diff --git a/protos/SignalService.proto b/protos/SignalService.proto index cbcb0256b..83d9646b3 100644 --- a/protos/SignalService.proto +++ b/protos/SignalService.proto @@ -162,6 +162,13 @@ message DataMessage { optional AttachmentPointer image = 3; } + message Sticker { + optional bytes packId = 1; + optional bytes packKey = 2; + optional uint32 stickerId = 3; + optional AttachmentPointer data = 4; + } + optional string body = 1; repeated AttachmentPointer attachments = 2; optional GroupContext group = 3; @@ -172,6 +179,7 @@ message DataMessage { optional Quote quote = 8; repeated Contact contact = 9; repeated Preview preview = 10; + optional Sticker sticker = 11; } message NullMessage { @@ -265,15 +273,26 @@ message SyncMessage { optional bool linkPreviews = 4; } - optional Sent sent = 1; - optional Contacts contacts = 2; - optional Groups groups = 3; - optional Request request = 4; - repeated Read read = 5; - optional Blocked blocked = 6; - optional Verified verified = 7; - optional Configuration configuration = 9; - optional bytes padding = 8; + message StickerPackOperation { + enum Type { + INSTALL = 0; + REMOVE = 1; + } + optional bytes packId = 1; + optional bytes packKey = 2; + optional Type type = 3; + } + + optional Sent sent = 1; + optional Contacts contacts = 2; + optional Groups groups = 3; + optional Request request = 4; + repeated Read read = 5; + optional Blocked blocked = 6; + optional Verified verified = 7; + optional Configuration configuration = 9; + optional bytes padding = 8; + repeated StickerPackOperation stickerPackOperation = 10; } message AttachmentPointer { diff --git a/protos/Stickers.proto b/protos/Stickers.proto new file mode 100644 index 000000000..82dfa0dbf --- /dev/null +++ b/protos/Stickers.proto @@ -0,0 +1,13 @@ +package signalservice; + +message StickerPack { + message Sticker { + optional uint32 id = 1; + optional string emoji = 2; + } + + optional string title = 1; + optional string author = 2; + optional Sticker cover = 3; + repeated Sticker stickers = 4; +} diff --git a/styleguide.config.js b/styleguide.config.js index c58cf7d82..c73a1ea88 100644 --- a/styleguide.config.js +++ b/styleguide.config.js @@ -21,6 +21,11 @@ module.exports = { description: 'Display media and documents in a conversation', components: 'ts/components/conversation/media-gallery/[^_]*.tsx', }, + { + name: 'Stickers', + description: 'All components related to stickers', + components: 'ts/components/stickers/[^_]*.tsx', + }, { name: 'Utility', description: 'Utility components used across the application', @@ -73,7 +78,7 @@ module.exports = { }, { // To test handling of attachments, we need arraybuffers in memory - test: /\.(gif|mp3|mp4|txt|jpg|jpeg|png)$/, + test: /\.(gif|mp3|mp4|txt|jpg|jpeg|png|webp)$/, loader: 'arraybuffer-loader', }, ], diff --git a/stylesheets/_conversation.scss b/stylesheets/_conversation.scss index 1ff50bd01..965080574 100644 --- a/stylesheets/_conversation.scss +++ b/stylesheets/_conversation.scss @@ -51,6 +51,11 @@ } } +// Make sure the main panel is hidden when other panels are in the dom +.panel + .main.panel { + display: none; +} + .message-detail-wrapper { height: calc(100% - 48px); width: 100%; diff --git a/stylesheets/_global.scss b/stylesheets/_global.scss index 5b1d19ee1..3a8d869e9 100644 --- a/stylesheets/_global.scss +++ b/stylesheets/_global.scss @@ -630,3 +630,7 @@ $loading-height: 16px; .inbox { position: relative; } + +.overflow-hidden { + overflow: hidden; +} diff --git a/stylesheets/_ios.scss b/stylesheets/_ios.scss index cbd61cfaa..b97c34dd7 100644 --- a/stylesheets/_ios.scss +++ b/stylesheets/_ios.scss @@ -43,6 +43,9 @@ .module-message__metadata__date--with-image-no-caption { color: $color-white; } + .module-message__metadata__date--with-sticker { + color: $color-gray-60; + } .module-message__metadata__status-icon--sending { @include color-svg('../images/sending.svg', $color-white); @@ -61,6 +64,9 @@ .module-message__metadata__status-icon--with-image-no-caption { background-color: $color-white; } + .module-message__metadata__status-icon--with-sticker { + background-color: $color-gray-60; + } .module-message__generic-attachment__file-name { color: $color-white; @@ -81,10 +87,12 @@ .module-expire-timer { background-color: $color-white-08; } - .module-expire-timer--incoming { background-color: $color-gray-60; } + .module-expire-timer--with-sticker { + background-color: $color-gray-60; + } .module-quote--outgoing { border-left-color: $color-white; @@ -188,7 +196,6 @@ .module-message__metadata__date { color: $color-white-08; } - .module-message__metadata__date--incoming { color: $color-gray-25; } @@ -203,7 +210,6 @@ .module-expire-timer { background-color: $color-white-08; } - .module-expire-timer--incoming { background-color: $color-gray-25; } diff --git a/stylesheets/_mixins.scss b/stylesheets/_mixins.scss index a585ad5bc..72b6f5f7c 100644 --- a/stylesheets/_mixins.scss +++ b/stylesheets/_mixins.scss @@ -34,7 +34,11 @@ } @mixin dark-theme() { - body.dark-theme & { + .dark-theme & { @content; } } + +@mixin popper-shadow() { + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.08), 0 8px 20px 0 rgba(0, 0, 0, 0.33); +} diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 5f5ce4b73..0e8c0caa2 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -157,6 +157,10 @@ min-width: 0px; } +.module-message__container--with-sticker { + padding-bottom: 0px; +} + .module-message__container--outgoing { background-color: $color-light-10; } @@ -201,31 +205,53 @@ } .module-message__attachment-container { - // Entirely to ensure that images are centered if they aren't full width of bubble + // To ensure that images are centered if they aren't full width of bubble text-align: center; position: relative; - margin-left: -12px; - margin-right: -12px; - margin-top: -10px; - margin-bottom: -10px; + margin: { + left: -12px; + right: -12px; + top: -10px; + bottom: -10px; + } border-radius: 16px; overflow: hidden; background-color: $color-white; + + &--with-content-below { + margin-bottom: 7px; + border-bottom-left-radius: 0px; + border-bottom-right-radius: 0px; + } + + &--with-content-above { + margin-top: 4px; + border-top-left-radius: 0px; + border-top-right-radius: 0px; + } } -.module-message__attachment-container--with-content-below { - margin-bottom: 7px; - border-bottom-left-radius: 0px; - border-bottom-right-radius: 0px; -} +.module-message__sticker-container { + // To ensure that images are centered if they aren't full width of bubble + text-align: center; -.module-message__attachment-container--with-content-above { - margin-top: 4px; - border-top-left-radius: 0px; - border-top-right-radius: 0px; + margin: { + left: -12px; + right: -12px; + top: -9px; + bottom: -5px; + } + + &--with-content-below { + margin-bottom: 5px; + } + + &--with-content-above { + margin-top: 4px; + } } .module-message__img-attachment { @@ -440,10 +466,32 @@ overflow-y: hidden; white-space: nowrap; text-overflow: ellipsis; + + &__profile-name { + font-style: italic; + } } -.module-message__author__profile-name { - font-style: italic; +.module-message__author_with_sticker { + color: $color-gray-90; + font-size: 13px; + font-weight: 300; + line-height: 18px; + height: 18px; + overflow-x: hidden; + overflow-y: hidden; + white-space: nowrap; + text-overflow: ellipsis; + + // Stickers are pretty narrow, so we allow this one element of a sticker + // message to go wider than normal. + // There's a tension here, since this is width and not max-width; things will + // look bad for RTL users if we make it too wide. + width: 300px; + + &__profile-name { + font-style: italic; + } } .module-message__text { @@ -451,7 +499,6 @@ font-size: 14px; line-height: 18px; text-align: start; - overflow-wrap: break-word; word-wrap: break-word; word-break: break-word; @@ -2160,13 +2207,16 @@ .module-image { overflow: hidden; - background-color: $color-white; position: relative; display: inline-block; margin: 1px; vertical-align: middle; } +.module-image--with-background { + background-color: $color-white; +} + .module-image__caption-icon { position: absolute; top: 6px; @@ -2318,6 +2368,10 @@ margin-bottom: -5px; } +.module-image-grid--with-sticker { + padding: 8px; +} + .module-image-grid__column { display: inline-flex; flex-direction: column; @@ -3119,6 +3173,910 @@ outline: none; } +// Module: StickerPicker + +.module-sticker-picker { + width: 332px; + height: 400px; + border-radius: 8px; + display: grid; + grid-template-rows: 44px 1fr; + grid-template-columns: 1fr; + user-select: none; + overflow: hidden; + z-index: 2; + margin-bottom: 6px; + + @include popper-shadow(); + + @include light-theme { + background: $color-gray-02; + } + + @include dark-theme { + background: $color-gray-75; + } +} + +.module-sticker-picker__header { + display: flex; + flex-direction: row; + padding: 0 8px; + justify-content: flex-start; + align-items: center; +} + +.module-sticker-picker__header__packs { + width: 288px; + overflow: hidden; + position: relative; + + &__slider { + display: flex; + flex-direction: row; + transform: translateX(0); + transition: transform 200ms cubic-bezier(0.25, 0.46, 0.45, 0.94); + } +} + +.module-sticker-picker__header__button { + width: 28px; + height: 28px; + border: 0; + border-radius: 8px; + display: flex; + justify-content: center; + align-items: center; + background: none; + margin-right: 4px; + + &:active, + &:focus { + outline: none; + } + + &--selected { + @include light-theme { + background: $color-gray-10; + } + @include dark-theme { + background: $color-gray-60; + } + } + + &--recents, + &--add-pack { + &::after { + content: ''; + display: block; + min-width: 20px; + min-height: 20px; + } + } + + &--recents { + &::after { + @include light-theme { + @include color-svg('../images/recent-outline.svg', $color-gray-60); + } + @include dark-theme { + @include color-svg('../images/recent-outline.svg', $color-gray-25); + } + } + } + + &--add-pack { + &::after { + @include light-theme { + @include color-svg('../images/plus-20.svg', $color-gray-60); + } + @include dark-theme { + @include color-svg('../images/plus-20.svg', $color-gray-25); + } + } + } + + &--prev-page, + &--next-page { + top: 0; + margin: 0; + border-radius: 0; + + &::after { + content: ''; + display: block; + min-width: 12px; + min-height: 12px; + } + + @include light-theme { + background: $color-gray-02; + } + + @include dark-theme { + background: $color-gray-75; + } + } + + &--prev-page { + position: absolute; + left: 0; + + &::after { + @include light-theme { + @include color-svg('../images/chevron-left-12.svg', $color-gray-60); + } + @include dark-theme { + @include color-svg('../images/chevron-left-12.svg', $color-gray-25); + } + } + } + + &--next-page { + position: absolute; + right: 0; + + &::after { + @include light-theme { + @include color-svg('../images/chevron-right-12.svg', $color-gray-60); + } + @include dark-theme { + @include color-svg('../images/chevron-right-12.svg', $color-gray-25); + } + } + } + + &--error { + position: relative; + + &::before { + display: block; + content: ''; + width: 12px; + height: 12px; + position: absolute; + left: 14px; + top: 2px; + @include color-svg('../images/error-filled.svg', $color-core-red); + } + } + + &--hint { + position: relative; + &::before { + display: block; + content: ''; + position: absolute; + top: 0; + right: 0; + width: 14px; + height: 14px; + border-radius: 7px; + background: $color-signal-blue; + } + } +} + +.module-sticker-picker__header__button__image { + min-width: 20px; + min-height: 20px; + max-width: 20px; + max-height: 20px; +} + +.module-sticker-picker__body { + position: relative; + + &__content { + width: 332px; + height: 356px; + padding: 8px 20px 16px 16px; + overflow-y: auto; + display: grid; + grid-gap: 8px; + grid-template-columns: repeat(4, 1fr); + grid-auto-rows: 68px; + + &--under-text { + height: 320px; + } + + &--under-long-text { + height: 304px; + } + } + + &__cell { + border: none; + background: none; + padding: 0; + width: 68px; + height: 68px; + display: flex; + justify-content: center; + align-items: center; + + &__image, + &__placeholder { + width: 100%; + height: 100%; + } + + &__placeholder { + border-radius: 4px; + + @include light-theme() { + background-color: $color-gray-05; + } + + @include dark-theme() { + background-color: $color-gray-60; + } + } + } + + &--empty { + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; + } + + &__text { + font-family: Roboto; + font-weight: 300; + font-size: 14px; + text-align: center; + padding: 8px 16px 12px 0; + + @include light-theme() { + color: $color-gray-60; + } + + @include dark-theme() { + color: $color-gray-25; + } + + &:only-child { + padding: 0 0 28px 0; // header height to offset the text so it is centered in the whole picker + } + + &--error { + @include light-theme() { + color: $color-core-red; + } + @include dark-theme() { + color: $color-core-red; + } + } + + &--hint { + @include light-theme() { + color: $color-signal-blue; + } + + @include dark-theme() { + color: $color-signal-blue; + } + } + + &--pin { + padding: 8px 16px 12px 0px; + position: absolute; + top: 0; + } + } +} + +// Module: StickerManager + +.module-sticker-manager { + padding: 0 16px; +} + +.module-sticker-manager__text { + height: 18px; + font-size: 13px; + font-weight: normal; + font-family: Roboto; + letter-spacing: 0px; + line-height: 18px; + + @include light-theme() { + color: $color-gray-60; + } + + @include dark-theme() { + color: $color-gray-25; + } + + &--heading { + font-size: 14px; + font-weight: 300; + + @include light-theme() { + color: $color-gray-90; + } + + @include dark-theme() { + color: $color-gray-05; + } + } +} + +.module-sticker-manager__empty { + display: flex; + justify-content: center; + align-items: center; + height: 64px; + border-radius: 8px; + font-family: Roboto-Light; + font-size: 13px; + + @include light-theme { + background: $color-gray-02; + color: $color-gray-60; + } + + @include dark-theme { + background: $color-gray-90; + color: $color-gray-25; + } +} + +.module-sticker-manager__pack-row { + display: flex; + flex-direction: row; + padding: 16px 0; + + &:hover { + cursor: pointer; + } + + & + & { + border-top: 1px solid $color-gray-15; + } + + &__cover { + width: 48px; + height: 48px; + } + + &__meta { + flex-grow: 1; + display: flex; + flex-direction: column; + + &:not(:first-child) { + padding: 0 12px; + } + + &__title { + flex: 1; + } + + &__author { + flex: 1; + font-family: Roboto-Light; + font-size: 13px; + + @include light-theme() { + color: $color-gray-60; + } + + @include dark-theme() { + color: $color-gray-25; + } + } + + &__blessed-icon { + height: 16px; + width: 16px; + display: inline-block; + margin-left: 3px; + vertical-align: top; + background-image: url('../images/check-circle-filled-16.svg'); + } + } + + &__controls { + flex-shrink: 1; + display: flex; + justify-content: center; + align-items: center; + + &__button { + background: none; + border: 0; + &--menu { + &:after { + content: ''; + display: block; + min-width: 24px; + min-height: 24px; + @include light-theme { + @include color-svg('../images/more-h.svg', $color-gray-60); + } + @include dark-theme { + @include color-svg('../images/more-h.svg', $color-gray-25); + } + } + } + } + } +} + +.module-sticker-manager__install-button { + background: none; + border: 0; + font-family: Roboto; + color: $color-gray-90; + font-weight: 300; + font-size: 13px; + height: 24px; + background: $color-gray-05; + border-radius: 12px; + display: flex; + justify-content: center; + align-items: center; + padding: 0 12px; + + @include dark-theme { + color: $color-gray-05; + background: $color-gray-75; + } + + &--blue { + @include light-theme { + background: $color-signal-blue; + color: $color-white; + } + @include dark-theme { + background: $color-signal-blue; + color: $color-white; + } + } +} + +.module-sticker-manager__preview-modal { + &__overlay { + background: rgba(0, 0, 0, 0.4); + position: fixed; + left: 0; + top: 0; + width: 100vw; + height: 100vh; + display: flex; + justify-content: center; + align-items: center; + z-index: 5; + } + + &__container { + position: relative; + border-radius: 8px; + box-shadow: 0 4px 12px 0 rgba(0, 0, 0, 0.2); + width: 440px; + height: 360px; + overflow: hidden; + display: flex; + flex-direction: column; + + @include light-theme { + background: $color-white; + } + @include dark-theme { + background: $color-gray-75; + } + + &__header { + display: flex; + flex-direction: row; + height: 36px; + padding: 0 8px 0 16px; + justify-content: space-between; + align-items: center; + + &__text { + font-weight: 300; + font-size: 14px; + color: $color-gray-90; + @include dark-theme { + color: $color-gray-05; + } + } + + &__close-button { + border: none; + width: 20px; + height: 20px; + @include light-theme { + @include color-svg('../images/x.svg', $color-gray-60); + } + @include dark-theme { + @include color-svg('../images/x.svg', $color-gray-05); + } + } + } + + &__sticker-grid { + width: 100%; + display: grid; + grid-gap: 8px; + grid-template-columns: repeat(4, 1fr); + overflow-y: auto; + padding: 0 16px 80px 16px; + + &__cell { + width: 96px; + height: 96px; + + &__image { + width: 100%; + height: 100%; + } + } + } + + &__meta-overlay { + border-radius: 4px; + width: 408px; + height: 52px; + position: absolute; + left: 16px; + bottom: 16px; + padding: 0 12px; + display: flex; + flex-direction: row; + align-items: center; + + @include light-theme { + background: $color-gray-05; + } + + @include dark-theme { + background: $color-gray-60; + } + + &__info { + flex-grow: 1; + display: flex; + flex-direction: column; + justify-content: center; + + &__title { + margin: 0; + font-size: 16px; + font-weight: 300; + height: 20px; + line-height: 20px; + + @include light-theme { + color: $color-gray-90; + } + + @include dark-theme { + color: $color-gray-05; + } + } + + &__author { + margin: 0; + font-family: Roboto-Light; + font-size: 13px; + height: 18px; + line-height: 18px; + + @include light-theme { + color: $color-gray-60; + } + + @include dark-theme { + color: $color-gray-25; + } + } + + &__blessed-icon { + height: 16px; + width: 16px; + display: inline-block; + margin-left: 3px; + vertical-align: top; + + @include light-theme { + background-image: url('../images/check-circle-filled-16.svg'); + } + + @include dark-theme { + background-image: url('../images/check-circle-filled-16.svg'); + } + } + } + + &__install { + flex-shrink: 1; + } + } + } +} + +// Module: Sticker button (launches the sticker picker) + +.sticker-button-wrapper { + height: 36px; + display: flex; + justify-content: center; + align-items: center; + margin-left: 6px; +} + +.module-sticker-button__button { + border: 0; + background: none; + width: 32px; + height: 32px; + border-radius: 16px; + display: flex; + justify-content: center; + align-items: center; + opacity: 0.5; + + &:hover { + opacity: 1; + } + + &:after { + display: block; + content: ''; + width: 24px; + height: 24px; + + @include light-theme { + @include color-svg('../images/sticker-filled.svg', $color-gray-60); + } + @include dark-theme { + @include color-svg('../images/sticker-filled.svg', $color-gray-25); + } + } + + &--active { + @include light-theme() { + background: $color-gray-10; + } + + @include dark-theme() { + background: $color-gray-75; + } + + opacity: 1; + } +} + +.module-sticker-button__tooltip { + height: 34px; + display: flex; + justify-content: center; + align-items: center; + padding: 7px 12px; + border-radius: 8px; + margin-bottom: 6px; + z-index: 1; + + @include light-theme { + background: $color-white; + } + + @include dark-theme { + background: $color-gray-75; + } + + @include popper-shadow(); + + &__triangle { + position: absolute; + width: 0; + height: 0; + border-style: solid; + border-width: 8px 8px 0 8px; + + @include light-theme { + border-color: $color-white transparent transparent transparent; + } + + @include dark-theme { + border-color: $color-gray-75 transparent transparent transparent; + } + + &--top-end { + top: 34px; + } + + &--introduction { + top: 72px; + } + } + + &__image { + width: 20px; + height: 20px; + } + + &__text { + margin-left: 4px; + font-size: 14px; + cursor: default; + + @include light-theme { + color: $color-gray-90; + } + + @include dark-theme { + color: $color-gray-05; + } + + &__title { + font-weight: 300; + } + } + + &--introduction { + width: 420px; + height: 72px; + display: flex; + flex-direction: row; + + &__image { + width: 52px; + height: 52px; + background: #eaeaea; + } + + &__meta { + flex-grow: 1; + padding: 0 12px; + display: flex; + flex-direction: column; + justify-content: center; + + @include light-theme { + color: $color-gray-90; + } + + @include dark-theme { + color: $color-gray-05; + } + + &__title { + margin: 0; + font-size: 14px; + font-weight: 300; + height: 16px; + line-height: 20px; + } + + &__subtitle { + margin-top: 3px; + font-size: 14px; + height: 16px; + } + } + + &__close { + flex-shrink: 1; + height: 100%; + &__button { + width: 20px; + height: 20px; + border: none; + + @include light-theme { + @include color-svg('../images/x.svg', $color-gray-60); + } + + @include dark-theme { + @include color-svg('../images/x.svg', $color-gray-05); + } + } + } + } +} + +// Module: confirmation dialog +.module-confirmation-dialog { + &__overlay { + background: rgba(0, 0, 0, 0.4); + position: fixed; + left: 0; + top: 0; + width: 100vw; + height: 100vh; + display: flex; + justify-content: center; + align-items: center; + z-index: 5; + } + + &__container { + width: 360px; + padding: 12px 16px; + border-radius: 8px; + @include popper-shadow(); + + @include light-theme() { + background: $color-white; + color: $color-gray-90; + } + + @include dark-theme() { + background: $color-gray-75; + color: $color-gray-05; + } + + &__content { + margin-bottom: 20px; + } + + &__buttons { + display: flex; + flex-direction: row; + justify-content: flex-end; + + &__button { + margin-left: 4px; + border-radius: 14px; + height: 28px; + padding: 5px 12px; + display: flex; + justify-content: center; + align-items: center; + font-size: 13px; + font-weight: 500; + font-family: Roboto; + + @include light-theme() { + background: $color-white; + color: $color-gray-60; + border: 1px solid $color-gray-60; + } + + @include dark-theme() { + background: $color-gray-75; + color: $color-gray-25; + border: 1px solid $color-gray-25; + } + + &--negative { + @include light-theme() { + border: none; + background: $color-core-red; + color: $color-white; + } + + @include dark-theme() { + border: none; + background: $color-core-red; + color: $color-white; + } + } + + &--affirmative { + @include light-theme() { + border: none; + background: $color-core-green; + color: $color-white; + } + + @include dark-theme() { + border: none; + background: $color-core-green; + color: $color-white; + } + } + } + } + } +} + // Third-party module: react-contextmenu .react-contextmenu { diff --git a/stylesheets/_theme_dark.scss b/stylesheets/_theme_dark.scss index a13fe80d3..afe5e61d3 100644 --- a/stylesheets/_theme_dark.scss +++ b/stylesheets/_theme_dark.scss @@ -695,6 +695,10 @@ body.dark-theme { color: $color-white; } + .module-message__author_with_sticker { + color: $color-gray-05; + } + .module-message__text { color: $color-dark-05; a { @@ -1351,7 +1355,7 @@ body.dark-theme { // Module: Image - .module-image { + .module-image--with-background { background-color: $color-black; } diff --git a/stylesheets/_variables.scss b/stylesheets/_variables.scss index e7c13bf25..edd888c84 100644 --- a/stylesheets/_variables.scss +++ b/stylesheets/_variables.scss @@ -42,6 +42,7 @@ $color-signal-blue-050: rgba($color-signal-blue, 0.5); $color-white: #ffffff; $color-gray-02: #f8f9f9; $color-gray-05: #eeefef; +$color-gray-10: #e1e2e3; $color-gray-15: #d5d6d6; $color-gray-25: #bbbdbe; $color-gray-45: #898a8c; diff --git a/ts/components/ConfirmationDialog.md b/ts/components/ConfirmationDialog.md new file mode 100644 index 000000000..328ce1027 --- /dev/null +++ b/ts/components/ConfirmationDialog.md @@ -0,0 +1,16 @@ +#### All Options + +```jsx + + console.log('onClose')} + onAffirmative={() => console.log('onAffirmative')} + affirmativeText="Affirm" + onNegative={() => console.log('onNegative')} + negativeText="Negate" + > + asdf child + + +``` diff --git a/ts/components/ConfirmationDialog.tsx b/ts/components/ConfirmationDialog.tsx new file mode 100644 index 000000000..c35408be1 --- /dev/null +++ b/ts/components/ConfirmationDialog.tsx @@ -0,0 +1,117 @@ +import * as React from 'react'; +import classNames from 'classnames'; +import { LocalizerType } from '../types/Util'; + +export type OwnProps = { + readonly i18n: LocalizerType; + readonly children: React.ReactNode; + readonly affirmativeText?: string; + readonly onAffirmative?: () => unknown; + readonly onClose: () => unknown; + readonly negativeText?: string; + readonly onNegative?: () => unknown; +}; + +export type Props = OwnProps; + +function focusRef(el: HTMLElement | null) { + if (el) { + el.focus(); + } +} + +export const ConfirmationDialog = React.memo( + ({ + i18n, + onClose, + children, + onAffirmative, + onNegative, + affirmativeText, + negativeText, + }: Props) => { + React.useEffect( + () => { + const handler = ({ key }: KeyboardEvent) => { + if (key === 'Escape') { + onClose(); + } + }; + document.addEventListener('keyup', handler); + + return () => { + document.removeEventListener('keyup', handler); + }; + }, + [onClose] + ); + + const handleCancel = React.useCallback( + (e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + onClose(); + } + }, + [onClose] + ); + + const handleNegative = React.useCallback( + () => { + onClose(); + if (onNegative) { + onNegative(); + } + }, + [onClose, onNegative] + ); + + const handleAffirmative = React.useCallback( + () => { + onClose(); + if (onAffirmative) { + onAffirmative(); + } + }, + [onClose, onAffirmative] + ); + + return ( +

+
+ {children} +
+
+ + {onNegative && negativeText ? ( + + ) : null} + {onAffirmative && affirmativeText ? ( + + ) : null} +
+
+ ); + } +); diff --git a/ts/components/ConfirmationModal.tsx b/ts/components/ConfirmationModal.tsx new file mode 100644 index 000000000..844dbae23 --- /dev/null +++ b/ts/components/ConfirmationModal.tsx @@ -0,0 +1,89 @@ +import * as React from 'react'; +import { createPortal } from 'react-dom'; +import { ConfirmationDialog } from './ConfirmationDialog'; +import { LocalizerType } from '../types/Util'; + +export type OwnProps = { + readonly i18n: LocalizerType; + readonly children: React.ReactNode; + readonly affirmativeText?: string; + readonly onAffirmative?: () => unknown; + readonly onClose: () => unknown; + readonly negativeText?: string; + readonly onNegative?: () => unknown; +}; + +export type Props = OwnProps; + +export const ConfirmationModal = React.memo( + // tslint:disable-next-line max-func-body-length + ({ + i18n, + onClose, + children, + onAffirmative, + onNegative, + affirmativeText, + negativeText, + }: Props) => { + const [root, setRoot] = React.useState(null); + + React.useEffect(() => { + const div = document.createElement('div'); + document.body.appendChild(div); + setRoot(div); + + return () => { + document.body.removeChild(div); + setRoot(null); + }; + }, []); + + React.useEffect( + () => { + const handler = ({ key }: KeyboardEvent) => { + if (key === 'Escape') { + onClose(); + } + }; + document.addEventListener('keyup', handler); + + return () => { + document.removeEventListener('keyup', handler); + }; + }, + [onClose] + ); + + const handleCancel = React.useCallback( + (e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + onClose(); + } + }, + [onClose] + ); + + return root + ? createPortal( +
+ + {children} + +
, + root + ) + : null; + } +); diff --git a/ts/components/conversation/ExpireTimer.tsx b/ts/components/conversation/ExpireTimer.tsx index cd66055fd..05a7c6a98 100644 --- a/ts/components/conversation/ExpireTimer.tsx +++ b/ts/components/conversation/ExpireTimer.tsx @@ -5,9 +5,10 @@ import { getIncrement, getTimerBucket } from '../../util/timer'; interface Props { withImageNoCaption: boolean; + withSticker: boolean; expirationLength: number; expirationTimestamp: number; - direction: 'incoming' | 'outgoing'; + direction?: 'incoming' | 'outgoing'; } export class ExpireTimer extends React.Component { @@ -44,6 +45,7 @@ export class ExpireTimer extends React.Component { expirationLength, expirationTimestamp, withImageNoCaption, + withSticker, } = this.props; const bucket = getTimerBucket(expirationTimestamp, expirationLength); @@ -56,7 +58,8 @@ export class ExpireTimer extends React.Component { `module-expire-timer--${direction}`, withImageNoCaption ? 'module-expire-timer--with-image-no-caption' - : null + : null, + withSticker ? 'module-expire-timer--with-sticker' : null )} /> ); diff --git a/ts/components/conversation/Image.md b/ts/components/conversation/Image.md index 9e3c39a7d..3dfa78032 100644 --- a/ts/components/conversation/Image.md +++ b/ts/components/conversation/Image.md @@ -418,3 +418,51 @@
``` + +### No border, no background + +```jsx + +
+
+ console.log('onClick')} + onClickClose={attachment => console.log('onClickClose', attachment)} + url={util.squareStickerObjectUrl} + i18n={util.i18n} + /> +
+
+ console.log('onClick')} + onClickClose={attachment => console.log('onClickClose', attachment)} + url={util.squareStickerObjectUrl} + i18n={util.i18n} + /> +
+
+ console.log('onClick')} + onClickClose={attachment => console.log('onClickClose', attachment)} + url={util.squareStickerObjectUrl} + i18n={util.i18n} + /> +
+
+
+``` diff --git a/ts/components/conversation/Image.tsx b/ts/components/conversation/Image.tsx index d94d682fc..376170213 100644 --- a/ts/components/conversation/Image.tsx +++ b/ts/components/conversation/Image.tsx @@ -15,6 +15,8 @@ interface Props { overlayText?: string; + noBorder?: boolean; + noBackground?: boolean; bottomOverlay?: boolean; closeButton?: boolean; curveBottomLeft?: boolean; @@ -49,6 +51,8 @@ export class Image extends React.Component { darkOverlay, height, i18n, + noBackground, + noBorder, onClick, onClickClose, onError, @@ -74,6 +78,7 @@ export class Image extends React.Component { }} className={classNames( 'module-image', + !noBackground ? 'module-image--with-background' : null, canClick ? 'module-image__with-click-handler' : null, curveBottomLeft ? 'module-image--curved-bottom-left' : null, curveBottomRight ? 'module-image--curved-bottom-right' : null, @@ -113,18 +118,20 @@ export class Image extends React.Component { alt={i18n('imageCaptionIconAlt')} /> ) : null} -
+ {!noBorder ? ( +
+ ) : null} {closeButton ? (
; ``` + +### Sticker + +``` +const attachments = [ + { + url: util.squareStickerObjectUrl, + contentType: 'image/webp', + width: 512, + height: 512, + }, +]; + +
+
+ +
+
+
+ +
+
; +``` diff --git a/ts/components/conversation/ImageGrid.tsx b/ts/components/conversation/ImageGrid.tsx index 41ad9e62e..617dde074 100644 --- a/ts/components/conversation/ImageGrid.tsx +++ b/ts/components/conversation/ImageGrid.tsx @@ -20,6 +20,8 @@ interface Props { withContentAbove?: boolean; withContentBelow?: boolean; bottomOverlay?: boolean; + isSticker?: boolean; + stickerSize?: number; i18n: LocalizerType; @@ -34,6 +36,8 @@ export class ImageGrid extends React.Component { attachments, bottomOverlay, i18n, + isSticker, + stickerSize, onError, onClick, withContentAbove, @@ -56,25 +60,31 @@ export class ImageGrid extends React.Component { if (attachments.length === 1 || !areAllAttachmentsVisual(attachments)) { const { height, width } = getImageDimensions(attachments[0]); + const finalHeight = isSticker ? stickerSize : height; + const finalWidth = isSticker ? stickerSize : width; + return (
{getAlt(attachments[0], { i18n={i18n} attachment={attachments[0]} bottomOverlay={withBottomOverlay} + noBorder={isSticker} curveTopLeft={curveTopLeft} curveBottomLeft={curveBottomLeft} playIconOverlay={isVideoAttachment(attachments[0])} @@ -104,6 +115,7 @@ export class ImageGrid extends React.Component { alt={getAlt(attachments[1], i18n)} i18n={i18n} bottomOverlay={withBottomOverlay} + noBorder={isSticker} curveTopRight={curveTopRight} curveBottomRight={curveBottomRight} playIconOverlay={isVideoAttachment(attachments[1])} @@ -125,6 +137,7 @@ export class ImageGrid extends React.Component { alt={getAlt(attachments[0], i18n)} i18n={i18n} bottomOverlay={withBottomOverlay} + noBorder={isSticker} curveTopLeft={curveTopLeft} curveBottomLeft={curveBottomLeft} attachment={attachments[0]} @@ -152,6 +165,7 @@ export class ImageGrid extends React.Component { alt={getAlt(attachments[2], i18n)} i18n={i18n} bottomOverlay={withBottomOverlay} + noBorder={isSticker} curveBottomRight={curveBottomRight} height={99} width={99} @@ -201,6 +215,7 @@ export class ImageGrid extends React.Component { alt={getAlt(attachments[2], i18n)} i18n={i18n} bottomOverlay={withBottomOverlay} + noBorder={isSticker} curveBottomLeft={curveBottomLeft} playIconOverlay={isVideoAttachment(attachments[2])} height={149} @@ -214,6 +229,7 @@ export class ImageGrid extends React.Component { alt={getAlt(attachments[3], i18n)} i18n={i18n} bottomOverlay={withBottomOverlay} + noBorder={isSticker} curveBottomRight={curveBottomRight} playIconOverlay={isVideoAttachment(attachments[3])} height={149} @@ -268,6 +284,7 @@ export class ImageGrid extends React.Component { alt={getAlt(attachments[2], i18n)} i18n={i18n} bottomOverlay={withBottomOverlay} + noBorder={isSticker} curveBottomLeft={curveBottomLeft} playIconOverlay={isVideoAttachment(attachments[2])} height={99} @@ -281,6 +298,7 @@ export class ImageGrid extends React.Component { alt={getAlt(attachments[3], i18n)} i18n={i18n} bottomOverlay={withBottomOverlay} + noBorder={isSticker} playIconOverlay={isVideoAttachment(attachments[3])} height={99} width={98} @@ -293,6 +311,7 @@ export class ImageGrid extends React.Component { alt={getAlt(attachments[4], i18n)} i18n={i18n} bottomOverlay={withBottomOverlay} + noBorder={isSticker} curveBottomRight={curveBottomRight} playIconOverlay={isVideoAttachment(attachments[4])} height={99} diff --git a/ts/components/conversation/Message.md b/ts/components/conversation/Message.md index aacf7c89c..578baa092 100644 --- a/ts/components/conversation/Message.md +++ b/ts/components/conversation/Message.md @@ -569,7 +569,7 @@ Note that timestamp and status can be hidden with the `collapseMetadata` boolean height: 240, }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} onDownload={() => console.log('onDownload')} onReply={() => console.log('onReply')} /> @@ -590,7 +590,7 @@ Note that timestamp and status can be hidden with the `collapseMetadata` boolean height: 240, }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} onDownload={() => console.log('onDownload')} onReply={() => console.log('onReply')} /> @@ -611,7 +611,7 @@ Note that timestamp and status can be hidden with the `collapseMetadata` boolean height: 240, }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} onDownload={() => console.log('onDownload')} onReply={() => console.log('onReply')} /> @@ -632,7 +632,7 @@ Note that timestamp and status can be hidden with the `collapseMetadata` boolean height: 240, }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} onDownload={() => console.log('onDownload')} onReply={() => console.log('onReply')} /> @@ -662,7 +662,7 @@ First, showing the metadata overlay on dark and light images, then a message wit height: 240, }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -682,7 +682,7 @@ First, showing the metadata overlay on dark and light images, then a message wit height: 240, }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -699,7 +699,7 @@ First, showing the metadata overlay on dark and light images, then a message wit height: 1200, }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -717,7 +717,7 @@ First, showing the metadata overlay on dark and light images, then a message wit height: 1200, }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -735,7 +735,7 @@ First, showing the metadata overlay on dark and light images, then a message wit height: 1200, }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -754,7 +754,306 @@ First, showing the metadata overlay on dark and light images, then a message wit height: 1200, }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} + /> +
  • + +``` + +#### Sticker + +Stickers have no background, but they have all the standard message bubble features. + +```jsx + +
  • + console.log('showVisualAttachment')} + /> +
  • +
  • + console.log('showVisualAttachment')} + /> +
  • +
  • + console.log('showVisualAttachment')} + /> +
  • +
  • + console.log('showVisualAttachment')} + /> +
  • +
  • + console.log('showVisualAttachment')} + /> +
  • +
  • + console.log('showVisualAttachment')} + /> +
  • +
  • + console.log('showVisualAttachment')} + /> +
  • +
    +``` + +#### Sticker with collapsed metadata + +First set is in a 1:1 conversation, second set is in a group. + +```jsx + +
  • + console.log('showVisualAttachment')} + /> +
  • +
  • + console.log('showVisualAttachment')} + /> +
  • +
  • + console.log('showVisualAttachment')} + /> +
  • +
  • + console.log('showVisualAttachment')} + /> +
  • +
    +``` + +#### Sticker with pending image + +A sticker with no attachments (what our selectors produce for a pending sticker) is not displayed at all. + +```jsx + +
  • + console.log('showVisualAttachment')} + /> +
  • +
  • + console.log('showVisualAttachment')} + /> +
  • +
  • + console.log('showVisualAttachment')} + /> +
  • +
  • + console.log('showVisualAttachment')} />
  • @@ -784,7 +1083,7 @@ First, showing the metadata overlay on dark and light images, then a message wit height: 240, }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -813,7 +1112,7 @@ First, showing the metadata overlay on dark and light images, then a message wit height: 240, }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -848,7 +1147,7 @@ First, showing the metadata overlay on dark and light images, then a message wit height: 240, }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -889,7 +1188,7 @@ First, showing the metadata overlay on dark and light images, then a message wit height: 240, }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -936,7 +1235,7 @@ First, showing the metadata overlay on dark and light images, then a message wit height: 240, }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -967,7 +1266,7 @@ First, showing the metadata overlay on dark and light images, then a message wit height: 240, }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -997,7 +1296,7 @@ First, showing the metadata overlay on dark and light images, then a message wit height: 240, }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -1033,7 +1332,7 @@ First, showing the metadata overlay on dark and light images, then a message wit height: 240, }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -1075,7 +1374,7 @@ First, showing the metadata overlay on dark and light images, then a message wit height: 240, }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -1123,7 +1422,7 @@ First, showing the metadata overlay on dark and light images, then a message wit height: 240, }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -1150,7 +1449,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co height: 1200, }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -1168,7 +1467,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co height: 1200, }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -1186,7 +1485,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co height: 1200, }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -1204,7 +1503,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co height: 1200, }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -1229,7 +1528,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co height: 240, }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -1248,7 +1547,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co height: 240, }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -1278,7 +1577,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co height: 240, }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -1309,7 +1608,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co height: 240, }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -1333,7 +1632,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co height: 200, }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -1351,7 +1650,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co height: 200, }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -1369,7 +1668,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co height: 200, }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -1387,7 +1686,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co height: 200, }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -1412,7 +1711,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co height: 200, }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -1431,7 +1730,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co height: 200, }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -1450,7 +1749,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co height: 200, }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -1470,7 +1769,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co height: 200, }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -1488,7 +1787,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co height: 200, }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} expirationLength={5 * 60 * 1000} expirationTimestamp={Date.now() + 5 * 60 * 1000} /> @@ -1509,7 +1808,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co height: 200, }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} expirationLength={5 * 60 * 1000} expirationTimestamp={Date.now() + 5 * 60 * 1000} /> @@ -1535,7 +1834,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co height: 50, }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -1553,7 +1852,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co height: 50, }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -1571,7 +1870,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co height: 50, }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -1589,7 +1888,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co height: 50, }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -1614,7 +1913,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co height: 50, }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -1633,7 +1932,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co height: 50, }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -1652,7 +1951,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co height: 50, }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -1671,7 +1970,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co height: 50, }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -1698,7 +1997,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co height: 240, }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -1719,7 +2018,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co height: 240, }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -1742,7 +2041,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co height: 1200, }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -1765,7 +2064,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co height: 1200, }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -1794,7 +2093,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co height: 1200, }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -1816,7 +2115,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co height: 1200, }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -1839,7 +2138,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co height: 1200, }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -1862,7 +2161,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co height: 1200, }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -1889,7 +2188,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co height: 240, }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -1909,7 +2208,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co height: 240, }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -1930,7 +2229,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co height: 240, }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -1951,7 +2250,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co height: 240, }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -1973,7 +2272,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co height: 240, }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -1995,7 +2294,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co height: 240, }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -2018,7 +2317,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co height: 240, }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -2041,7 +2340,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co height: 240, }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -2068,7 +2367,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co height: 240, }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -2089,7 +2388,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co height: 240, }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -2111,7 +2410,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co height: 240, }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -2133,7 +2432,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co height: 240, }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -2160,7 +2459,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co fileSize: '3.05 KB', }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -2180,7 +2479,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co fileSize: '3.05 KB', }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -2202,7 +2501,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co fileSize: '3.05 KB', }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -2224,7 +2523,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co fileSize: '3.05 KB', }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -2249,7 +2548,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co fileSize: '3.05 KB', }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -2268,7 +2567,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co fileSize: '3.05 KB', }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -2291,7 +2590,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co fileSize: '3.05 KB', }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -2311,7 +2610,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co fileSize: '3.05 KB', }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -2334,7 +2633,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co contentType: 'audio/mp3', }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -2351,7 +2650,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co contentType: 'audio/mp3', }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -2368,7 +2667,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co contentType: 'audio/mp3', }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -2385,7 +2684,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co contentType: 'audio/mp3', }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -2407,7 +2706,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co contentType: 'audio/mp3', }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -2423,7 +2722,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co contentType: 'audio/mp3', }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -2439,7 +2738,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co contentType: 'audio/mp3', }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -2455,7 +2754,7 @@ Note that the delivered indicator is always Signal Blue, not the conversation co contentType: 'audio/mp3', }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -2484,7 +2783,7 @@ Voice notes are not shown any differently from audio attachments. fileSize: '3.05 KB', }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -2503,7 +2802,7 @@ Voice notes are not shown any differently from audio attachments. fileSize: '3.05 KB', }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -2522,7 +2821,7 @@ Voice notes are not shown any differently from audio attachments. fileSize: '3.05 KB', }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -2541,7 +2840,7 @@ Voice notes are not shown any differently from audio attachments. fileSize: '3.05 KB', }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -2561,7 +2860,7 @@ Voice notes are not shown any differently from audio attachments. fileSize: '3.05 KB', }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -2580,7 +2879,7 @@ Voice notes are not shown any differently from audio attachments. fileSize: '3.05 KB', }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -2622,7 +2921,7 @@ Voice notes are not shown any differently from audio attachments. fileSize: '3.05 KB', }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -2640,7 +2939,7 @@ Voice notes are not shown any differently from audio attachments. fileSize: '3.05 KB', }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -2658,7 +2957,7 @@ Voice notes are not shown any differently from audio attachments. fileSize: '3.05 KB', }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -2676,7 +2975,7 @@ Voice notes are not shown any differently from audio attachments. fileSize: '3.05 KB', }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -2701,7 +3000,7 @@ Voice notes are not shown any differently from audio attachments. fileSize: '3.05 KB', }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -2720,7 +3019,7 @@ Voice notes are not shown any differently from audio attachments. fileSize: '3.05 KB', }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -2737,7 +3036,7 @@ Voice notes are not shown any differently from audio attachments. fileSize: '3.05 KB', }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -2754,7 +3053,7 @@ Voice notes are not shown any differently from audio attachments. fileSize: '3.05 KB', }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • @@ -2778,8 +3077,8 @@ Voice notes are not shown any differently from audio attachments. fileSize: '3.05 KB', }, ]} - onClickAttachment={isDangerous => - console.log('onClickAttachment - isDangerous:', isDangerous) + showVisualAttachment={isDangerous => + console.log('showVisualAttachment - isDangerous:', isDangerous) } /> @@ -2798,8 +3097,8 @@ Voice notes are not shown any differently from audio attachments. fileSize: '3.05 KB', }, ]} - onClickAttachment={isDangerous => - console.log('onClickAttachment - isDangerous:', isDangerous) + showVisualAttachment={isDangerous => + console.log('showVisualAttachment - isDangerous:', isDangerous) } /> @@ -2915,6 +3214,62 @@ Voice notes are not shown any differently from audio attachments. ``` +#### Link previews, stickers url + +Sticker link previews are forced to use the small link preview form, no matter the image size. + +```jsx + +
  • + console.log('onClickLinkPreview', url)} + /> +
  • +
  • + console.log('onClickLinkPreview', url)} + /> +
  • +
    +``` + #### Link previews, small image ```jsx @@ -3228,6 +3583,19 @@ Note that the author avatar goes away if `collapseMetadata` is set. authorAvatarPath={util.gifObjectUrl} /> +
  • + +
  • console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} authorAvatarPath={util.gifObjectUrl} />
  • @@ -3309,7 +3677,7 @@ Note that the author avatar goes away if `collapseMetadata` is set. height: 1200, }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} authorAvatarPath={util.gifObjectUrl} /> @@ -3327,7 +3695,7 @@ Note that the author avatar goes away if `collapseMetadata` is set. contentType: 'audio/mp3', }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} authorAvatarPath={util.gifObjectUrl} /> @@ -3348,7 +3716,7 @@ Note that the author avatar goes away if `collapseMetadata` is set. fileSize: '3.05 KB', }, ]} - onClickAttachment={() => console.log('onClickAttachment')} + showVisualAttachment={() => console.log('showVisualAttachment')} />
  • diff --git a/ts/components/conversation/Message.tsx b/ts/components/conversation/Message.tsx index 62314d963..e140c8470 100644 --- a/ts/components/conversation/Message.tsx +++ b/ts/components/conversation/Message.tsx @@ -39,11 +39,13 @@ interface Trigger { // Same as MIN_WIDTH in ImageGrid.tsx const MINIMUM_LINK_PREVIEW_IMAGE_WIDTH = 200; +const STICKER_SIZE = 128; interface LinkPreviewType { title: string; domain: string; url: string; + isStickerPack: boolean; image?: AttachmentType; } @@ -51,6 +53,7 @@ export type PropsData = { id: string; text?: string; textPending?: boolean; + isSticker: boolean; direction: 'incoming' | 'outgoing'; timestamp: number; status?: 'sending' | 'sent' | 'delivered' | 'read' | 'error'; @@ -223,6 +226,7 @@ export class Message extends React.PureComponent { expirationLength, expirationTimestamp, i18n, + isSticker, status, text, textPending, @@ -234,8 +238,9 @@ export class Message extends React.PureComponent { } const isShowingImage = this.isShowingImage(); - const withImageNoCaption = Boolean(!text && isShowingImage); + const withImageNoCaption = Boolean(!isSticker && !text && isShowingImage); const showError = status === 'error' && direction === 'outgoing'; + const metadataDirection = isSticker ? undefined : direction; return (
    { { i18n={i18n} timestamp={timestamp} extended={true} - direction={direction} + direction={metadataDirection} withImageNoCaption={withImageNoCaption} + withSticker={isSticker} module="module-message__metadata__date" /> )} {expirationLength && expirationTimestamp ? ( ) : null} @@ -287,6 +297,9 @@ export class Message extends React.PureComponent { className={classNames( 'module-message__metadata__status-icon', `module-message__metadata__status-icon--${status}`, + isSticker + ? 'module-message__metadata__status-icon--with-sticker' + : null, withImageNoCaption ? 'module-message__metadata__status-icon--with-image-no-caption' : null @@ -302,24 +315,33 @@ export class Message extends React.PureComponent { authorName, authorPhoneNumber, authorProfileName, + collapseMetadata, conversationType, direction, i18n, + isSticker, } = this.props; + if (collapseMetadata) { + return; + } + const title = authorName ? authorName : authorPhoneNumber; if (direction !== 'incoming' || conversationType !== 'group' || !title) { return null; } + const suffix = isSticker ? '_with_sticker' : ''; + const moduleName = `module-message__author${suffix}`; + return ( -
    +
    @@ -329,15 +351,16 @@ export class Message extends React.PureComponent { // tslint:disable-next-line max-func-body-length cyclomatic-complexity public renderAttachment() { const { - id, attachments, - text, collapseMetadata, conversationType, direction, i18n, + id, quote, showVisualAttachment, + isSticker, + text, } = this.props; const { imageBroken } = this.state; @@ -359,23 +382,31 @@ export class Message extends React.PureComponent { ((isImage(attachments) && hasImage(attachments)) || (isVideo(attachments) && hasVideoScreenshot(attachments))) ) { + const prefix = isSticker ? 'sticker' : 'attachment'; + const bottomOverlay = !isSticker && !collapseMetadata; + return (
    { @@ -494,7 +525,10 @@ export class Message extends React.PureComponent { const previewHasImage = first.image && isImageAttachment(first.image); const width = first.image && first.image.width; - const isFullSizeImage = width && width >= MINIMUM_LINK_PREVIEW_IMAGE_WIDTH; + const isFullSizeImage = + !first.isStickerPack && + width && + width >= MINIMUM_LINK_PREVIEW_IMAGE_WIDTH; return (
    { disableMenu, downloadAttachment, id, + isSticker, replyToMessage, timestamp, } = this.props; @@ -783,7 +818,10 @@ export class Message extends React.PureComponent { const firstAttachment = attachments && attachments[0]; const downloadButton = - !multipleAttachments && firstAttachment && !firstAttachment.pending ? ( + !isSticker && + !multipleAttachments && + firstAttachment && + !firstAttachment.pending ? (
    { downloadAttachment({ @@ -850,6 +888,7 @@ export class Message extends React.PureComponent { downloadAttachment, i18n, id, + isSticker, deleteMessage, showMessageDetail, replyToMessage, @@ -866,7 +905,7 @@ export class Message extends React.PureComponent { const menu = ( - {!multipleAttachments && attachments && attachments[0] ? ( + {!isSticker && !multipleAttachments && attachments && attachments[0] ? ( { } public getWidth(): number | undefined { - const { attachments, previews } = this.props; + const { attachments, isSticker, previews } = this.props; if (attachments && attachments.length) { + if (isSticker) { + // Padding is 8px, on both sides + return STICKER_SIZE + 8 * 2; + } + const dimensions = getGridDimensions(attachments); if (dimensions) { return dimensions.width; @@ -949,6 +993,7 @@ export class Message extends React.PureComponent { const { width } = first.image; if ( + !first.isStickerPack && isImageAttachment(first.image) && width && width >= MINIMUM_LINK_PREVIEW_IMAGE_WIDTH @@ -999,11 +1044,13 @@ export class Message extends React.PureComponent { const { authorPhoneNumber, authorColor, + attachments, direction, id, + isSticker, timestamp, } = this.props; - const { expired, expiring } = this.state; + const { expired, expiring, imageBroken } = this.state; // This id is what connects our triple-dot click with our associated pop-up menu. // It needs to be unique. @@ -1013,6 +1060,10 @@ export class Message extends React.PureComponent { return null; } + if (isSticker && (imageBroken || !attachments || !attachments.length)) { + return null; + } + const width = this.getWidth(); const isShowingImage = this.isShowingImage(); @@ -1029,8 +1080,9 @@ export class Message extends React.PureComponent {
    { module, timestamp, withImageNoCaption, + withSticker, extended, } = this.props; const moduleName = module || 'module-timestamp'; @@ -61,7 +63,8 @@ export class Timestamp extends React.Component { className={classNames( moduleName, direction ? `${moduleName}--${direction}` : null, - withImageNoCaption ? `${moduleName}--with-image-no-caption` : null + withImageNoCaption ? `${moduleName}--with-image-no-caption` : null, + withSticker ? `${moduleName}--with-sticker` : null )} title={moment(timestamp).format('llll')} > diff --git a/ts/components/stickers/StickerButton.md b/ts/components/stickers/StickerButton.md new file mode 100644 index 000000000..cf5b1badc --- /dev/null +++ b/ts/components/stickers/StickerButton.md @@ -0,0 +1,271 @@ +#### Default + +```jsx +const abeSticker = { id: 4, url: util.squareStickerObjectUrl, packId: 'abe' }; +const sticker1 = { id: 1, url: util.kitten164ObjectUrl, packId: 'foo' }; +const sticker2 = { id: 2, url: util.kitten264ObjectUrl, packId: 'bar' }; +const sticker3 = { id: 3, url: util.kitten364ObjectUrl, packId: 'baz' }; + +const packs = [ + { + id: 'foo', + cover: sticker1, + stickers: Array(101) + .fill(0) + .map((n, id) => ({ ...sticker1, id })), + }, + { + id: 'bar', + cover: sticker2, + stickers: Array(101) + .fill(0) + .map((n, id) => ({ ...sticker2, id })), + }, + { + id: 'baz', + cover: sticker3, + stickers: Array(101) + .fill(0) + .map((n, id) => ({ ...sticker3, id })), + }, +]; + + +
    + + console.log('onPickSticker', { packId, stickerId }) + } + clearInstalledStickerPack={() => console.log('clearInstalledStickerPack')} + onClickAddPack={() => console.log('onClickAddPack')} + recentStickers={[abeSticker, sticker1, sticker2, sticker3]} + /> +
    +
    ; +``` + +#### No Installed Packs + +When there are no installed packs the button should call the `onClickAddPack` +callback. + +```jsx +const abeSticker = { id: 4, url: util.squareStickerObjectUrl, packId: 'abe' }; +const sticker1 = { id: 1, url: util.kitten164ObjectUrl, packId: 'foo' }; +const sticker2 = { id: 2, url: util.kitten264ObjectUrl, packId: 'bar' }; +const sticker3 = { id: 3, url: util.kitten364ObjectUrl, packId: 'baz' }; + +const packs = [ + { + id: 'foo', + cover: sticker1, + stickers: Array(101) + .fill(0) + .map((n, id) => ({ ...sticker1, id })), + }, + { + id: 'bar', + cover: sticker2, + stickers: Array(101) + .fill(0) + .map((n, id) => ({ ...sticker2, id })), + }, + { + id: 'baz', + cover: sticker3, + stickers: Array(101) + .fill(0) + .map((n, id) => ({ ...sticker3, id })), + }, +]; + + +
    + + console.log('onPickSticker', { packId, stickerId }) + } + clearInstalledStickerPack={() => console.log('clearInstalledStickerPack')} + onClickAddPack={() => console.log('onClickAddPack')} + recentStickers={[abeSticker, sticker1, sticker2, sticker3]} + /> +
    +
    ; +``` + +#### No Advertised Packs and No Installed Packs + +When there are no advertised packs and no installed packs the button should not render anything. + +```jsx + + + console.log('onPickSticker', { packId, stickerId }) + } + clearInstalledStickerPack={() => console.log('clearInstalledStickerPack')} + onClickAddPack={() => console.log('onClickAddPack')} + recentStickers={[]} + /> + +``` + +#### Installed Pack Tooltip + +When a pack is installed there should be a tooltip saying as such. + +```jsx +const abeSticker = { id: 4, url: util.squareStickerObjectUrl, packId: 'abe' }; +const sticker1 = { id: 1, url: util.kitten164ObjectUrl, packId: 'foo' }; +const sticker2 = { id: 2, url: util.kitten264ObjectUrl, packId: 'bar' }; +const sticker3 = { id: 3, url: util.kitten364ObjectUrl, packId: 'baz' }; + +const packs = [ + { + id: 'foo', + title: 'Abe', + cover: abeSticker, + stickers: Array(101) + .fill(0) + .map((n, id) => ({ ...abeSticker, id })), + }, + { + id: 'bar', + cover: sticker1, + stickers: Array(101) + .fill(0) + .map((n, id) => ({ ...sticker1, id })), + }, + { + id: 'baz', + cover: sticker2, + stickers: Array(101) + .fill(0) + .map((n, id) => ({ ...sticker2, id })), + }, + { + id: 'qux', + cover: sticker3, + stickers: Array(101) + .fill(0) + .map((n, id) => ({ ...sticker3, id })), + }, +]; + + +
    + + console.log('onPickSticker', { packId, stickerId }) + } + clearInstalledStickerPack={() => console.log('clearInstalledStickerPack')} + onClickAddPack={() => console.log('onClickAddPack')} + recentStickers={[]} + /> +
    +
    ; +``` + +#### New Installation Splash Tooltip + +When the application is updated or freshly installed there should be a tooltip +showing the user the sticker button. + +```jsx +const abeSticker = { id: 4, url: util.squareStickerObjectUrl, packId: 'abe' }; +const sticker1 = { id: 1, url: util.kitten164ObjectUrl, packId: 'foo' }; +const sticker2 = { id: 2, url: util.kitten264ObjectUrl, packId: 'bar' }; +const sticker3 = { id: 3, url: util.kitten364ObjectUrl, packId: 'baz' }; + +const packs = [ + { + id: 'foo', + title: 'Abe', + cover: abeSticker, + stickers: Array(101) + .fill(0) + .map((n, id) => ({ ...abeSticker, id })), + }, + { + id: 'bar', + cover: sticker1, + stickers: Array(101) + .fill(0) + .map((n, id) => ({ ...sticker1, id })), + }, + { + id: 'baz', + cover: sticker2, + stickers: Array(101) + .fill(0) + .map((n, id) => ({ ...sticker2, id })), + }, + { + id: 'qux', + cover: sticker3, + stickers: Array(101) + .fill(0) + .map((n, id) => ({ ...sticker3, id })), + }, +]; + + +
    + + console.log('onPickSticker', { packId, stickerId }) + } + clearInstalledStickerPack={() => console.log('clearInstalledStickerPack')} + onClickAddPack={() => console.log('onClickAddPack')} + recentStickers={[]} + showIntroduction + clearShowIntroduction={() => console.log('clearShowIntroduction')} + /> +
    +
    ; +``` diff --git a/ts/components/stickers/StickerButton.tsx b/ts/components/stickers/StickerButton.tsx new file mode 100644 index 000000000..89aaf9936 --- /dev/null +++ b/ts/components/stickers/StickerButton.tsx @@ -0,0 +1,255 @@ +import * as React from 'react'; +import classNames from 'classnames'; +import { noop } from 'lodash'; +import { Manager, Popper, Reference } from 'react-popper'; +import { createPortal } from 'react-dom'; +import { StickerPicker } from './StickerPicker'; +import { StickerPackType, StickerType } from '../../state/ducks/stickers'; +import { LocalizerType } from '../../types/Util'; + +export type OwnProps = { + readonly i18n: LocalizerType; + readonly receivedPacks: ReadonlyArray; + readonly installedPacks: ReadonlyArray; + readonly installedPack?: StickerPackType | null; + readonly recentStickers: ReadonlyArray; + readonly clearInstalledStickerPack: () => unknown; + readonly onClickAddPack: () => unknown; + readonly onPickSticker: (packId: string, stickerId: number) => unknown; + readonly showIntroduction?: boolean; + readonly clearShowIntroduction: () => unknown; + readonly showPickerHint: boolean; + readonly clearShowPickerHint: () => unknown; +}; + +export type Props = OwnProps; + +export const StickerButton = React.memo( + // tslint:disable-next-line max-func-body-length + ({ + i18n, + clearInstalledStickerPack, + onClickAddPack, + onPickSticker, + recentStickers, + receivedPacks, + installedPack, + installedPacks, + showIntroduction, + clearShowIntroduction, + showPickerHint, + clearShowPickerHint, + }: Props) => { + const [open, setOpen] = React.useState(false); + const [popperRoot, setPopperRoot] = React.useState( + null + ); + + const handleClickButton = React.useCallback( + () => { + // Clear tooltip state + clearInstalledStickerPack(); + + // Handle button click + if (installedPacks.length === 0) { + onClickAddPack(); + } else if (popperRoot) { + setOpen(false); + } else { + setOpen(true); + } + }, + [ + clearInstalledStickerPack, + onClickAddPack, + installedPacks, + popperRoot, + setOpen, + ] + ); + + const handlePickSticker = React.useCallback( + (packId: string, stickerId: number) => { + setOpen(false); + onPickSticker(packId, stickerId); + }, + [setOpen, onPickSticker] + ); + + const handleClickAddPack = React.useCallback( + () => { + setOpen(false); + if (showPickerHint) { + clearShowPickerHint(); + } + onClickAddPack(); + }, + [onClickAddPack, showPickerHint, clearShowPickerHint] + ); + + const handleClearIntroduction = React.useCallback( + () => { + clearInstalledStickerPack(); + clearShowIntroduction(); + }, + [clearInstalledStickerPack, clearShowIntroduction] + ); + + // Create popper root and handle outside clicks + React.useEffect( + () => { + if (open) { + const root = document.createElement('div'); + setPopperRoot(root); + document.body.appendChild(root); + const handleOutsideClick = ({ target }: MouseEvent) => { + if (!root.contains(target as Node)) { + setOpen(false); + } + }; + document.addEventListener('click', handleOutsideClick); + + return () => { + document.body.removeChild(root); + document.removeEventListener('click', handleOutsideClick); + setPopperRoot(null); + }; + } + + return noop; + }, + [open, setOpen, setPopperRoot] + ); + + // Clear the installed pack after one minute + React.useEffect( + () => { + if (installedPack) { + // tslint:disable-next-line:no-string-based-set-timeout + const timerId = setTimeout(clearInstalledStickerPack, 60 * 1000); + + return () => { + clearTimeout(timerId); + }; + } + + return noop; + }, + [installedPack, clearInstalledStickerPack] + ); + + if (installedPacks.length + receivedPacks.length === 0) { + return null; + } + + return ( + + + {({ ref }) => ( +
    +
    +
    + )} + + ) : null} + {open && popperRoot + ? createPortal( + + {({ ref, style }) => ( + + )} + , + popperRoot + ) + : null} + + ); + } +); diff --git a/ts/components/stickers/StickerManager.md b/ts/components/stickers/StickerManager.md new file mode 100644 index 000000000..719f0dca4 --- /dev/null +++ b/ts/components/stickers/StickerManager.md @@ -0,0 +1,178 @@ +#### Default + +```jsx +const sticker1 = { id: 1, url: util.kitten164ObjectUrl, packId: 'foo' }; +const sticker2 = { id: 2, url: util.kitten264ObjectUrl, packId: 'bar' }; +const sticker3 = { id: 3, url: util.kitten364ObjectUrl, packId: 'baz' }; + +const packs = [ + { + id: 'foo', + cover: sticker1, + title: 'Foo', + author: 'Foo McBarrington', + stickers: Array(101) + .fill(0) + .map((n, id) => ({ ...sticker1, id })), + }, + { + id: 'bar', + cover: sticker2, + title: 'Baz', + author: 'Foo McBarrington (Official)', + stickers: Array(101) + .fill(0) + .map((n, id) => ({ ...sticker2, id })), + }, + { + id: 'baz', + cover: sticker3, + title: 'Third', + author: 'Foo McBarrington', + stickers: Array(101) + .fill(0) + .map((n, id) => ({ ...sticker3, id })), + }, +]; + +const receivedPacks = packs.map(p => ({ ...p, status: 'advertised' })); +const installedPacks = packs.map(p => ({ ...p, status: 'installed' })); +const blessedPacks = packs.map(p => ({ + ...p, + status: 'advertised', + isBlessed: true, +})); + + + console.log('installStickerPack', id)} + uninstallStickerPack={id => console.log('uninstallStickerPack', id)} + /> +; +``` + +#### No Advertised Packs + +```jsx +const sticker1 = { id: 1, url: util.kitten164ObjectUrl, packId: 'foo' }; +const sticker2 = { id: 2, url: util.kitten264ObjectUrl, packId: 'bar' }; +const sticker3 = { id: 3, url: util.kitten364ObjectUrl, packId: 'baz' }; + +const packs = [ + { + id: 'foo', + cover: sticker1, + title: 'Foo', + author: 'Foo McBarrington', + stickers: Array(101) + .fill(0) + .map((n, id) => ({ ...sticker1, id })), + }, + { + id: 'bar', + cover: sticker2, + title: 'Baz', + author: 'Foo McBarrington', + stickers: Array(101) + .fill(0) + .map((n, id) => ({ ...sticker2, id })), + }, + { + id: 'baz', + cover: sticker3, + title: 'Baz', + author: 'Foo McBarrington', + stickers: Array(101) + .fill(0) + .map((n, id) => ({ ...sticker3, id })), + }, +]; + +const installedPacks = packs.map(p => ({ ...p, status: 'installed' })); +const noPacks = []; + + + console.log('installStickerPack', id)} + uninstallStickerPack={id => console.log('uninstallStickerPack', id)} + /> +; +``` + +#### No Installed Packs + +```jsx +const sticker1 = { id: 1, url: util.kitten164ObjectUrl, packId: 'foo' }; +const sticker2 = { id: 2, url: util.kitten264ObjectUrl, packId: 'bar' }; +const sticker3 = { id: 3, url: util.kitten364ObjectUrl, packId: 'baz' }; + +const packs = [ + { + id: 'foo', + cover: sticker1, + title: 'Foo', + author: 'Foo McBarrington', + stickers: Array(101) + .fill(0) + .map((n, id) => ({ ...sticker1, id })), + }, + { + id: 'bar', + cover: sticker2, + title: 'Baz', + author: 'Foo McBarrington', + stickers: Array(101) + .fill(0) + .map((n, id) => ({ ...sticker2, id })), + }, + { + id: 'baz', + cover: sticker3, + title: 'Baz', + author: 'Foo McBarrington', + stickers: Array(101) + .fill(0) + .map((n, id) => ({ ...sticker3, id })), + }, +]; + +const receivedPacks = packs.map(p => ({ ...p, status: 'installed' })); +const noPacks = []; + + + console.log('installStickerPack', id)} + /> +; +``` + +#### No Packs at All + +```jsx +const noPacks = []; + + +
    + console.log('installStickerPack', id)} + uninstallStickerPack={id => console.log('uninstallStickerPack', id)} + /> +
    +
    ; +``` diff --git a/ts/components/stickers/StickerManager.tsx b/ts/components/stickers/StickerManager.tsx new file mode 100644 index 000000000..8450a9533 --- /dev/null +++ b/ts/components/stickers/StickerManager.tsx @@ -0,0 +1,107 @@ +import * as React from 'react'; +import classNames from 'classnames'; +import { StickerManagerPackRow } from './StickerManagerPackRow'; +import { StickerPreviewModal } from './StickerPreviewModal'; +import { LocalizerType } from '../../types/Util'; +import { StickerPackType } from '../../state/ducks/stickers'; + +export type OwnProps = { + readonly installedPacks: ReadonlyArray; + readonly receivedPacks: ReadonlyArray; + readonly blessedPacks: ReadonlyArray; + readonly installStickerPack: (packId: string, packKey: string) => unknown; + readonly uninstallStickerPack: (packId: string, packKey: string) => unknown; + readonly i18n: LocalizerType; +}; + +export type Props = OwnProps; + +export const StickerManager = React.memo( + ({ + installedPacks, + receivedPacks, + blessedPacks, + installStickerPack, + uninstallStickerPack, + i18n, + }: Props) => { + const [ + packToPreview, + setPackToPreview, + ] = React.useState(null); + + const clearPackToPreview = React.useCallback( + () => { + setPackToPreview(null); + }, + [setPackToPreview] + ); + + const previewPack = React.useCallback( + (pack: StickerPackType) => { + setPackToPreview(pack); + }, + [clearPackToPreview] + ); + + return ( + <> + {packToPreview ? ( + + ) : null} +
    + {[ + { + i18nKey: 'stickers--StickerManager--InstalledPacks', + i18nEmptyKey: 'stickers--StickerManager--InstalledPacks--Empty', + packs: installedPacks, + }, + { + i18nKey: 'stickers--StickerManager--BlessedPacks', + i18nEmptyKey: 'stickers--StickerManager--BlessedPacks--Empty', + packs: blessedPacks, + }, + { + i18nKey: 'stickers--StickerManager--ReceivedPacks', + i18nEmptyKey: 'stickers--StickerManager--ReceivedPacks--Empty', + packs: receivedPacks, + }, + ].map(section => ( + +

    + {i18n(section.i18nKey)} +

    + {section.packs.length > 0 ? ( + section.packs.map(pack => ( + + )) + ) : ( +
    + {i18n(section.i18nEmptyKey)} +
    + )} +
    + ))} +
    + + ); + } +); diff --git a/ts/components/stickers/StickerManagerPackRow.tsx b/ts/components/stickers/StickerManagerPackRow.tsx new file mode 100644 index 000000000..536d6cfda --- /dev/null +++ b/ts/components/stickers/StickerManagerPackRow.tsx @@ -0,0 +1,129 @@ +import * as React from 'react'; +import { StickerPackInstallButton } from './StickerPackInstallButton'; +import { ConfirmationModal } from '../ConfirmationModal'; +import { LocalizerType } from '../../types/Util'; +import { StickerPackType } from '../../state/ducks/stickers'; + +export type OwnProps = { + readonly i18n: LocalizerType; + readonly pack: StickerPackType; + readonly onClickPreview?: (sticker: StickerPackType) => unknown; + readonly installStickerPack?: (packId: string, packKey: string) => unknown; + readonly uninstallStickerPack?: (packId: string, packKey: string) => unknown; +}; + +export type Props = OwnProps; + +export const StickerManagerPackRow = React.memo( + // tslint:disable-next-line max-func-body-length + ({ + installStickerPack, + uninstallStickerPack, + onClickPreview, + pack, + i18n, + }: Props) => { + const { id, key, isBlessed } = pack; + const [uninstalling, setUninstalling] = React.useState(false); + + const clearUninstalling = React.useCallback( + () => { + setUninstalling(false); + }, + [setUninstalling] + ); + + const handleInstall = React.useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + if (installStickerPack) { + installStickerPack(id, key); + } + }, + [installStickerPack, pack] + ); + + const handleUninstall = React.useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + if (isBlessed && uninstallStickerPack) { + uninstallStickerPack(id, key); + } else { + setUninstalling(true); + } + }, + [setUninstalling, id, key, isBlessed] + ); + + const handleConfirmUninstall = React.useCallback( + () => { + clearUninstalling(); + if (uninstallStickerPack) { + uninstallStickerPack(id, key); + } + }, + [id, key, clearUninstalling] + ); + + const handleClickPreview = React.useCallback( + () => { + if (onClickPreview) { + onClickPreview(pack); + } + }, + [onClickPreview, pack] + ); + + return ( + <> + {uninstalling ? ( + + {i18n('stickers--StickerManager--UninstallWarning')} + + ) : null} +
    + {pack.title} +
    +
    + {pack.title} + {pack.isBlessed ? ( + + ) : null} +
    +
    + {pack.author} +
    +
    +
    + {pack.status === 'advertised' ? ( + + ) : ( + + )} +
    +
    + + ); + } +); diff --git a/ts/components/stickers/StickerPackInstallButton.tsx b/ts/components/stickers/StickerPackInstallButton.tsx new file mode 100644 index 000000000..ea19b030d --- /dev/null +++ b/ts/components/stickers/StickerPackInstallButton.tsx @@ -0,0 +1,29 @@ +import * as React from 'react'; +import classNames from 'classnames'; +import { LocalizerType } from '../../types/Util'; + +export type OwnProps = { + readonly installed: boolean; + readonly i18n: LocalizerType; + readonly blue?: boolean; +}; + +export type Props = OwnProps & React.HTMLProps; + +export const StickerPackInstallButton = React.forwardRef< + HTMLButtonElement, + Props +>(({ i18n, installed, blue, ...props }: Props, ref) => ( + +)); diff --git a/ts/components/stickers/StickerPicker.md b/ts/components/stickers/StickerPicker.md new file mode 100644 index 000000000..bd93ecb8d --- /dev/null +++ b/ts/components/stickers/StickerPicker.md @@ -0,0 +1,321 @@ +#### Default + +```jsx +const abeSticker = { id: 4, url: util.squareStickerObjectUrl, packId: 'abe' }; +const sticker1 = { id: 1, url: util.kitten164ObjectUrl, packId: 'foo' }; +const sticker2 = { id: 2, url: util.kitten264ObjectUrl, packId: 'bar' }; +const sticker3 = { id: 3, url: util.kitten364ObjectUrl, packId: 'baz' }; + +const packs = [ + { + id: 'foo', + cover: sticker1, + stickerCount: 101, + stickers: Array(101) + .fill(0) + .map((n, id) => ({ ...sticker1, id })), + }, + { + id: 'bar', + cover: sticker2, + stickerCount: 101, + stickers: Array(101) + .fill(0) + .map((n, id) => ({ ...sticker2, id })), + }, + { + id: 'baz', + cover: sticker3, + stickerCount: 101, + stickers: Array(101) + .fill(0) + .map((n, id) => ({ ...sticker3, id })), + }, + { + id: 'qux', + cover: sticker2, + stickerCount: 101, + stickers: Array(101) + .fill(0) + .map((n, id) => ({ ...sticker2, id })), + }, + { + id: 'quux', + cover: sticker3, + stickerCount: 101, + stickers: Array(101) + .fill(0) + .map((n, id) => ({ ...sticker1, id })), + }, + { + id: 'corge', + cover: sticker2, + stickerCount: 101, + stickers: Array(101) + .fill(0) + .map((n, id) => ({ ...sticker2, id })), + }, + { + id: 'grault', + cover: sticker1, + stickerCount: 101, + stickers: Array(101) + .fill(0) + .map((n, id) => ({ ...sticker1, id })), + }, + { + id: 'garply', + cover: sticker2, + stickerCount: 101, + stickers: Array(101) + .fill(0) + .map((n, id) => ({ ...sticker2, id })), + }, + { + id: 'waldo', + cover: sticker3, + stickerCount: 101, + stickers: Array(101) + .fill(0) + .map((n, id) => ({ ...sticker3, id })), + }, + { + id: 'fred', + cover: sticker2, + stickerCount: 101, + stickers: Array(101) + .fill(0) + .map((n, id) => ({ ...sticker2, id })), + }, + { + id: 'plugh', + cover: sticker1, + stickerCount: 101, + stickers: Array(101) + .fill(0) + .map((n, id) => ({ ...sticker1, id })), + }, + { + id: 'xyzzy', + cover: sticker2, + stickerCount: 101, + stickers: Array(101) + .fill(0) + .map((n, id) => ({ ...sticker2, id })), + }, + { + id: 'thud', + cover: abeSticker, + stickerCount: 101, + stickers: Array(101) + .fill(0) + .map((n, id) => ({ ...abeSticker, id })), + }, + { + id: 'banana', + cover: sticker2, + stickerCount: 101, + stickers: Array(101) + .fill(0) + .map((n, id) => ({ ...sticker2, id })), + }, + { + id: 'apple', + cover: sticker1, + stickerCount: 101, + stickers: Array(101) + .fill(0) + .map((n, id) => ({ ...sticker1, id })), + }, + { + id: 'strawberry', + cover: sticker2, + stickerCount: 101, + stickers: Array(101) + .fill(0) + .map((n, id) => ({ ...sticker2, id })), + }, + { + id: 'tombrady', + cover: abeSticker, + stickerCount: 101, + stickers: Array(101) + .fill(0) + .map((n, id) => ({ ...abeSticker, id })), + }, +]; + + + console.log('onClickAddPack')} + onPickSticker={(packId, stickerId) => + console.log('onPickSticker', { packId, stickerId }) + } + /> +; +``` + +#### No Recently Used Stickers + +The sticker picker defaults to the first pack when there are no recent stickers. + +```jsx +const abeSticker = { id: 4, url: util.squareStickerObjectUrl, packId: 'abe' }; +const sticker1 = { id: 1, url: util.kitten164ObjectUrl, packId: 'foo' }; +const sticker2 = { id: 2, url: util.kitten264ObjectUrl, packId: 'bar' }; +const sticker3 = { id: 3, url: util.kitten364ObjectUrl, packId: 'baz' }; + +const packs = [ + { + id: 'foo', + cover: sticker1, + stickerCount: 101, + stickers: Array(101) + .fill(0) + .map((n, id) => ({ ...sticker1, id })), + }, + { + id: 'bar', + cover: sticker2, + stickerCount: 101, + stickers: Array(101) + .fill(0) + .map((n, id) => ({ ...sticker2, id })), + }, + { + id: 'baz', + cover: sticker3, + stickerCount: 101, + stickers: Array(101) + .fill(0) + .map((n, id) => ({ ...sticker3, id })), + }, +]; + + + console.log('onClickAddPack')} + onPickSticker={(packId, stickerId) => + console.log('onPickSticker', { packId, stickerId }) + } + /> +; +``` + +#### Empty + +```jsx + + console.log('onClickAddPack')} + onPickSticker={(packId, stickerId) => + console.log('onPickSticker', { packId, stickerId }) + } + /> + +``` + +#### Pending Download + +```jsx +const abeSticker = { id: 4, url: util.squareStickerObjectUrl, packId: 'abe' }; +const packs = [ + { + id: 'tombrady', + status: 'pending', + cover: abeSticker, + stickerCount: 30, + stickers: [abeSticker], + }, +]; + + + console.log('onClickAddPack')} + onPickSticker={(packId, stickerId) => + console.log('onPickSticker', { packId, stickerId }) + } + /> +; +``` + +#### Picker Hint + +```jsx +const abeSticker = { id: 4, url: util.squareStickerObjectUrl, packId: 'abe' }; +const packs = [ + { + id: 'tombrady', + cover: abeSticker, + stickerCount: 100, + stickers: Array(100) + .fill(0) + .map((_el, i) => ({ ...abeSticker, id: i })), + }, +]; + + + console.log('onClickAddPack')} + onPickSticker={(packId, stickerId) => + console.log('onPickSticker', { packId, stickerId }) + } + showPickerHint={true} + /> +; +``` + +#### Pack With Error + +```jsx +const abeSticker = { id: 4, url: util.squareStickerObjectUrl, packId: 'abe' }; +const packs = [ + { + id: 'tombrady', + status: 'error', + cover: abeSticker, + stickerCount: 3, + stickers: [], + }, + { + id: 'foo', + status: 'error', + cover: abeSticker, + stickerCount: 3, + stickers: [abeSticker], + }, +]; + + + console.log('onClickAddPack')} + onPickSticker={(packId, stickerId) => + console.log('onPickSticker', { packId, stickerId }) + } + /> +; +``` diff --git a/ts/components/stickers/StickerPicker.tsx b/ts/components/stickers/StickerPicker.tsx new file mode 100644 index 000000000..abc92860d --- /dev/null +++ b/ts/components/stickers/StickerPicker.tsx @@ -0,0 +1,282 @@ +/* tslint:disable:max-func-body-length */ +/* tslint:disable:cyclomatic-complexity */ +import * as React from 'react'; +import classNames from 'classnames'; +import { StickerPackType, StickerType } from '../../state/ducks/stickers'; +import { LocalizerType } from '../../types/Util'; + +export type OwnProps = { + readonly i18n: LocalizerType; + readonly onClickAddPack: () => unknown; + readonly onPickSticker: (packId: string, stickerId: number) => unknown; + readonly packs: ReadonlyArray; + readonly recentStickers: ReadonlyArray; + readonly showPickerHint?: boolean; +}; + +export type Props = OwnProps & Pick, 'style'>; + +function useTabs(tabs: ReadonlyArray, initialTab = tabs[0]) { + const [tab, setTab] = React.useState(initialTab); + const handlers = React.useMemo( + () => + tabs.map(t => () => { + setTab(t); + }), + tabs + ); + + return [tab, handlers] as [T, ReadonlyArray<() => void>]; +} + +const PACKS_PAGE_SIZE = 7; +const PACK_ICON_WIDTH = 32; +const PACK_PAGE_WIDTH = PACKS_PAGE_SIZE * PACK_ICON_WIDTH; + +function getPacksPageOffset(page: number, packs: number): number { + if (page === 0) { + return 0; + } + + if (isLastPacksPage(page, packs)) { + return ( + PACK_PAGE_WIDTH * (Math.floor(packs / PACKS_PAGE_SIZE) - 1) + + (packs % PACKS_PAGE_SIZE - 1) * PACK_ICON_WIDTH + ); + } + + return page * PACK_ICON_WIDTH * PACKS_PAGE_SIZE; +} + +function isLastPacksPage(page: number, packs: number): boolean { + return page === Math.floor(packs / PACKS_PAGE_SIZE); +} + +export const StickerPicker = React.memo( + React.forwardRef( + ( + { + i18n, + packs, + recentStickers, + onClickAddPack, + onPickSticker, + showPickerHint, + style, + }: Props, + ref + ) => { + const tabIds = React.useMemo( + () => ['recents', ...packs.map(({ id }) => id)], + packs + ); + const [currentTab, [recentsHandler, ...packsHandlers]] = useTabs( + tabIds, + // If there are no recent stickers, default to the first sticker pack, unless there are no sticker packs. + tabIds[recentStickers.length > 0 ? 0 : Math.min(1, tabIds.length)] + ); + const selectedPack = packs.find(({ id }) => id === currentTab); + const { + stickers = recentStickers, + title: packTitle = 'Recent Stickers', + } = + selectedPack || {}; + + const [packsPage, setPacksPage] = React.useState(0); + const onClickPrevPackPage = React.useCallback( + () => { + setPacksPage(i => i - 1); + }, + [setPacksPage] + ); + const onClickNextPackPage = React.useCallback( + () => { + setPacksPage(i => i + 1); + }, + [setPacksPage] + ); + + const isEmpty = stickers.length === 0; + const downloadError = + selectedPack && + selectedPack.status === 'error' && + selectedPack.stickerCount !== selectedPack.stickers.length; + const pendingCount = + selectedPack && selectedPack.status === 'pending' + ? selectedPack.stickerCount - stickers.length + : 0; + + const hasPacks = packs.length > 0; + const isRecents = hasPacks && currentTab === 'recents'; + const showPendingText = pendingCount > 0; + const showDownlaodErrorText = downloadError; + const showEmptyText = !downloadError && isEmpty; + const showText = + showPendingText || showDownlaodErrorText || showEmptyText; + const showLongText = showPickerHint; + + return ( +
    +
    +
    +
    + {hasPacks ? ( + + ))} +
    + {packsPage > 0 ? ( +
    +
    +
    + {showPickerHint ? ( +
    + {i18n('stickers--StickerPicker--Hint')} +
    + ) : null} + {!hasPacks ? ( +
    + {i18n('stickers--StickerPicker--NoPacks')} +
    + ) : null} + {pendingCount > 0 ? ( +
    + {i18n('stickers--StickerPicker--DownloadPending')} +
    + ) : null} + {downloadError ? ( +
    + {stickers.length > 0 + ? i18n('stickers--StickerPicker--DownloadError') + : i18n('stickers--StickerPicker--Empty')} +
    + ) : null} + {hasPacks && showEmptyText ? ( +
    + {isRecents + ? i18n('stickers--StickerPicker--NoRecents') + : i18n('stickers--StickerPicker--Empty')} +
    + ) : null} + {!isEmpty ? ( +
    + {stickers.map(({ packId, id, url }) => ( + + ))} + {Array(pendingCount) + .fill(0) + .map((_, i) => ( +
    + ))} +
    + ) : null} +
    +
    + ); + } + ) +); diff --git a/ts/components/stickers/StickerPreviewModal.md b/ts/components/stickers/StickerPreviewModal.md new file mode 100644 index 000000000..90737b0d7 --- /dev/null +++ b/ts/components/stickers/StickerPreviewModal.md @@ -0,0 +1,29 @@ +#### Not yet installed + +```jsx +const abeSticker = { url: util.squareStickerObjectUrl, packId: 'abe' }; + +const pack = { + id: 'foo', + cover: abeSticker, + title: 'Foo', + isBlessed: true, + author: 'Foo McBarrington', + status: 'advertised', + stickers: Array(101) + .fill(0) + .map((n, id) => ({ ...abeSticker, id })), +}; + + + console.log('onClose')} + installStickerPack={(...args) => console.log('installStickerPack', ...args)} + uninstallStickerPack={(...args) => + console.log('uninstallStickerPack', ...args) + } + i18n={util.i18n} + pack={pack} + /> +; +``` diff --git a/ts/components/stickers/StickerPreviewModal.tsx b/ts/components/stickers/StickerPreviewModal.tsx new file mode 100644 index 000000000..bc51282a2 --- /dev/null +++ b/ts/components/stickers/StickerPreviewModal.tsx @@ -0,0 +1,165 @@ +import * as React from 'react'; +import { createPortal } from 'react-dom'; +import { StickerPackInstallButton } from './StickerPackInstallButton'; +import { ConfirmationDialog } from '../ConfirmationDialog'; +import { LocalizerType } from '../../types/Util'; +import { StickerPackType } from '../../state/ducks/stickers'; + +export type OwnProps = { + readonly onClose: () => unknown; + readonly installStickerPack: (packId: string, packKey: string) => unknown; + readonly uninstallStickerPack: (packId: string, packKey: string) => unknown; + readonly pack: StickerPackType; + readonly i18n: LocalizerType; +}; + +export type Props = OwnProps; + +function focusRef(el: HTMLElement | null) { + if (el) { + el.focus(); + } +} + +export const StickerPreviewModal = React.memo( + // tslint:disable-next-line max-func-body-length + ({ + onClose, + pack, + i18n, + installStickerPack, + uninstallStickerPack, + }: Props) => { + const [root, setRoot] = React.useState(null); + const [confirmingUninstall, setConfirmingUninstall] = React.useState(false); + + React.useEffect(() => { + const div = document.createElement('div'); + document.body.appendChild(div); + setRoot(div); + + return () => { + document.body.removeChild(div); + setRoot(null); + }; + }, []); + + const isInstalled = pack.status === 'installed'; + const handleToggleInstall = React.useCallback( + () => { + if (isInstalled) { + setConfirmingUninstall(true); + } else { + installStickerPack(pack.id, pack.key); + onClose(); + } + }, + [isInstalled, pack, setConfirmingUninstall, installStickerPack, onClose] + ); + + const handleUninstall = React.useCallback( + () => { + uninstallStickerPack(pack.id, pack.key); + setConfirmingUninstall(false); + // onClose is called by the confirmation modal + }, + [uninstallStickerPack, setConfirmingUninstall, pack] + ); + + React.useEffect( + () => { + const handler = ({ key }: KeyboardEvent) => { + if (key === 'Escape') { + onClose(); + } + }; + + document.addEventListener('keyup', handler); + + return () => { + document.removeEventListener('keyup', handler); + }; + }, + [onClose] + ); + + const handleClickToClose = React.useCallback( + (e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + onClose(); + } + }, + [onClose] + ); + + return root + ? createPortal( +
    + {confirmingUninstall ? ( + + {i18n('stickers--StickerManager--UninstallWarning')} + + ) : ( +
    +
    +

    + {i18n('stickers--StickerPreview--Title')} +

    +
    +
    + {pack.stickers.map(({ id, url }) => ( +
    + {pack.title} +
    + ))} +
    +
    +
    +

    + {pack.title} + {pack.isBlessed ? ( + + ) : null} +

    +

    + {pack.author} +

    +
    +
    + +
    +
    +
    + )} +
    , + root + ) + : null; + } +); diff --git a/ts/shims/storage.ts b/ts/shims/storage.ts new file mode 100644 index 000000000..35c25b898 --- /dev/null +++ b/ts/shims/storage.ts @@ -0,0 +1,9 @@ +export async function put(key: string, value: any) { + // @ts-ignore + return window.storage.put(key, value); +} + +export async function remove(key: string) { + // @ts-ignore + return window.storage.remove(key); +} diff --git a/ts/shims/textsecure.ts b/ts/shims/textsecure.ts new file mode 100644 index 000000000..8ae9eac0a --- /dev/null +++ b/ts/shims/textsecure.ts @@ -0,0 +1,77 @@ +type LoggerType = (...args: Array) => void; + +type TextSecureType = { + storage: { + user: { + getNumber: () => string; + }; + }; + messaging: { + sendStickerPackSync: ( + operations: Array<{ + packId: string; + packKey: string; + installed: boolean; + }>, + options: Object + ) => Promise; + }; +}; + +type ConversationControllerType = { + prepareForSend: ( + id: string, + options: Object + ) => { + wrap: (promise: Promise) => Promise; + sendOptions: Object; + }; +}; + +interface ShimmedWindow extends Window { + log: { + error: LoggerType; + info: LoggerType; + }; + textsecure: TextSecureType; + ConversationController: ConversationControllerType; +} + +export function sendStickerPackSync( + packId: string, + packKey: string, + installed: boolean +) { + const { ConversationController, textsecure, log } = window as ShimmedWindow; + const ourNumber = textsecure.storage.user.getNumber(); + const { wrap, sendOptions } = ConversationController.prepareForSend( + ourNumber, + { syncMessage: true } + ); + + if (!textsecure.messaging) { + log.error( + 'shim: Cannot call sendStickerPackSync, textsecure.messaging is falsey' + ); + + return; + } + + wrap( + textsecure.messaging.sendStickerPackSync( + [ + { + packId, + packKey, + installed, + }, + ], + sendOptions + ) + ).catch(error => { + log.error( + 'shim: Error calling sendStickerPackSync:', + error && error.stack ? error.stack : error + ); + }); +} diff --git a/ts/state/actions.ts b/ts/state/actions.ts index ee562e3a2..39dfc2fdd 100644 --- a/ts/state/actions.ts +++ b/ts/state/actions.ts @@ -1,15 +1,13 @@ -import { bindActionCreators, Dispatch } from 'redux'; - -import { actions as search } from './ducks/search'; import { actions as conversations } from './ducks/conversations'; +import { actions as items } from './ducks/items'; +import { actions as search } from './ducks/search'; +import { actions as stickers } from './ducks/stickers'; import { actions as user } from './ducks/user'; -const actions = { - ...search, +export const mapDispatchToProps = { ...conversations, + ...items, + ...search, + ...stickers, ...user, }; - -export function mapDispatchToProps(dispatch: Dispatch): Object { - return bindActionCreators(actions, dispatch); -} diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 3c825e53e..229be1c49 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -1,3 +1,4 @@ +import { AnyAction } from 'redux'; import { omit } from 'lodash'; import { trigger } from '../../shims/events'; @@ -127,6 +128,7 @@ type ShowArchivedConversationsActionType = { }; export type ConversationActionType = + | AnyAction | ConversationAddedActionType | ConversationChangedActionType | ConversationRemovedActionType @@ -256,13 +258,9 @@ function getEmptyState(): ConversationsStateType { } export function reducer( - state: ConversationsStateType, + state: ConversationsStateType = getEmptyState(), action: ConversationActionType ): ConversationsStateType { - if (!state) { - return getEmptyState(); - } - if (action.type === 'CONVERSATION_ADDED') { const { payload } = action; const { id, data } = payload; diff --git a/ts/state/ducks/items.ts b/ts/state/ducks/items.ts new file mode 100644 index 000000000..c42f97c3d --- /dev/null +++ b/ts/state/ducks/items.ts @@ -0,0 +1,121 @@ +import { omit } from 'lodash'; +import * as storageShim from '../../shims/storage'; + +// State + +export type ItemsStateType = { + readonly [key: string]: any; +}; + +// Actions + +type ItemPutAction = { + type: 'items/PUT'; + payload: Promise; +}; + +type ItemPutExternalAction = { + type: 'items/PUT_EXTERNAL'; + payload: { + key: string; + value: any; + }; +}; + +type ItemRemoveAction = { + type: 'items/REMOVE'; + payload: Promise; +}; + +type ItemRemoveExternalAction = { + type: 'items/REMOVE_EXTERNAL'; + payload: string; +}; + +type ItemsResetAction = { + type: 'items/RESET'; +}; + +export type ItemsActionType = + | ItemPutAction + | ItemPutExternalAction + | ItemRemoveAction + | ItemRemoveExternalAction + | ItemsResetAction; + +// Action Creators + +export const actions = { + putItem, + putItemExternal, + removeItem, + removeItemExternal, + resetItems, +}; + +function putItem(key: string, value: any): ItemPutAction { + return { + type: 'items/PUT', + payload: storageShim.put(key, value), + }; +} + +function putItemExternal(key: string, value: any): ItemPutExternalAction { + return { + type: 'items/PUT_EXTERNAL', + payload: { + key, + value, + }, + }; +} + +function removeItem(key: string): ItemRemoveAction { + return { + type: 'items/REMOVE', + payload: storageShim.remove(key), + }; +} + +function removeItemExternal(key: string): ItemRemoveExternalAction { + return { + type: 'items/REMOVE_EXTERNAL', + payload: key, + }; +} + +function resetItems(): ItemsResetAction { + return { type: 'items/RESET' }; +} + +// Reducer + +function getEmptyState(): ItemsStateType { + return {}; +} + +export function reducer( + state: ItemsStateType = getEmptyState(), + action: ItemsActionType +): ItemsStateType { + if (action.type === 'items/PUT_EXTERNAL') { + const { payload } = action; + + return { + ...state, + [payload.key]: payload.value, + }; + } + + if (action.type === 'items/REMOVE_EXTERNAL') { + const { payload } = action; + + return omit(state, payload); + } + + if (action.type === 'items/RESET') { + return getEmptyState(); + } + + return state; +} diff --git a/ts/state/ducks/search.ts b/ts/state/ducks/search.ts index 51517f72a..9bd372075 100644 --- a/ts/state/ducks/search.ts +++ b/ts/state/ducks/search.ts @@ -1,3 +1,4 @@ +import { AnyAction } from 'redux'; import { omit, reject } from 'lodash'; import { normalize } from '../../types/PhoneNumber'; @@ -63,6 +64,7 @@ type ClearSearchActionType = { }; export type SEARCH_TYPES = + | AnyAction | SearchResultsFulfilledActionType | UpdateSearchTermActionType | ClearSearchActionType @@ -218,13 +220,9 @@ function getEmptyState(): SearchStateType { } export function reducer( - state: SearchStateType | undefined, + state: SearchStateType = getEmptyState(), action: SEARCH_TYPES ): SearchStateType { - if (!state) { - return getEmptyState(); - } - if (action.type === 'SEARCH_CLEAR') { return getEmptyState(); } diff --git a/ts/state/ducks/stickers.ts b/ts/state/ducks/stickers.ts new file mode 100644 index 000000000..a06602f67 --- /dev/null +++ b/ts/state/ducks/stickers.ts @@ -0,0 +1,463 @@ +import { Dictionary, omit, reject } from 'lodash'; +import { + getRecentStickers, + updateStickerLastUsed, + updateStickerPackStatus, +} from '../../../js/modules/data'; +import { maybeDeletePack } from '../../../js/modules/stickers'; +import { sendStickerPackSync } from '../../shims/textsecure'; +import { trigger } from '../../shims/events'; + +// State + +export type StickerDBType = { + readonly id: number; + readonly packId: string; + + readonly emoji: string; + readonly isCoverOnly: string; + readonly lastUsed: number; + readonly path: string; +}; + +export type StickerPackDBType = { + readonly id: string; + readonly key: string; + + readonly attemptedStatus: string; + readonly author: string; + readonly coverStickerId: number; + readonly createdAt: number; + readonly downloadAttempts: number; + readonly installedAt: number | null; + readonly lastUsed: number; + readonly status: 'advertised' | 'installed' | 'pending' | 'error'; + readonly stickerCount: number; + readonly stickers: Dictionary; + readonly title: string; +}; + +export type RecentStickerType = { + readonly stickerId: number; + readonly packId: string; +}; + +export type StickersStateType = { + readonly installedPack: string | null; + readonly packs: Dictionary; + readonly recentStickers: Array; + readonly blessedPacks: Dictionary; +}; + +// These are for the React components + +export type StickerType = { + readonly id: number; + readonly packId: string; + readonly emoji: string; + readonly url: string; +}; + +export type StickerPackType = { + readonly id: string; + readonly key: string; + readonly title: string; + readonly author: string; + readonly isBlessed: boolean; + readonly cover: StickerType; + readonly lastUsed: number; + readonly status: 'advertised' | 'installed' | 'pending' | 'error'; + readonly stickers: Array; + readonly stickerCount: number; +}; + +// Actions + +type StickerPackAddedAction = { + type: 'stickers/STICKER_PACK_ADDED'; + payload: StickerPackDBType; +}; + +type StickerAddedAction = { + type: 'stickers/STICKER_ADDED'; + payload: StickerDBType; +}; + +type InstallStickerPackPayloadType = { + packId: string; + status: 'installed'; + installedAt: number; + recentStickers: Array; +}; +type InstallStickerPackAction = { + type: 'stickers/INSTALL_STICKER_PACK'; + payload: Promise; +}; +type InstallStickerPackFulfilledAction = { + type: 'stickers/INSTALL_STICKER_PACK_FULFILLED'; + payload: InstallStickerPackPayloadType; +}; +type ClearInstalledStickerPackAction = { + type: 'stickers/CLEAR_INSTALLED_STICKER_PACK'; +}; + +type UninstallStickerPackPayloadType = { + packId: string; + status: 'advertised'; + installedAt: null; + recentStickers: Array; +}; +type UninstallStickerPackAction = { + type: 'stickers/UNINSTALL_STICKER_PACK'; + payload: Promise; +}; +type UninstallStickerPackFulfilledAction = { + type: 'stickers/UNINSTALL_STICKER_PACK_FULFILLED'; + payload: UninstallStickerPackPayloadType; +}; + +type StickerPackUpdatedAction = { + type: 'stickers/STICKER_PACK_UPDATED'; + payload: { packId: string; patch: Partial }; +}; + +type StickerPackRemovedAction = { + type: 'stickers/REMOVE_STICKER_PACK'; + payload: string; +}; + +type UseStickerPayloadType = { + packId: string; + stickerId: number; + time: number; +}; +type UseStickerAction = { + type: 'stickers/USE_STICKER'; + payload: Promise; +}; +type UseStickerFulfilledAction = { + type: 'stickers/USE_STICKER_FULFILLED'; + payload: UseStickerPayloadType; +}; + +export type StickersActionType = + | ClearInstalledStickerPackAction + | StickerAddedAction + | StickerPackAddedAction + | InstallStickerPackFulfilledAction + | UninstallStickerPackFulfilledAction + | StickerPackUpdatedAction + | StickerPackRemovedAction + | UseStickerFulfilledAction; + +// Action Creators + +export const actions = { + clearInstalledStickerPack, + removeStickerPack, + stickerAdded, + stickerPackAdded, + installStickerPack, + uninstallStickerPack, + stickerPackUpdated, + useSticker, +}; + +function removeStickerPack(id: string): StickerPackRemovedAction { + return { + type: 'stickers/REMOVE_STICKER_PACK', + payload: id, + }; +} + +function stickerAdded(payload: StickerDBType): StickerAddedAction { + return { + type: 'stickers/STICKER_ADDED', + payload, + }; +} + +function stickerPackAdded(payload: StickerPackDBType): StickerPackAddedAction { + const { status, attemptedStatus } = payload; + + // We do this to trigger a toast, which is still done via Backbone + if (status === 'error' && attemptedStatus === 'installed') { + trigger('pack-install-failed'); + } + + return { + type: 'stickers/STICKER_PACK_ADDED', + payload, + }; +} + +function installStickerPack( + packId: string, + packKey: string, + options: { fromSync: boolean } | null = null +): InstallStickerPackAction { + return { + type: 'stickers/INSTALL_STICKER_PACK', + payload: doInstallStickerPack(packId, packKey, options), + }; +} +async function doInstallStickerPack( + packId: string, + packKey: string, + options: { fromSync: boolean } | null +): Promise { + const { fromSync } = options || { fromSync: false }; + + const status = 'installed'; + const timestamp = Date.now(); + await updateStickerPackStatus(packId, status, { timestamp }); + + if (!fromSync) { + // Kick this off, but don't wait for it + sendStickerPackSync(packId, packKey, true); + } + + const recentStickers = await getRecentStickers(); + + return { + packId, + installedAt: timestamp, + status, + recentStickers: recentStickers.map(item => ({ + packId: item.packId, + stickerId: item.id, + })), + }; +} +function uninstallStickerPack( + packId: string, + packKey: string, + options: { fromSync: boolean } | null = null +): UninstallStickerPackAction { + return { + type: 'stickers/UNINSTALL_STICKER_PACK', + payload: doUninstallStickerPack(packId, packKey, options), + }; +} +async function doUninstallStickerPack( + packId: string, + packKey: string, + options: { fromSync: boolean } | null +): Promise { + const { fromSync } = options || { fromSync: false }; + + const status = 'advertised'; + await updateStickerPackStatus(packId, status); + + // If there are no more references, it should be removed + await maybeDeletePack(packId); + + if (!fromSync) { + // Kick this off, but don't wait for it + sendStickerPackSync(packId, packKey, false); + } + + const recentStickers = await getRecentStickers(); + + return { + packId, + status, + installedAt: null, + recentStickers: recentStickers.map(item => ({ + packId: item.packId, + stickerId: item.id, + })), + }; +} +function clearInstalledStickerPack(): ClearInstalledStickerPackAction { + return { type: 'stickers/CLEAR_INSTALLED_STICKER_PACK' }; +} + +function stickerPackUpdated( + packId: string, + patch: Partial +): StickerPackUpdatedAction { + return { + type: 'stickers/STICKER_PACK_UPDATED', + payload: { + packId, + patch, + }, + }; +} + +function useSticker( + packId: string, + stickerId: number, + time = Date.now() +): UseStickerAction { + return { + type: 'stickers/USE_STICKER', + payload: doUseSticker(packId, stickerId, time), + }; +} +async function doUseSticker( + packId: string, + stickerId: number, + time = Date.now() +): Promise { + await updateStickerLastUsed(packId, stickerId, time); + + return { + packId, + stickerId, + time, + }; +} + +// Reducer + +function getEmptyState(): StickersStateType { + return { + installedPack: null, + packs: {}, + recentStickers: [], + blessedPacks: {}, + }; +} + +// tslint:disable-next-line max-func-body-length +export function reducer( + state: StickersStateType = getEmptyState(), + action: StickersActionType +): StickersStateType { + if (action.type === 'stickers/STICKER_PACK_ADDED') { + const { payload } = action; + const newPack = { + stickers: {}, + ...payload, + }; + + return { + ...state, + packs: { + ...state.packs, + [payload.id]: newPack, + }, + }; + } + + if (action.type === 'stickers/STICKER_ADDED') { + const { payload } = action; + const packToUpdate = state.packs[payload.packId]; + + return { + ...state, + packs: { + ...state.packs, + [packToUpdate.id]: { + ...packToUpdate, + stickers: { + ...packToUpdate.stickers, + [payload.id]: payload, + }, + }, + }, + }; + } + + if (action.type === 'stickers/STICKER_PACK_UPDATED') { + const { payload } = action; + const packToUpdate = state.packs[payload.packId]; + + return { + ...state, + packs: { + ...state.packs, + [packToUpdate.id]: { + ...packToUpdate, + ...payload.patch, + }, + }, + }; + } + + if ( + action.type === 'stickers/INSTALL_STICKER_PACK_FULFILLED' || + action.type === 'stickers/UNINSTALL_STICKER_PACK_FULFILLED' + ) { + const { payload } = action; + const { installedAt, packId, status, recentStickers } = payload; + const { packs } = state; + const existingPack = packs[packId]; + + // A pack might be deleted as part of the uninstall process + if (!existingPack) { + return { + ...state, + installedPack: + state.installedPack === packId ? null : state.installedPack, + recentStickers, + }; + } + + return { + ...state, + installedPack: packId, + packs: { + ...packs, + [packId]: { + ...packs[packId], + status, + installedAt, + }, + }, + recentStickers, + }; + } + + if (action.type === 'stickers/CLEAR_INSTALLED_STICKER_PACK') { + return { + ...state, + installedPack: null, + }; + } + + if (action.type === 'stickers/REMOVE_STICKER_PACK') { + const { payload } = action; + + return { + ...state, + packs: omit(state.packs, payload), + }; + } + + if (action.type === 'stickers/USE_STICKER_FULFILLED') { + const { payload } = action; + const { packId, stickerId, time } = payload; + const { recentStickers, packs } = state; + + const filteredRecents = reject( + recentStickers, + item => item.packId === packId && item.stickerId === stickerId + ); + const pack = packs[packId]; + const sticker = pack.stickers[stickerId]; + + return { + ...state, + recentStickers: [payload, ...filteredRecents], + packs: { + ...state.packs, + [packId]: { + ...pack, + lastUsed: time, + stickers: { + ...pack.stickers, + [stickerId]: { + ...sticker, + lastUsed: time, + }, + }, + }, + }, + }; + } + + return state; +} diff --git a/ts/state/ducks/user.ts b/ts/state/ducks/user.ts index 4123170cd..fe8c685d3 100644 --- a/ts/state/ducks/user.ts +++ b/ts/state/ducks/user.ts @@ -1,8 +1,11 @@ +import { AnyAction } from 'redux'; import { LocalizerType } from '../../types/Util'; // State export type UserStateType = { + attachmentsPath: string; + stickersPath: string; ourNumber: string; regionCode: string; i18n: LocalizerType; @@ -18,7 +21,7 @@ type UserChangedActionType = { }; }; -export type UserActionType = UserChangedActionType; +export type UserActionType = AnyAction | UserChangedActionType; // Action Creators @@ -40,6 +43,8 @@ function userChanged(attributes: { function getEmptyState(): UserStateType { return { + attachmentsPath: 'missing', + stickersPath: 'missing', ourNumber: 'missing', regionCode: 'missing', i18n: () => 'missing', @@ -47,7 +52,7 @@ function getEmptyState(): UserStateType { } export function reducer( - state: UserStateType, + state: UserStateType = getEmptyState(), action: UserActionType ): UserStateType { if (!state) { diff --git a/ts/state/reducer.ts b/ts/state/reducer.ts index 965c27937..098b7e483 100644 --- a/ts/state/reducer.ts +++ b/ts/state/reducer.ts @@ -1,25 +1,48 @@ import { combineReducers } from 'redux'; -import { reducer as search, SearchStateType } from './ducks/search'; import { + ConversationActionType, ConversationsStateType, reducer as conversations, } from './ducks/conversations'; +import { + ItemsActionType, + ItemsStateType, + reducer as items, +} from './ducks/items'; +import { + reducer as search, + SEARCH_TYPES as SearchActionType, + SearchStateType, +} from './ducks/search'; +import { + reducer as stickers, + StickersActionType, + StickersStateType, +} from './ducks/stickers'; import { reducer as user, UserStateType } from './ducks/user'; export type StateType = { - search: SearchStateType; conversations: ConversationsStateType; + items: ItemsStateType; + search: SearchStateType; + stickers: StickersStateType; user: UserStateType; }; +export type ActionsType = + | ItemsActionType + | ConversationActionType + | StickersActionType + | SearchActionType; + export const reducers = { - search, conversations, + items, + search, + stickers, user, }; -// Making this work would require that our reducer signature supported AnyAction, not -// our restricted actions -// @ts-ignore -export const reducer = combineReducers(reducers); +// @ts-ignore: AnyAction breaks strong type checking inside reducers +export const reducer = combineReducers(reducers); diff --git a/ts/state/roots/createStickerButton.tsx b/ts/state/roots/createStickerButton.tsx new file mode 100644 index 000000000..ade6bb62e --- /dev/null +++ b/ts/state/roots/createStickerButton.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { Provider } from 'react-redux'; + +import { Store } from 'redux'; + +import { SmartStickerButton } from '../smart/StickerButton'; + +// Workaround: A react component's required properties are filtering up through connect() +// https://github.com/DefinitelyTyped/DefinitelyTyped/issues/31363 +const FilteredStickerButton = SmartStickerButton as any; + +export const createStickerButton = (store: Store, props: Object) => ( + + + +); diff --git a/ts/state/roots/createStickerManager.tsx b/ts/state/roots/createStickerManager.tsx new file mode 100644 index 000000000..47523e361 --- /dev/null +++ b/ts/state/roots/createStickerManager.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { Provider } from 'react-redux'; + +import { Store } from 'redux'; + +import { SmartStickerManager } from '../smart/StickerManager'; + +// Workaround: A react component's required properties are filtering up through connect() +// https://github.com/DefinitelyTyped/DefinitelyTyped/issues/31363 +const FilteredStickerManager = SmartStickerManager as any; + +export const createStickerManager = (store: Store) => ( + + + +); diff --git a/ts/state/roots/createStickerPreviewModal.tsx b/ts/state/roots/createStickerPreviewModal.tsx new file mode 100644 index 000000000..9ce2de21a --- /dev/null +++ b/ts/state/roots/createStickerPreviewModal.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { Provider } from 'react-redux'; + +import { Store } from 'redux'; + +import { SmartStickerPreviewModal } from '../smart/StickerPreviewModal'; + +// Workaround: A react component's required properties are filtering up through connect() +// https://github.com/DefinitelyTyped/DefinitelyTyped/issues/31363 +const FilteredStickerPreviewModal = SmartStickerPreviewModal as any; + +export const createStickerPreviewModal = (store: Store, props: Object) => ( + + + +); diff --git a/ts/state/selectors/stickers.ts b/ts/state/selectors/stickers.ts new file mode 100644 index 000000000..7734832b8 --- /dev/null +++ b/ts/state/selectors/stickers.ts @@ -0,0 +1,206 @@ +import { join } from 'path'; +import { + compact, + Dictionary, + filter, + map, + orderBy, + reject, + sortBy, + values, +} from 'lodash'; +import { createSelector } from 'reselect'; + +import { StateType } from '../reducer'; +import { + RecentStickerType, + StickerDBType, + StickerPackDBType, + StickerPackType, + StickersStateType, + StickerType, +} from '../ducks/stickers'; +import { getStickersPath } from './user'; + +const getSticker = ( + packs: Dictionary, + packId: string, + stickerId: number, + stickerPath: string +): StickerType | undefined => { + const pack = packs[packId]; + if (!pack) { + return; + } + + const sticker = pack.stickers[stickerId]; + if (!sticker) { + return; + } + + return translateStickerFromDB(sticker, stickerPath); +}; + +const translateStickerFromDB = ( + sticker: StickerDBType, + stickerPath: string +): StickerType => { + const { id, packId, emoji, path } = sticker; + + return { + id, + packId, + emoji, + url: join(stickerPath, path), + }; +}; + +export const translatePackFromDB = ( + pack: StickerPackDBType, + packs: Dictionary, + blessedPacks: Dictionary, + stickersPath: string +) => { + const { id, stickers, coverStickerId } = pack; + + // Sometimes sticker packs have a cover which isn't included in their set of stickers. + // We don't want to show cover-only images when previewing or picking from a pack. + const filteredStickers = reject( + values(stickers), + sticker => sticker.isCoverOnly + ); + const translatedStickers = map(filteredStickers, sticker => + translateStickerFromDB(sticker, stickersPath) + ); + + return { + ...pack, + isBlessed: Boolean(blessedPacks[id]), + cover: getSticker(packs, id, coverStickerId, stickersPath), + stickers: sortBy(translatedStickers, sticker => sticker.id), + }; +}; + +const filterAndTransformPacks = ( + packs: Dictionary, + packFilter: (sticker: StickerPackDBType) => boolean, + packSort: (sticker: StickerPackDBType) => any, + blessedPacks: Dictionary, + stickersPath: string +): Array => { + const list = filter(packs, packFilter); + const sorted = orderBy(list, packSort, ['desc']); + + const ready = sorted.map(pack => + translatePackFromDB(pack, packs, blessedPacks, stickersPath) + ); + + // We're explicitly forcing pack.cover to be truthy here, but TypeScript doesn't + // understand that. + return ready.filter(pack => Boolean(pack.cover)) as Array; +}; + +const getStickers = (state: StateType) => state.stickers; + +export const getPacks = createSelector( + getStickers, + (stickers: StickersStateType) => stickers.packs +); + +const getRecents = createSelector( + getStickers, + (stickers: StickersStateType) => stickers.recentStickers +); + +export const getBlessedPacks = createSelector( + getStickers, + (stickers: StickersStateType) => stickers.blessedPacks +); + +export const getRecentStickers = createSelector( + getRecents, + getPacks, + getStickersPath, + ( + recents: Array, + packs: Dictionary, + stickersPath: string + ) => { + return compact( + recents.map(({ packId, stickerId }) => { + return getSticker(packs, packId, stickerId, stickersPath); + }) + ); + } +); + +export const getInstalledStickerPacks = createSelector( + getPacks, + getBlessedPacks, + getStickersPath, + ( + packs: Dictionary, + blessedPacks: Dictionary, + stickersPath: string + ): Array => { + return filterAndTransformPacks( + packs, + pack => pack.status === 'installed', + pack => pack.installedAt, + blessedPacks, + stickersPath + ); + } +); + +export const getRecentlyInstalledStickerPack = createSelector( + getInstalledStickerPacks, + getStickers, + (packs, { installedPack: packId }) => { + if (!packId) { + return null; + } + + return packs.find(({ id }) => id === packId) || null; + } +); + +export const getReceivedStickerPacks = createSelector( + getPacks, + getBlessedPacks, + getStickersPath, + ( + packs: Dictionary, + blessedPacks: Dictionary, + stickersPath: string + ): Array => { + return filterAndTransformPacks( + packs, + pack => + (pack.status === 'advertised' || pack.status === 'pending') && + !blessedPacks[pack.id], + pack => pack.createdAt, + blessedPacks, + stickersPath + ); + } +); + +export const getBlessedStickerPacks = createSelector( + getPacks, + getBlessedPacks, + getStickersPath, + ( + packs: Dictionary, + blessedPacks: Dictionary, + stickersPath: string + ): Array => { + return filterAndTransformPacks( + packs, + pack => blessedPacks[pack.id] && pack.status !== 'installed', + pack => pack.createdAt, + blessedPacks, + stickersPath + ); + } +); diff --git a/ts/state/selectors/user.ts b/ts/state/selectors/user.ts index 67478ab9f..636cd34df 100644 --- a/ts/state/selectors/user.ts +++ b/ts/state/selectors/user.ts @@ -21,3 +21,13 @@ export const getIntl = createSelector( getUser, (state: UserStateType): LocalizerType => state.i18n ); + +export const getAttachmentsPath = createSelector( + getUser, + (state: UserStateType): string => state.attachmentsPath +); + +export const getStickersPath = createSelector( + getUser, + (state: UserStateType): string => state.stickersPath +); diff --git a/ts/state/smart/StickerButton.tsx b/ts/state/smart/StickerButton.tsx new file mode 100644 index 000000000..d9ee73604 --- /dev/null +++ b/ts/state/smart/StickerButton.tsx @@ -0,0 +1,48 @@ +import { connect } from 'react-redux'; +import { get } from 'lodash'; +import { mapDispatchToProps } from '../actions'; +import { StickerButton } from '../../components/stickers/StickerButton'; +import { StateType } from '../reducer'; + +import { getIntl } from '../selectors/user'; +import { + getInstalledStickerPacks, + getReceivedStickerPacks, + getRecentlyInstalledStickerPack, + getRecentStickers, +} from '../selectors/stickers'; + +const mapStateToProps = (state: StateType) => { + const receivedPacks = getReceivedStickerPacks(state); + const installedPacks = getInstalledStickerPacks(state); + const recentStickers = getRecentStickers(state); + const installedPack = getRecentlyInstalledStickerPack(state); + const showIntroduction = get( + state.items, + ['showStickersIntroduction', 'value'], + false + ); + const showPickerHint = + get(state.items, ['showStickerPickerHint', 'value'], false) && + receivedPacks.length > 0; + + return { + receivedPacks, + installedPack, + installedPacks, + recentStickers, + showIntroduction, + showPickerHint, + i18n: getIntl(state), + }; +}; + +const smart = connect(mapStateToProps, { + ...mapDispatchToProps, + clearShowIntroduction: () => + mapDispatchToProps.removeItem('showStickersIntroduction'), + clearShowPickerHint: () => + mapDispatchToProps.removeItem('showStickerPickerHint'), +}); + +export const SmartStickerButton = smart(StickerButton); diff --git a/ts/state/smart/StickerManager.tsx b/ts/state/smart/StickerManager.tsx new file mode 100644 index 000000000..6e042a228 --- /dev/null +++ b/ts/state/smart/StickerManager.tsx @@ -0,0 +1,28 @@ +import { connect } from 'react-redux'; +import { mapDispatchToProps } from '../actions'; +import { StickerManager } from '../../components/stickers/StickerManager'; +import { StateType } from '../reducer'; + +import { getIntl } from '../selectors/user'; +import { + getBlessedStickerPacks, + getInstalledStickerPacks, + getReceivedStickerPacks, +} from '../selectors/stickers'; + +const mapStateToProps = (state: StateType) => { + const blessedPacks = getBlessedStickerPacks(state); + const receivedPacks = getReceivedStickerPacks(state); + const installedPacks = getInstalledStickerPacks(state); + + return { + blessedPacks, + receivedPacks, + installedPacks, + i18n: getIntl(state), + }; +}; + +const smart = connect(mapStateToProps, mapDispatchToProps); + +export const SmartStickerManager = smart(StickerManager); diff --git a/ts/state/smart/StickerPreviewModal.tsx b/ts/state/smart/StickerPreviewModal.tsx new file mode 100644 index 000000000..2e94c3cbe --- /dev/null +++ b/ts/state/smart/StickerPreviewModal.tsx @@ -0,0 +1,54 @@ +import { connect } from 'react-redux'; +import { mapDispatchToProps } from '../actions'; +import { StickerPreviewModal } from '../../components/stickers/StickerPreviewModal'; +import { StateType } from '../reducer'; + +import { getIntl, getStickersPath } from '../selectors/user'; +import { + getBlessedPacks, + getPacks, + translatePackFromDB, +} from '../selectors/stickers'; + +type ExternalProps = { + packId: string; + readonly onClose: () => unknown; +}; + +const mapStateToProps = (state: StateType, props: ExternalProps) => { + const { packId } = props; + const stickersPath = getStickersPath(state); + const packs = getPacks(state); + const blessedPacks = getBlessedPacks(state); + const pack = packs[packId]; + + if (!pack) { + throw new Error(`Cannot find pack ${packId}`); + } + const translated = translatePackFromDB( + pack, + packs, + blessedPacks, + stickersPath + ); + + return { + ...props, + pack: { + ...translated, + cover: translated.cover + ? translated.cover + : { + id: 0, + url: 'nonexistent', + packId, + emoji: 'WTF', + }, + }, + i18n: getIntl(state), + }; +}; + +const smart = connect(mapStateToProps, mapDispatchToProps); + +export const SmartStickerPreviewModal = smart(StickerPreviewModal); diff --git a/ts/styleguide/StyleGuideUtil.ts b/ts/styleguide/StyleGuideUtil.ts index 49faa0a42..84af75a16 100644 --- a/ts/styleguide/StyleGuideUtil.ts +++ b/ts/styleguide/StyleGuideUtil.ts @@ -41,6 +41,10 @@ import landscape from '../../fixtures/koushik-chowdavarapu-105425-unsplash.jpg'; // 800×1200 const landscapeObjectUrl = makeObjectUrl(landscape, 'image/png'); +// @ts-ignore +import squareSticker from '../../fixtures/512x515-thumbs-up-lincoln.webp'; +const squareStickerObjectUrl = makeObjectUrl(squareSticker, 'image/webp'); + // @ts-ignore import landscapeGreen from '../../fixtures/1000x50-green.jpeg'; const landscapeGreenObjectUrl = makeObjectUrl(landscapeGreen, 'image/jpeg'); @@ -57,6 +61,16 @@ const landscapeRedObjectUrl = makeObjectUrl(landscapeRed, 'image/png'); import portraitTeal from '../../fixtures/50x1000-teal.jpeg'; const portraitTealObjectUrl = makeObjectUrl(portraitTeal, 'image/png'); +// @ts-ignore +import kitten164 from '../../fixtures/kitten-1-64-64.jpg'; +const kitten164ObjectUrl = makeObjectUrl(kitten164, 'image/jpeg'); +// @ts-ignore +import kitten264 from '../../fixtures/kitten-2-64-64.jpg'; +const kitten264ObjectUrl = makeObjectUrl(kitten264, 'image/jpeg'); +// @ts-ignore +import kitten364 from '../../fixtures/kitten-3-64-64.jpg'; +const kitten364ObjectUrl = makeObjectUrl(kitten364, 'image/jpeg'); + function makeObjectUrl(data: ArrayBuffer, contentType: string): string { const blob = new Blob([data], { type: contentType, @@ -66,6 +80,12 @@ function makeObjectUrl(data: ArrayBuffer, contentType: string): string { } export { + kitten164, + kitten164ObjectUrl, + kitten264, + kitten264ObjectUrl, + kitten364, + kitten364ObjectUrl, mp3, mp3ObjectUrl, gif, @@ -76,6 +96,8 @@ export { mp4ObjectUrlV2, png, pngObjectUrl, + squareSticker, + squareStickerObjectUrl, txt, txtObjectUrl, landscape, diff --git a/ts/types/MIME.ts b/ts/types/MIME.ts index a083e19cd..dcf968138 100644 --- a/ts/types/MIME.ts +++ b/ts/types/MIME.ts @@ -6,6 +6,7 @@ export const AUDIO_AAC = 'audio/aac' as MIMEType; export const AUDIO_MP3 = 'audio/mp3' as MIMEType; export const IMAGE_GIF = 'image/gif' as MIMEType; export const IMAGE_JPEG = 'image/jpeg' as MIMEType; +export const IMAGE_WEBP = 'image/webp' as MIMEType; export const VIDEO_MP4 = 'video/mp4' as MIMEType; export const VIDEO_QUICKTIME = 'video/quicktime' as MIMEType; diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 67e2a28c1..2e7cfe0c3 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -203,46 +203,6 @@ "updated": "2018-09-19T18:13:29.628Z", "reasonDetail": "Interacting with already-existing DOM nodes" }, - { - "rule": "jQuery-wrap(", - "path": "js/modules/crypto.js", - "line": " return dcodeIO.ByteBuffer.wrap(arrayBuffer).toString('base64');", - "lineNumber": 45, - "reasonCategory": "falseMatch", - "updated": "2018-10-05T23:12:28.961Z" - }, - { - "rule": "jQuery-wrap(", - "path": "js/modules/crypto.js", - "line": " return dcodeIO.ByteBuffer.wrap(base64string, 'base64').toArrayBuffer();", - "lineNumber": 48, - "reasonCategory": "falseMatch", - "updated": "2018-10-05T23:12:28.961Z" - }, - { - "rule": "jQuery-wrap(", - "path": "js/modules/crypto.js", - "line": " return dcodeIO.ByteBuffer.wrap(key, 'binary').toArrayBuffer();", - "lineNumber": 52, - "reasonCategory": "falseMatch", - "updated": "2018-10-05T23:12:28.961Z" - }, - { - "rule": "jQuery-wrap(", - "path": "js/modules/crypto.js", - "line": " return dcodeIO.ByteBuffer.wrap(string, 'utf8').toArrayBuffer();", - "lineNumber": 56, - "reasonCategory": "falseMatch", - "updated": "2018-10-05T23:12:28.961Z" - }, - { - "rule": "jQuery-wrap(", - "path": "js/modules/crypto.js", - "line": " return dcodeIO.ByteBuffer.wrap(buffer).toString('utf8');", - "lineNumber": 59, - "reasonCategory": "falseMatch", - "updated": "2018-10-05T23:12:28.961Z" - }, { "rule": "jQuery-append(", "path": "js/modules/debuglogs.js", @@ -267,6 +227,14 @@ "reasonCategory": "falseMatch", "updated": "2018-09-19T18:13:29.628Z" }, + { + "rule": "jQuery-load(", + "path": "js/modules/stickers.js", + "line": "async function load() {", + "lineNumber": 53, + "reasonCategory": "falseMatch", + "updated": "2019-04-26T17:48:30.675Z" + }, { "rule": "jQuery-$(", "path": "js/permissions_popup_start.js", @@ -501,7 +469,7 @@ "rule": "jQuery-$(", "path": "js/views/inbox_view.js", "line": " let $el = this.$(`#${id}`);", - "lineNumber": 27, + "lineNumber": 33, "reasonCategory": "usageTrusted", "updated": "2018-09-19T21:59:32.770Z", "reasonDetail": "Protected from arbitrary input" @@ -510,7 +478,7 @@ "rule": "jQuery-prependTo(", "path": "js/views/inbox_view.js", "line": " $el.prependTo(this.el);", - "lineNumber": 36, + "lineNumber": 42, "reasonCategory": "usageTrusted", "updated": "2018-09-19T18:13:29.628Z", "reasonDetail": "Interacting with already-existing DOM nodes" @@ -519,7 +487,7 @@ "rule": "jQuery-$(", "path": "js/views/inbox_view.js", "line": " this.$('.message').text(message);", - "lineNumber": 48, + "lineNumber": 56, "reasonCategory": "usageTrusted", "updated": "2018-09-19T21:59:32.770Z", "reasonDetail": "Protected from arbitrary input" @@ -528,7 +496,7 @@ "rule": "jQuery-$(", "path": "js/views/inbox_view.js", "line": " el: this.$('.conversation-stack'),", - "lineNumber": 65, + "lineNumber": 73, "reasonCategory": "usageTrusted", "updated": "2018-09-19T21:59:32.770Z", "reasonDetail": "Protected from arbitrary input" @@ -537,7 +505,7 @@ "rule": "jQuery-prependTo(", "path": "js/views/inbox_view.js", "line": " this.appLoadingScreen.$el.prependTo(this.el);", - "lineNumber": 72, + "lineNumber": 80, "reasonCategory": "usageTrusted", "updated": "2018-09-19T18:13:29.628Z", "reasonDetail": "Interacting with already-existing DOM nodes" @@ -546,7 +514,7 @@ "rule": "jQuery-append(", "path": "js/views/inbox_view.js", "line": " .append(this.networkStatusView.render().el);", - "lineNumber": 87, + "lineNumber": 95, "reasonCategory": "usageTrusted", "updated": "2018-09-19T18:13:29.628Z", "reasonDetail": "Interacting with already-existing DOM nodes" @@ -555,16 +523,25 @@ "rule": "jQuery-prependTo(", "path": "js/views/inbox_view.js", "line": " banner.$el.prependTo(this.$el);", - "lineNumber": 91, + "lineNumber": 99, "reasonCategory": "usageTrusted", "updated": "2018-09-19T18:13:29.628Z", "reasonDetail": "Interacting with already-existing DOM nodes" }, + { + "rule": "jQuery-appendTo(", + "path": "js/views/inbox_view.js", + "line": " toast.$el.appendTo(this.$el);", + "lineNumber": 105, + "reasonCategory": "usageTrusted", + "updated": "2019-05-10T00:25:51.515Z", + "reasonDetail": "Interacting with already-existing DOM nodes" + }, { "rule": "jQuery-$(", "path": "js/views/inbox_view.js", "line": " this.$('.left-pane-placeholder').append(this.leftPaneView.el);", - "lineNumber": 111, + "lineNumber": 125, "reasonCategory": "usageTrusted", "updated": "2019-03-08T23:49:08.796Z", "reasonDetail": "Protected from arbitrary input" @@ -573,7 +550,7 @@ "rule": "jQuery-append(", "path": "js/views/inbox_view.js", "line": " this.$('.left-pane-placeholder').append(this.leftPaneView.el);", - "lineNumber": 111, + "lineNumber": 125, "reasonCategory": "usageTrusted", "updated": "2019-03-08T23:49:08.796Z", "reasonDetail": "Protected from arbitrary input" @@ -582,7 +559,7 @@ "rule": "jQuery-$(", "path": "js/views/inbox_view.js", "line": " if (e && this.$(e.target).closest('.placeholder').length) {", - "lineNumber": 152, + "lineNumber": 166, "reasonCategory": "usageTrusted", "updated": "2019-03-08T23:49:08.796Z", "reasonDetail": "Protected from arbitrary input" @@ -591,7 +568,7 @@ "rule": "jQuery-$(", "path": "js/views/inbox_view.js", "line": " this.$('#header, .gutter').addClass('inactive');", - "lineNumber": 156, + "lineNumber": 170, "reasonCategory": "usageTrusted", "updated": "2019-03-08T23:49:08.796Z", "reasonDetail": "Protected from arbitrary input" @@ -600,7 +577,7 @@ "rule": "jQuery-$(", "path": "js/views/inbox_view.js", "line": " this.$('.conversation-stack').addClass('inactive');", - "lineNumber": 160, + "lineNumber": 174, "reasonCategory": "usageTrusted", "updated": "2019-03-08T23:49:08.796Z", "reasonDetail": "Protected from arbitrary input" @@ -609,7 +586,7 @@ "rule": "jQuery-$(", "path": "js/views/inbox_view.js", "line": " this.$('.conversation:first .menu').trigger('close');", - "lineNumber": 162, + "lineNumber": 176, "reasonCategory": "usageTrusted", "updated": "2019-03-08T23:49:08.796Z", "reasonDetail": "Protected from arbitrary input" @@ -618,7 +595,7 @@ "rule": "jQuery-$(", "path": "js/views/inbox_view.js", "line": " if (e && this.$(e.target).closest('.capture-audio').length > 0) {", - "lineNumber": 182, + "lineNumber": 196, "reasonCategory": "usageTrusted", "updated": "2019-03-08T23:49:08.796Z", "reasonDetail": "Protected from arbitrary input" @@ -627,7 +604,7 @@ "rule": "jQuery-$(", "path": "js/views/inbox_view.js", "line": " this.$('.conversation:first .recorder').trigger('close');", - "lineNumber": 185, + "lineNumber": 199, "reasonCategory": "usageTrusted", "updated": "2019-03-08T23:49:08.796Z", "reasonDetail": "Protected from arbitrary input" @@ -887,7 +864,7 @@ "rule": "jQuery-append(", "path": "js/views/message_view.js", "line": " this.$el.append(this.childView.el);", - "lineNumber": 123, + "lineNumber": 139, "reasonCategory": "usageTrusted", "updated": "2018-09-19T18:13:29.628Z", "reasonDetail": "Interacting with already-existing DOM nodes" @@ -1335,7 +1312,7 @@ "rule": "jQuery-wrap(", "path": "libtextsecure/crypto.js", "line": " const data = dcodeIO.ByteBuffer.wrap(", - "lineNumber": 205, + "lineNumber": 206, "reasonCategory": "falseMatch", "updated": "2018-09-19T18:13:29.628Z" }, @@ -1343,7 +1320,7 @@ "rule": "jQuery-wrap(", "path": "libtextsecure/crypto.js", "line": " return dcodeIO.ByteBuffer.wrap(padded)", - "lineNumber": 219, + "lineNumber": 220, "reasonCategory": "falseMatch", "updated": "2018-09-19T18:13:29.628Z" }, @@ -1379,6 +1356,22 @@ "reasonCategory": "falseMatch", "updated": "2018-09-19T18:13:29.628Z" }, + { + "rule": "jQuery-wrap(", + "path": "libtextsecure/sendmessage.js", + "line": " return dcodeIO.ByteBuffer.wrap(string, 'hex').toArrayBuffer();", + "lineNumber": 17, + "reasonCategory": "falseMatch", + "updated": "2019-04-26T17:48:30.675Z" + }, + { + "rule": "jQuery-wrap(", + "path": "libtextsecure/sendmessage.js", + "line": " return dcodeIO.ByteBuffer.wrap(string, 'base64').toArrayBuffer();", + "lineNumber": 20, + "reasonCategory": "falseMatch", + "updated": "2019-04-26T17:48:30.675Z" + }, { "rule": "jQuery-wrap(", "path": "libtextsecure/sync_request.js", @@ -1825,7 +1818,7 @@ "lineNumber": 31, "reasonCategory": "usageTrusted", "updated": "2019-03-22T19:15:12.445Z", - "reasonDetail": "" + "reasonDetail": "It's usage of bluebird that's the problem, not bluebird itself with this rule" }, { "rule": "thenify-multiArgs", @@ -1897,7 +1890,7 @@ "lineNumber": 3519, "reasonCategory": "usageTrusted", "updated": "2019-03-22T19:15:12.445Z", - "reasonDetail": "" + "reasonDetail": "Usage of bluebird is the problem with this rule, not bluebird itself" }, { "rule": "thenify-multiArgs", @@ -2509,143 +2502,105 @@ { "rule": "jQuery-$(", "path": "node_modules/core-js/build/index.js", - "line": " if (name.indexOf(ns + \".\") === 0 && !in$(name, experimental)) {", - "lineNumber": 36, + "line": " if (name.indexOf(ns + \".\") === 0 && !in$(name, experimental)) {", + "lineNumber": 43, "reasonCategory": "falseMatch", - "updated": "2018-09-19T21:59:32.770Z" + "updated": "2019-04-26T19:18:14.550Z" }, { "rule": "jQuery-$(", "path": "node_modules/core-js/build/index.js", "line": " function in$(x, xs){", - "lineNumber": 99, + "lineNumber": 93, "reasonCategory": "falseMatch", - "updated": "2018-09-19T21:59:32.770Z" + "updated": "2019-04-26T19:18:14.550Z" }, { "rule": "jQuery-wrap(", "path": "node_modules/core-js/client/core.js", - "line": "\t return wrap(tag);", - "lineNumber": 391, - "reasonCategory": "falseMatch", - "updated": "2018-09-19T18:13:29.628Z" + "line": "\t return wrap(uid(arguments.length > 0 ? arguments[0] : undefined));", + "lineNumber": 1082, + "reasonCategory": "falseMatch" }, { "rule": "jQuery-wrap(", "path": "node_modules/core-js/client/core.js", - "line": "\t return wrap(wks(name));", - "lineNumber": 408, + "line": "\t symbolStatics[it] = useNative ? sym : wrap(sym);", + "lineNumber": 1135, "reasonCategory": "falseMatch", - "updated": "2018-09-19T18:13:29.628Z" - }, - { - "rule": "jQuery-$(", - "path": "node_modules/core-js/client/core.js", - "line": "\t if(!NPCG)separator2 = new RegExp('^' + separatorCopy.source + '$(?!\\\\s)', flags);", - "lineNumber": 3893, - "reasonCategory": "falseMatch", - "updated": "2018-09-19T21:59:32.770Z" + "updated": "2019-04-26T19:18:14.550Z" }, { "rule": "jQuery-wrap(", "path": "node_modules/core-js/client/core.js", "line": "\t setTimeout: wrap(global.setTimeout),", - "lineNumber": 7226, + "lineNumber": 4496, "reasonCategory": "falseMatch", - "updated": "2018-09-19T18:13:29.628Z" - }, - { - "rule": "jQuery-$(", - "path": "node_modules/core-js/client/core.min.js", - "lineNumber": 8, - "reasonCategory": "falseMatch", - "updated": "2018-09-19T21:59:32.770Z" + "updated": "2019-04-26T19:18:14.550Z" }, { "rule": "jQuery-wrap(", "path": "node_modules/core-js/client/library.js", - "line": "\t return wrap(tag);", - "lineNumber": 379, + "line": "\t return wrap(uid(arguments.length > 0 ? arguments[0] : undefined));", + "lineNumber": 1033, "reasonCategory": "falseMatch", - "updated": "2018-09-19T18:13:29.628Z" + "updated": "2019-04-26T19:18:14.550Z" }, { "rule": "jQuery-wrap(", "path": "node_modules/core-js/client/library.js", - "line": "\t return wrap(wks(name));", - "lineNumber": 396, + "line": "\t symbolStatics[it] = useNative ? sym : wrap(sym);", + "lineNumber": 1086, "reasonCategory": "falseMatch", - "updated": "2018-09-19T18:13:29.628Z" + "updated": "2019-04-26T19:18:14.550Z" }, { "rule": "jQuery-wrap(", "path": "node_modules/core-js/client/library.js", "line": "\t setTimeout: wrap(global.setTimeout),", - "lineNumber": 6749, + "lineNumber": 4136, "reasonCategory": "falseMatch", - "updated": "2018-09-19T18:13:29.628Z" - }, - { - "rule": "jQuery-$(", - "path": "node_modules/core-js/client/library.min.js", - "lineNumber": 8, - "reasonCategory": "falseMatch", - "updated": "2018-09-19T21:59:32.770Z" + "updated": "2019-04-26T19:18:14.550Z" }, { "rule": "jQuery-wrap(", "path": "node_modules/core-js/client/shim.js", - "line": "\t return wrap(tag);", - "lineNumber": 377, + "line": "\t return wrap(uid(arguments.length > 0 ? arguments[0] : undefined));", + "lineNumber": 1068, "reasonCategory": "falseMatch", - "updated": "2018-09-19T18:13:29.628Z" + "updated": "2019-04-26T19:18:14.550Z" }, { "rule": "jQuery-wrap(", "path": "node_modules/core-js/client/shim.js", - "line": "\t return wrap(wks(name));", - "lineNumber": 394, + "line": "\t symbolStatics[it] = useNative ? sym : wrap(sym);", + "lineNumber": 1121, "reasonCategory": "falseMatch", - "updated": "2018-09-19T18:13:29.628Z" - }, - { - "rule": "jQuery-$(", - "path": "node_modules/core-js/client/shim.js", - "line": "\t if(!NPCG)separator2 = new RegExp('^' + separatorCopy.source + '$(?!\\\\s)', flags);", - "lineNumber": 3879, - "reasonCategory": "falseMatch", - "updated": "2018-09-19T21:59:32.770Z" + "updated": "2019-04-26T19:18:14.550Z" }, { "rule": "jQuery-wrap(", "path": "node_modules/core-js/client/shim.js", "line": "\t setTimeout: wrap(global.setTimeout),", - "lineNumber": 7212, + "lineNumber": 4482, "reasonCategory": "falseMatch", - "updated": "2018-09-19T18:13:29.628Z" - }, - { - "rule": "jQuery-$(", - "path": "node_modules/core-js/client/shim.min.js", - "lineNumber": 8, - "reasonCategory": "falseMatch", - "updated": "2018-09-19T21:59:32.770Z" + "updated": "2019-04-26T19:18:14.550Z" }, { "rule": "jQuery-wrap(", "path": "node_modules/core-js/library/modules/es6.symbol.js", - "line": " return wrap(tag);", + "line": " return wrap(uid(arguments.length > 0 ? arguments[0] : undefined));", "lineNumber": 142, "reasonCategory": "falseMatch", - "updated": "2018-09-19T18:13:29.628Z" + "updated": "2019-04-26T19:18:14.550Z" }, { "rule": "jQuery-wrap(", "path": "node_modules/core-js/library/modules/es6.symbol.js", - "line": " return wrap(wks(name));", - "lineNumber": 159, + "line": " symbolStatics[it] = useNative ? sym : wrap(sym);", + "lineNumber": 195, "reasonCategory": "falseMatch", - "updated": "2018-09-19T18:13:29.628Z" + "updated": "2019-04-26T19:18:14.550Z" }, { "rule": "jQuery-wrap(", @@ -2653,31 +2608,23 @@ "line": " setTimeout: wrap(global.setTimeout),", "lineNumber": 18, "reasonCategory": "falseMatch", - "updated": "2018-09-19T18:13:29.628Z" - }, - { - "rule": "jQuery-$(", - "path": "node_modules/core-js/modules/es6.regexp.split.js", - "line": " if(!NPCG)separator2 = new RegExp('^' + separatorCopy.source + '$(?!\\\\s)', flags);", - "lineNumber": 36, - "reasonCategory": "falseMatch", - "updated": "2018-09-19T21:59:32.770Z" + "updated": "2019-04-26T19:26:59.689Z" }, { "rule": "jQuery-wrap(", "path": "node_modules/core-js/modules/es6.symbol.js", - "line": " return wrap(tag);", + "line": " return wrap(uid(arguments.length > 0 ? arguments[0] : undefined));", "lineNumber": 142, "reasonCategory": "falseMatch", - "updated": "2018-09-19T18:13:29.628Z" + "updated": "2019-04-26T19:18:14.550Z" }, { "rule": "jQuery-wrap(", "path": "node_modules/core-js/modules/es6.symbol.js", - "line": " return wrap(wks(name));", - "lineNumber": 159, + "line": " symbolStatics[it] = useNative ? sym : wrap(sym);", + "lineNumber": 195, "reasonCategory": "falseMatch", - "updated": "2018-09-19T18:13:29.628Z" + "updated": "2019-04-26T19:18:14.550Z" }, { "rule": "jQuery-wrap(", @@ -2685,7 +2632,78 @@ "line": " setTimeout: wrap(global.setTimeout),", "lineNumber": 18, "reasonCategory": "falseMatch", - "updated": "2018-09-19T18:13:29.628Z" + "updated": "2019-04-26T19:26:59.689Z" + }, + { + "rule": "fbjs-createNodesFromMarkup", + "path": "node_modules/create-react-context/node_modules/fbjs/lib/createNodesFromMarkup.js", + "line": "function createNodesFromMarkup(markup, handleScript) {", + "lineNumber": 51, + "reasonCategory": "falseMatch", + "updated": "2019-04-26T19:18:14.550Z" + }, + { + "rule": "fbjs-createNodesFromMarkup", + "path": "node_modules/create-react-context/node_modules/fbjs/lib/createNodesFromMarkup.js", + "line": " !!!dummyNode ? process.env.NODE_ENV !== 'production' ? invariant(false, 'createNodesFromMarkup dummy not initialized') : invariant(false) : void 0;", + "lineNumber": 53, + "reasonCategory": "falseMatch", + "updated": "2019-04-26T19:18:14.550Z" + }, + { + "rule": "DOM-innerHTML", + "path": "node_modules/create-react-context/node_modules/fbjs/lib/createNodesFromMarkup.js", + "line": " node.innerHTML = wrap[1] + markup + wrap[2];", + "lineNumber": 58, + "reasonCategory": "falseMatch", + "updated": "2019-04-26T19:18:14.550Z" + }, + { + "rule": "DOM-innerHTML", + "path": "node_modules/create-react-context/node_modules/fbjs/lib/createNodesFromMarkup.js", + "line": " node.innerHTML = markup;", + "lineNumber": 65, + "reasonCategory": "falseMatch", + "updated": "2019-04-26T19:18:14.550Z" + }, + { + "rule": "fbjs-createNodesFromMarkup", + "path": "node_modules/create-react-context/node_modules/fbjs/lib/createNodesFromMarkup.js", + "line": " !handleScript ? process.env.NODE_ENV !== 'production' ? invariant(false, 'createNodesFromMarkup(...): Unexpected