Fix sticker creator in our new typescriptified world

This commit is contained in:
Scott Nonnenberg 2022-06-13 18:48:07 -07:00 committed by GitHub
parent f2af71f8b5
commit 68f27c1c7c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 381 additions and 311 deletions

View File

@ -24,7 +24,6 @@ js/WebAudioRecorderMp3.js
app/**/*.js
ts/**/*.js
sticker-creator/**/*.js
!sticker-creator/preload.js
**/*.d.ts
.eslintrc.js

1
.gitignore vendored
View File

@ -37,6 +37,7 @@ sticker-creator/**/*.js
# Sticker Creator
sticker-creator/dist/*
sticker-creator/**/*.js
# Editors
/.idea

View File

@ -3,6 +3,7 @@
# Generated files
app/**/*.js
sticker-creator/**/*.js
config/local-*.json
config/local.json
dist/**

View File

@ -68,7 +68,7 @@ esbuild.build({
format: 'cjs',
mainFields: ['browser', 'main'],
entryPoints: glob
.sync('{app,ts}/**/*.{ts,tsx}', {
.sync('{app,ts,sticker-creator}/**/*.{ts,tsx}', {
nodir: true,
root: ROOT_DIR,
})

View File

@ -4,7 +4,7 @@
.facade {
background: rgba(0, 0, 0, 0.33);
width: 100vw;
height: var(--window-height);
height: 100vh;
display: flex;
justify-content: center;
align-items: center;

View File

@ -44,5 +44,35 @@ export const _StickerFrame = (): JSX.Element => {
};
_StickerFrame.story = {
name: 'StickerFrame',
name: 'StickerFrame, add sticker',
};
export const EmojiSelectMode = (): JSX.Element => {
const image = text('image url', '/fixtures/512x515-thumbs-up-lincoln.webp');
const setSkinTone = action('setSkinTone');
const onRemove = action('onRemove');
const onDrop = action('onDrop');
const [emoji, setEmoji] = React.useState<EmojiPickDataType | undefined>(
undefined
);
return (
<StoryRow top>
<StickerFrame
id="1337"
emojiData={emoji}
image={image}
mode="pick-emoji"
onRemove={onRemove}
skinTone={0}
onSetSkinTone={setSkinTone}
onPickEmoji={e => setEmoji(e.emoji)}
onDrop={onDrop}
/>
</StoryRow>
);
};
EmojiSelectMode.story = {
name: 'StickerFrame, emoji select mode',
};

View File

@ -155,6 +155,7 @@ export const StickerFrame = React.memo(
return () => {
removeRoot(root);
setEmojiPopperRoot(null);
document.removeEventListener('click', handleOutsideClick);
};
}
@ -254,7 +255,7 @@ export const StickerFrame = React.memo(
</button>
)}
</PopperReference>
{emojiPickerOpen && onSetSkinTone && emojiPopperRoot
{emojiPickerOpen && emojiPopperRoot
? createPortal(
<Popper placement="bottom-start">
{({ ref, style }) => (

View File

@ -1,290 +1,11 @@
// Copyright 2019-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import PQueue from 'p-queue';
import Backbone from 'backbone';
import './window/phase1-dependencies';
import './window/phase2-signal';
import './window/phase3-sticker-functions';
import './window/phase4-theme';
import { ipcRenderer as ipc } from 'electron';
import sharp from 'sharp';
import pify from 'pify';
import { readFile } from 'fs';
import { noop, uniqBy } from 'lodash';
// It is important to call this as early as possible
import { SignalContext } from '../ts/windows/context';
import {
deriveStickerPackKey,
encryptAttachment,
getRandomBytes,
} from '../ts/Crypto';
import * as Bytes from '../ts/Bytes';
import { SignalService as Proto } from '../ts/protobuf';
import { getEnvironment } from '../ts/environment';
import { createSetting } from '../ts/util/preload';
import * as Attachments from '../ts/windows/attachments';
import * as Signal from '../ts/signal';
import { textsecure } from '../ts/textsecure';
import { initialize as initializeWebAPI } from '../ts/textsecure/WebAPI';
import { getAnimatedPngDataIfExists } from '../ts/util/getAnimatedPngDataIfExists';
const { config } = SignalContext;
window.i18n = SignalContext.i18n;
const STICKER_SIZE = 512;
const MIN_STICKER_DIMENSION = 10;
const MAX_STICKER_DIMENSION = STICKER_SIZE;
const MAX_STICKER_BYTE_LENGTH = 300 * 1024;
window.ROOT_PATH = window.location.href.startsWith('file') ? '../../' : '/';
window.getEnvironment = getEnvironment;
window.getVersion = () => window.SignalContext.config.version;
window.PQueue = PQueue;
window.Backbone = Backbone;
window.localeMessages = ipc.sendSync('locale-data');
require('../ts/SignalProtocolStore');
SignalContext.log.info('sticker-creator starting up...');
window.Signal = Signal.setup({
Attachments,
getRegionCode: () => {
throw new Error('Sticker Creator preload: Not implemented!');
},
logger: SignalContext.log,
userDataPath: SignalContext.config.userDataPath,
});
window.textsecure = textsecure;
const WebAPI = initializeWebAPI({
url: config.serverUrl,
storageUrl: config.storageUrl,
updatesUrl: config.updatesUrl,
directoryVersion: config.directoryVersion,
directoryUrl: config.directoryUrl,
directoryEnclaveId: config.directoryEnclaveId,
directoryTrustAnchor: config.directoryTrustAnchor,
directoryV2Url: config.directoryV2Url,
directoryV2PublicKey: config.directoryV2PublicKey,
directoryV2CodeHashes: config.directoryV2CodeHashes,
cdnUrlObject: {
0: config.cdnUrl0,
2: config.cdnUrl2,
},
certificateAuthority: config.certificateAuthority,
contentProxyUrl: config.contentProxyUrl,
proxyUrl: config.proxyUrl,
version: config.version,
});
function processStickerError(message: string, i18nKey: string): Error {
const result = new Error(message);
result.errorMessageI18nKey = i18nKey;
return result;
}
window.processStickerImage = async (path: string | undefined) => {
if (!path) {
throw new Error(`Path ${path} is not valid!`);
}
const imgBuffer = await pify(readFile)(path);
const sharpImg = sharp(imgBuffer);
const meta = await sharpImg.metadata();
const { width, height } = meta;
if (!width || !height) {
throw processStickerError(
'Sticker height or width were falsy',
'StickerCreator--Toasts--errorProcessing'
);
}
let contentType;
let processedBuffer;
// [Sharp doesn't support APNG][0], so we do something simpler: validate the file size
// and dimensions without resizing, cropping, or converting. In a perfect world, we'd
// resize and convert any animated image (GIF, animated WebP) to APNG.
// [0]: https://github.com/lovell/sharp/issues/2375
const animatedPngDataIfExists = getAnimatedPngDataIfExists(imgBuffer);
if (animatedPngDataIfExists) {
if (imgBuffer.byteLength > MAX_STICKER_BYTE_LENGTH) {
throw processStickerError(
'Sticker file was too large',
'StickerCreator--Toasts--tooLarge'
);
}
if (width !== height) {
throw processStickerError(
'Sticker must be square',
'StickerCreator--Toasts--APNG--notSquare'
);
}
if (width > MAX_STICKER_DIMENSION) {
throw processStickerError(
'Sticker dimensions are too large',
'StickerCreator--Toasts--APNG--dimensionsTooLarge'
);
}
if (width < MIN_STICKER_DIMENSION) {
throw processStickerError(
'Sticker dimensions are too small',
'StickerCreator--Toasts--APNG--dimensionsTooSmall'
);
}
if (animatedPngDataIfExists.numPlays !== Infinity) {
throw processStickerError(
'Animated stickers must loop forever',
'StickerCreator--Toasts--mustLoopForever'
);
}
contentType = 'image/png';
processedBuffer = imgBuffer;
} else {
contentType = 'image/webp';
processedBuffer = await sharpImg
.resize({
width: STICKER_SIZE,
height: STICKER_SIZE,
fit: 'contain',
background: { r: 0, g: 0, b: 0, alpha: 0 },
})
.webp()
.toBuffer();
if (processedBuffer.byteLength > MAX_STICKER_BYTE_LENGTH) {
throw processStickerError(
'Sticker file was too large',
'StickerCreator--Toasts--tooLarge'
);
}
}
return {
path,
buffer: processedBuffer,
src: `data:${contentType};base64,${processedBuffer.toString('base64')}`,
meta,
};
};
window.encryptAndUpload = async (
manifest,
stickers,
cover,
onProgress = noop
) => {
const usernameItem = await window.Signal.Data.getItemById('uuid_id');
const oldUsernameItem = await window.Signal.Data.getItemById('number_id');
const passwordItem = await window.Signal.Data.getItemById('password');
const username = usernameItem?.value || oldUsernameItem?.value;
if (!username || !passwordItem?.value) {
const { message } =
window.localeMessages['StickerCreator--Authentication--error'];
ipc.send('show-message-box', {
type: 'warning',
message,
});
throw new Error(message);
}
const { value: password } = passwordItem;
const packKey = getRandomBytes(32);
const encryptionKey = deriveStickerPackKey(packKey);
const iv = getRandomBytes(16);
const server = WebAPI.connect({
username,
password,
useWebSocket: false,
});
const uniqueStickers = uniqBy(
[...stickers, { imageData: cover }],
'imageData'
);
const manifestProto = new Proto.StickerPack();
manifestProto.title = manifest.title;
manifestProto.author = manifest.author;
manifestProto.stickers = stickers.map(({ emoji }, id) => {
const s = new Proto.StickerPack.Sticker();
s.id = id;
if (emoji) {
s.emoji = emoji;
}
return s;
});
const coverSticker = new Proto.StickerPack.Sticker();
coverSticker.id =
uniqueStickers.length === stickers.length ? 0 : uniqueStickers.length - 1;
coverSticker.emoji = '';
manifestProto.cover = coverSticker;
const encryptedManifest = await encrypt(
Proto.StickerPack.encode(manifestProto).finish(),
encryptionKey,
iv
);
const encryptedStickers = uniqueStickers.map(({ imageData }) => {
if (!imageData?.buffer) {
throw new Error('encryptStickers: Missing image data on sticker');
}
return encrypt(imageData.buffer, encryptionKey, iv);
});
const packId = await server.putStickers(
encryptedManifest,
encryptedStickers,
onProgress
);
const hexKey = Bytes.toHex(packKey);
ipc.send('install-sticker-pack', packId, hexKey);
return { packId, key: hexKey };
};
function encrypt(
data: Uint8Array,
key: Uint8Array,
iv: Uint8Array
): Uint8Array {
const { ciphertext } = encryptAttachment(data, key, iv);
return ciphertext;
}
const getThemeSetting = createSetting('themeSetting');
async function resolveTheme() {
const theme = (await getThemeSetting.getValue()) || 'system';
if (theme === 'system') {
return SignalContext.nativeThemeListener.getSystemTheme();
}
return theme;
}
async function applyTheme() {
window.document.body.classList.remove('dark-theme');
window.document.body.classList.remove('light-theme');
window.document.body.classList.add(`${await resolveTheme()}-theme`);
}
window.addEventListener('DOMContentLoaded', applyTheme);
SignalContext.nativeThemeListener.subscribe(() => applyTheme());
SignalContext.log.info('sticker-creator preload complete...');

View File

@ -0,0 +1,23 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import PQueue from 'p-queue';
import Backbone from 'backbone';
import { ipcRenderer as ipc } from 'electron';
// It is important to call this as early as possible
import { SignalContext } from '../../ts/windows/context';
import { getEnvironment } from '../../ts/environment';
SignalContext.log.info('sticker-creator starting up...');
window.ROOT_PATH = window.location.href.startsWith('file') ? '../../' : '/';
window.getEnvironment = getEnvironment;
window.getVersion = () => window.SignalContext.config.version;
window.PQueue = PQueue;
window.Backbone = Backbone;
window.localeMessages = ipc.sendSync('locale-data');

View File

@ -0,0 +1,20 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as Signal from '../../ts/signal';
import { textsecure } from '../../ts/textsecure';
import * as Attachments from '../../ts/windows/attachments';
import '../../ts/SignalProtocolStore';
import { SignalContext } from '../../ts/windows/context';
window.Signal = Signal.setup({
Attachments,
getRegionCode: () => {
throw new Error('Sticker Creator preload: Not implemented!');
},
logger: SignalContext.log,
userDataPath: SignalContext.config.userDataPath,
});
window.textsecure = textsecure;

View File

@ -0,0 +1,233 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import sharp from 'sharp';
import pify from 'pify';
import { readFile } from 'fs';
import { noop, uniqBy } from 'lodash';
import { ipcRenderer as ipc } from 'electron';
import {
deriveStickerPackKey,
encryptAttachment,
getRandomBytes,
} from '../../ts/Crypto';
import * as Bytes from '../../ts/Bytes';
import { SignalService as Proto } from '../../ts/protobuf';
import { initialize as initializeWebAPI } from '../../ts/textsecure/WebAPI';
import { SignalContext } from '../../ts/windows/context';
import { getAnimatedPngDataIfExists } from '../../ts/util/getAnimatedPngDataIfExists';
const STICKER_SIZE = 512;
const MIN_STICKER_DIMENSION = 10;
const MAX_STICKER_DIMENSION = STICKER_SIZE;
const MAX_STICKER_BYTE_LENGTH = 300 * 1024;
const { config } = SignalContext;
const WebAPI = initializeWebAPI({
url: config.serverUrl,
storageUrl: config.storageUrl,
updatesUrl: config.updatesUrl,
directoryVersion: config.directoryVersion,
directoryUrl: config.directoryUrl,
directoryEnclaveId: config.directoryEnclaveId,
directoryTrustAnchor: config.directoryTrustAnchor,
directoryV2Url: config.directoryV2Url,
directoryV2PublicKey: config.directoryV2PublicKey,
directoryV2CodeHashes: config.directoryV2CodeHashes,
cdnUrlObject: {
0: config.cdnUrl0,
2: config.cdnUrl2,
},
certificateAuthority: config.certificateAuthority,
contentProxyUrl: config.contentProxyUrl,
proxyUrl: config.proxyUrl,
version: config.version,
});
function processStickerError(message: string, i18nKey: string): Error {
const result = new Error(message);
result.errorMessageI18nKey = i18nKey;
return result;
}
window.processStickerImage = async (path: string | undefined) => {
if (!path) {
throw new Error(`Path ${path} is not valid!`);
}
const imgBuffer = await pify(readFile)(path);
const sharpImg = sharp(imgBuffer);
const meta = await sharpImg.metadata();
const { width, height } = meta;
if (!width || !height) {
throw processStickerError(
'Sticker height or width were falsy',
'StickerCreator--Toasts--errorProcessing'
);
}
let contentType;
let processedBuffer;
// [Sharp doesn't support APNG][0], so we do something simpler: validate the file size
// and dimensions without resizing, cropping, or converting. In a perfect world, we'd
// resize and convert any animated image (GIF, animated WebP) to APNG.
// [0]: https://github.com/lovell/sharp/issues/2375
const animatedPngDataIfExists = getAnimatedPngDataIfExists(imgBuffer);
if (animatedPngDataIfExists) {
if (imgBuffer.byteLength > MAX_STICKER_BYTE_LENGTH) {
throw processStickerError(
'Sticker file was too large',
'StickerCreator--Toasts--tooLarge'
);
}
if (width !== height) {
throw processStickerError(
'Sticker must be square',
'StickerCreator--Toasts--APNG--notSquare'
);
}
if (width > MAX_STICKER_DIMENSION) {
throw processStickerError(
'Sticker dimensions are too large',
'StickerCreator--Toasts--APNG--dimensionsTooLarge'
);
}
if (width < MIN_STICKER_DIMENSION) {
throw processStickerError(
'Sticker dimensions are too small',
'StickerCreator--Toasts--APNG--dimensionsTooSmall'
);
}
if (animatedPngDataIfExists.numPlays !== Infinity) {
throw processStickerError(
'Animated stickers must loop forever',
'StickerCreator--Toasts--mustLoopForever'
);
}
contentType = 'image/png';
processedBuffer = imgBuffer;
} else {
contentType = 'image/webp';
processedBuffer = await sharpImg
.resize({
width: STICKER_SIZE,
height: STICKER_SIZE,
fit: 'contain',
background: { r: 0, g: 0, b: 0, alpha: 0 },
})
.webp()
.toBuffer();
if (processedBuffer.byteLength > MAX_STICKER_BYTE_LENGTH) {
throw processStickerError(
'Sticker file was too large',
'StickerCreator--Toasts--tooLarge'
);
}
}
return {
path,
buffer: processedBuffer,
src: `data:${contentType};base64,${processedBuffer.toString('base64')}`,
meta,
};
};
window.encryptAndUpload = async (
manifest,
stickers,
cover,
onProgress = noop
) => {
const usernameItem = await window.Signal.Data.getItemById('uuid_id');
const oldUsernameItem = await window.Signal.Data.getItemById('number_id');
const passwordItem = await window.Signal.Data.getItemById('password');
const username = usernameItem?.value || oldUsernameItem?.value;
if (!username || !passwordItem?.value) {
const { message } =
window.localeMessages['StickerCreator--Authentication--error'];
ipc.send('show-message-box', {
type: 'warning',
message,
});
throw new Error(message);
}
const { value: password } = passwordItem;
const packKey = getRandomBytes(32);
const encryptionKey = deriveStickerPackKey(packKey);
const iv = getRandomBytes(16);
const server = WebAPI.connect({
username,
password,
useWebSocket: false,
});
const uniqueStickers = uniqBy(
[...stickers, { imageData: cover }],
'imageData'
);
const manifestProto = new Proto.StickerPack();
manifestProto.title = manifest.title;
manifestProto.author = manifest.author;
manifestProto.stickers = stickers.map(({ emoji }, id) => {
const s = new Proto.StickerPack.Sticker();
s.id = id;
if (emoji) {
s.emoji = emoji;
}
return s;
});
const coverSticker = new Proto.StickerPack.Sticker();
coverSticker.id =
uniqueStickers.length === stickers.length ? 0 : uniqueStickers.length - 1;
coverSticker.emoji = '';
manifestProto.cover = coverSticker;
const encryptedManifest = await encrypt(
Proto.StickerPack.encode(manifestProto).finish(),
encryptionKey,
iv
);
const encryptedStickers = uniqueStickers.map(({ imageData }) => {
if (!imageData?.buffer) {
throw new Error('encryptStickers: Missing image data on sticker');
}
return encrypt(imageData.buffer, encryptionKey, iv);
});
const packId = await server.putStickers(
encryptedManifest,
encryptedStickers,
onProgress
);
const hexKey = Bytes.toHex(packKey);
ipc.send('install-sticker-pack', packId, hexKey);
return { packId, key: hexKey };
};
function encrypt(
data: Uint8Array,
key: Uint8Array,
iv: Uint8Array
): Uint8Array {
const { ciphertext } = encryptAttachment(data, key, iv);
return ciphertext;
}

View File

@ -0,0 +1,25 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { createSetting } from '../../ts/util/preload';
import { SignalContext } from '../../ts/windows/context';
const getThemeSetting = createSetting('themeSetting');
async function resolveTheme() {
const theme = (await getThemeSetting.getValue()) || 'system';
if (theme === 'system') {
return SignalContext.nativeThemeListener.getSystemTheme();
}
return theme;
}
async function applyTheme() {
window.document.body.classList.remove('dark-theme');
window.document.body.classList.remove('light-theme');
window.document.body.classList.add(`${await resolveTheme()}-theme`);
}
window.addEventListener('DOMContentLoaded', applyTheme);
SignalContext.nativeThemeListener.subscribe(() => applyTheme());

View File

@ -34,7 +34,7 @@ export type OwnProps = {
readonly onPickEmoji: (o: EmojiPickDataType) => unknown;
readonly doSend?: () => unknown;
readonly skinTone?: number;
readonly onSetSkinTone: (tone: number) => unknown;
readonly onSetSkinTone?: (tone: number) => unknown;
readonly recentEmojis?: Array<string>;
readonly onClickSettings?: () => unknown;
readonly onClose?: () => unknown;
@ -406,26 +406,28 @@ export const EmojiPicker = React.memo(
type="button"
/>
)}
<div className="module-emoji-picker__footer__skin-tones">
{[0, 1, 2, 3, 4, 5].map(tone => (
<button
type="button"
key={tone}
data-tone={tone}
onClick={handlePickTone}
title={i18n('EmojiPicker--skin-tone', [`${tone}`])}
className={classNames(
'module-emoji-picker__button',
'module-emoji-picker__button--footer',
selectedTone === tone
? 'module-emoji-picker__button--selected'
: null
)}
>
<Emoji shortName="hand" skinTone={tone} size={20} />
</button>
))}
</div>
{onSetSkinTone ? (
<div className="module-emoji-picker__footer__skin-tones">
{[0, 1, 2, 3, 4, 5].map(tone => (
<button
type="button"
key={tone}
data-tone={tone}
onClick={handlePickTone}
title={i18n('EmojiPicker--skin-tone', [`${tone}`])}
className={classNames(
'module-emoji-picker__button',
'module-emoji-picker__button--footer',
selectedTone === tone
? 'module-emoji-picker__button--selected'
: null
)}
>
<Emoji shortName="hand" skinTone={tone} size={20} />
</button>
))}
</div>
) : null}
{Boolean(onClickSettings) && (
<div className="module-emoji-picker__footer__settings-spacer" />
)}

View File

@ -913,8 +913,7 @@
"path": "node_modules/agent-base/node_modules/debug/src/common.js",
"line": "\tcreateDebug.enable(createDebug.load());",
"reasonCategory": "falseMatch|testCode|exampleCode|otherUtilityCode|regexMatchedSafeCode|notExercisedByOurApp|ruleNeeded|usageTrusted",
"updated": "2022-02-11T21:58:24.827Z",
"reasonDetail": "<optional>"
"updated": "2022-02-11T21:58:24.827Z"
},
{
"rule": "jQuery-load(",
@ -8273,6 +8272,13 @@
"reasonCategory": "falseMatch",
"updated": "2018-09-19T21:59:32.770Z"
},
{
"rule": "React-useRef",
"path": "sticker-creator/components/StickerFrame.js",
"line": " const timerRef = React.useRef();",
"reasonCategory": "falseMatch",
"updated": "2022-06-14T01:19:45.446Z"
},
{
"rule": "React-useRef",
"path": "sticker-creator/components/StickerFrame.tsx",
@ -8280,6 +8286,13 @@
"reasonCategory": "usageTrusted",
"updated": "2021-07-30T16:57:33.618Z"
},
{
"rule": "jQuery-$(",
"path": "sticker-creator/util/i18n.js",
"line": " const FIND_REPLACEMENTS = /\\$([^$]+)\\$/g;",
"reasonCategory": "falseMatch",
"updated": "2022-06-14T01:19:45.446Z"
},
{
"rule": "jQuery-$(",
"path": "sticker-creator/util/i18n.tsx",

View File

@ -129,3 +129,4 @@ export const SignalContext: SignalContextType = {
};
window.SignalContext = SignalContext;
window.i18n = SignalContext.i18n;