Sticker Creator

This commit is contained in:
Ken Powers 2019-12-17 15:25:57 -05:00 committed by Scott Nonnenberg
parent 2df1ba6e61
commit 11d47a8eb9
123 changed files with 11287 additions and 1714 deletions

8
.babelrc.js Normal file
View File

@ -0,0 +1,8 @@
module.exports = {
presets: ['@babel/preset-react', '@babel/preset-typescript'],
plugins: [
'react-hot-loader/babel',
'lodash',
'@babel/plugin-proposal-class-properties',
],
};

View File

@ -12,6 +12,7 @@ js/libsignal-protocol-worker.js
libtextsecure/components.js
libtextsecure/test/test.js
test/test.js
sticker-creator/dist/**
# Third-party files
js/Mp3LameEncoder.min.js

6
.gitignore vendored
View File

@ -27,3 +27,9 @@ test/test.js
# React / TypeScript
ts/**/*.js
ts/protobuf/*.d.ts
# CSS Modules
**/*.scss.d.ts
# Sticker Creator
sticker-creator/dist/*

View File

@ -17,6 +17,7 @@ ts/protobuf/*.d.ts
ts/protobuf/*.js
stylesheets/manifest.css
ts/util/lint/exceptions.json
sticker-creator/dist/**
# Third-party files
node_modules/**

2
.storybook/addons.js Normal file
View File

@ -0,0 +1,2 @@
import '@storybook/addon-knobs/register';
import '@storybook/addon-actions/register';

37
.storybook/config.js Normal file
View File

@ -0,0 +1,37 @@
import * as React from 'react';
import { addDecorator, configure } from '@storybook/react';
import { withKnobs } from '@storybook/addon-knobs';
import classnames from 'classnames';
import * as styles from './styles.scss';
import messages from '../_locales/en/messages.json';
import { I18n } from '../sticker-creator/util/i18n';
addDecorator(withKnobs);
addDecorator((storyFn /* , context */) => {
const contents = storyFn();
return (
<div className={styles.container}>
<div className={styles.panel}>{contents}</div>
<div className={classnames(styles.darkTheme, styles.panel, 'dark-theme')}>
{contents}
</div>
</div>
);
});
// Hack to enable hooks in stories: https://github.com/storybookjs/storybook/issues/5721#issuecomment-473869398
addDecorator(Story => <Story />);
addDecorator(story => <I18n messages={messages}>{story()}</I18n>);
configure(() => {
// Load sticker creator stories
const stickerCreatorContext = require.context(
'../sticker-creator',
true,
/\.stories\.tsx?$/
);
stickerCreatorContext.keys().forEach(f => stickerCreatorContext(f));
}, module);

View File

@ -0,0 +1,2 @@
<!-- prettier-ignore -->
<link rel="stylesheet" href="../stylesheets/manifest_bridge.css" />

21
.storybook/styles.scss Normal file
View File

@ -0,0 +1,21 @@
.container {
display: flex;
flex-direction: row;
align-items: stretch;
align-content: stretch;
width: 100vw;
height: 100vh;
}
.panel {
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-around;
align-items: center;
padding: 16px;
}
.dark-theme {
background-color: #17191d;
}

View File

@ -0,0 +1,25 @@
module.exports = ({ config }) => {
config.entry.unshift(
'!!style-loader!css-loader!sanitize.css',
'!!style-loader!css-loader!typeface-inter'
);
config.module.rules.unshift(
{
test: /\.tsx?$/,
loader: 'babel-loader',
},
{
test: /\.scss$/,
loaders: [
'style-loader',
'css-loader?modules=true&localsConvention=camelCaseOnly',
'sass-loader',
],
}
);
config.resolve.extensions = ['.tsx', '.ts', '.jsx', '.js'];
return config;
};

View File

@ -56,6 +56,7 @@ npm install --global yarn # (only if you dont already have `yarn`)
yarn install --frozen-lockfile # Install and build dependencies (this will take a while)
yarn grunt # Generate final JS and CSS assets
yarn icon-gen # Generate full set of icons for Electron
yarn build:webpack # Build parts of the app that use webpack (Sticker Creator)
yarn test # A good idea to make sure tests run first
yarn start # Start Signal!
```
@ -76,6 +77,24 @@ while you make changes:
yarn grunt dev # runs until you stop it, re-generating built assets on file changes
```
### webpack
Some parts of the app (such as the Sticker Creator) have moved to webpack.
You can run a development server for these parts of the app with the
following command:
```
yarn dev
```
In order for the app to make requests to the development server you must set
the `SIGNAL_ENABLE_HTTP` environment variable to a truthy value. On Linux and
macOS, that simply looks like this:
```
SIGNAL_ENABLE_HTTP=1 yarn start
```
## Setting up standalone
By default the application will connect to the **staging** servers, which means that you
@ -261,7 +280,7 @@ To test changes to the build system, build a release using
```
yarn generate
yarn build-release
yarn build
```
Then, run the tests using `grunt test-release:osx --dir=release`, replacing `osx` with `linux` or `win` depending on your platform.

View File

@ -98,6 +98,7 @@ module.exports = grunt => {
dev: {
files: {
'stylesheets/manifest.css': 'stylesheets/manifest.scss',
'stylesheets/manifest_bridge.css': 'stylesheets/manifest_bridge.scss',
},
},
},

View File

@ -28,6 +28,11 @@
"description":
"The label that is used for the File menu in the program main menu. The '&' indicates that the following letter will be used as the keyboard 'shortcut letter' for accessing the menu with the Alt-<letter> combination."
},
"mainMenuCreateStickers": {
"message": "Create/upload sticker pack",
"description":
"The label that is used for the Create/upload sticker pack option in the File menu in the program main menu. The '&' indicates that the following letter will be used as the keyboard 'shortcut letter' for accessing the menu with the Alt-<letter> combination."
},
"mainMenuEdit": {
"message": "&Edit",
"description":
@ -716,6 +721,11 @@
"description":
"Title of the window that pops up with Signal Desktop preferences in it"
},
"signalDesktopStickerCreator": {
"message": "Sticker pack creator",
"description":
"Title of the window that pops up with Signal Desktop preferences in it"
},
"aboutSignalDesktop": {
"message": "About Signal Desktop",
"description": "Item under the Help menu, which opens a small about window"
@ -2142,5 +2152,216 @@
"message": "Conversation returned to inbox",
"description":
"A toast that shows up when the user unarchives a conversation"
},
"StickerCreator--title": {
"message": "Sticker pack creator",
"description": "The title of the Sticker Pack Creator window"
},
"StickerCreator--DropZone--staticText": {
"message": "Click to add or drop images here",
"description":
"Text which appears on the Sticker Creator drop zone when there is no active drag"
},
"StickerCreator--DropZone--activeText": {
"message": "Drop images here",
"description":
"Text which appears on the Sticker Creator drop zone when there is an active drag"
},
"StickerCreator--Preview--title": {
"message": "Sticker pack",
"description": "The 'title' of the sticker pack preview 'modal'"
},
"StickerCreator--ConfirmDialog--cancel": {
"message": "Cancel",
"description": "The default text for the confirm dialog cancel button"
},
"StickerCreator--CopyText--button": {
"message": "Copy",
"description":
"The text which appears on the copy button for the sticker creator share screen"
},
"StickerCreator--ShareButtons--facebook": {
"message": "Facebook",
"description": "Title for Facebook button"
},
"StickerCreator--ShareButtons--twitter": {
"message": "Twitter",
"description": "Title for Twitter button"
},
"StickerCreator--ShareButtons--pinterest": {
"message": "Pinterest",
"description": "Title for Pinterest button"
},
"StickerCreator--ShareButtons--whatsapp": {
"message": "WhatsApp",
"description": "Title for WhatsApp button"
},
"StickerCreator--AppStage--next": {
"message": "Next",
"description":
"Default text for the next button on all stages of the sticker creator"
},
"StickerCreator--AppStage--prev": {
"message": "Back",
"description":
"Default text for the previous button on all stages of the sticker creator"
},
"StickerCreator--DropStage--title": {
"message": "Add your stickers",
"description": "Title for the drop stage of the sticker creator"
},
"StickerCreator--DropStage--help": {
"message":
"Stickers must be in PNG format with a transparent background and 512x512 pixels. Recommended margin is 16px.",
"description": "Help text for the drop stage of the sticker creator"
},
"StickerCreator--DropStage--showMargins": {
"message": "Show margins",
"description":
"Text for the show margins toggle on the drop stage of the sticker creator"
},
"StickerCreator--EmojiStage--title": {
"message": "Add an emoji to each sticker",
"description": "Title for the drop stage of the sticker creator"
},
"StickerCreator--EmojiStage--help": {
"message": "This allows us to suggest stickers to you as you're messaging.",
"description": "Help text for the drop stage of the sticker creator"
},
"StickerCreator--MetaStage--title": {
"message": "Just a few more details...",
"description": "Title for the meta stage of the sticker creator"
},
"StickerCreator--MetaStage--Field--title": {
"message": "Title",
"description":
"Label for the title input of the meta stage of the sticker creator"
},
"StickerCreator--MetaStage--Field--author": {
"message": "Author",
"description":
"Label for the author input of the meta stage of the sticker creator"
},
"StickerCreator--MetaStage--Field--cover": {
"message": "Cover image",
"description":
"Label for the cover image picker of the meta stage of the sticker creator"
},
"StickerCreator--MetaStage--Field--cover--help": {
"message":
"This is the image that will show up when you share your sticker pack",
"description":
"Help text for the cover image picker of the meta stage of the sticker creator"
},
"StickerCreator--MetaStage--ConfirmDialog--title": {
"message": "Are you sure you want to upload your sticker pack?",
"description":
"Title for the confirm dialog on the meta stage of the sticker creator"
},
"StickerCreator--MetaStage--ConfirmDialog--confirm": {
"message": "Upload",
"description":
"Text for the upload button in the confirmation dialog on the meta stage of the sticker creator"
},
"StickerCreator--MetaStage--ConfirmDialog--text": {
"message":
"You will no longer be able to make edits or delete after creating a sticker pack.",
"description":
"The text inside the confirmation dialog on the meta stage of the sticker creator"
},
"StickerCreator--UploadStage--title": {
"message": "Creating your sticker pack",
"description": "Title for the upload stage of the sticker creator"
},
"StickerCreator--UploadStage-uploaded": {
"message": "$count$ of $total$ uploaded",
"description": "Title for the upload stage of the sticker creator",
"placeholders": {
"count": {
"content": "$1",
"example": "3"
},
"total": {
"content": "$2",
"example": "20"
}
}
},
"StickerCreator--ShareStage--title": {
"message": "Congratulations! You created a sticker pack.",
"description": "Title for the share stage of the sticker creator"
},
"StickerCreator--ShareStage--help": {
"message":
"Access your new stickers through the sticker icon, or share with your friends using the link below.",
"description": "Help text for the share stage of the sticker creator"
},
"StickerCreator--ShareStage--callToAction": {
"message":
"Use the hashtag $hashtag$ to help other people find the URLs for any custom sticker packs that you would like to make publicly accessible.",
"description":
"Call to action text for the share stage of the sticker creator",
"placeholders": {
"hashtag": {
"content": "$1",
"example": "<strong>#makeprivacystick</strong>"
}
}
},
"StickerCreator--ShareStage--copyTitle": {
"message": "Sticker Pack URL",
"description":
"Title for the copy button on the share stage of the sticker creator"
},
"StickerCreator--ShareStage--close": {
"message": "Close",
"description":
"Text for the close button on the share stage of the sticker creator"
},
"StickerCreator--ShareStage--createAnother": {
"message": "Create another sticker pack",
"description":
"Text for the create another sticker pack button on the share stage of the sticker creator"
},
"StickerCreator--ShareStage--socialMessage": {
"message":
"Check out this new sticker pack I created for Signal. #makeprivacystick",
"description":
"Text which is shared to social media platforms for sticker packs"
},
"StickerCreator--Toasts--imagesAdded": {
"message": "$count$ image(s) added",
"description":
"Text for the toast when images are added to the sticker creator",
"placeholders": {
"count": {
"content": "$1",
"example": "3"
}
}
},
"StickerCreator--Toasts--tooLarge": {
"message": "Dropped image is too large",
"description":
"Text for the toast when an image that is too large was dropped"
},
"StickerCreator--Toasts--linkedCopied": {
"message": "Link copied",
"description":
"Text for the toast when a link for sharing is copied from the Sticker Creator"
},
"StickerCreator--StickerPreview--light": {
"message": "My sticker in light theme",
"description": "Text for the sticker preview for the light theme"
},
"StickerCreator--StickerPreview--dark": {
"message": "My sticker in dark theme",
"description": "Text for the sticker preview for the dark theme"
},
"StickerCreator--Authentication--error": {
"message":
"Please set up Signal on your phone and desktop to use the Sticker Pack Creator",
"description":
"The error message which appears when the user has not linked their account and attempts to use the Sticker Creator"
}
}

View File

@ -1,11 +1,10 @@
const path = require('path');
const electronIsDev = require('electron-is-dev');
const { app } = require('electron');
let environment;
// In production mode, NODE_ENV cannot be customized by the user
if (electronIsDev) {
if (!app.isPackaged) {
environment = process.env.NODE_ENV || 'development';
} else {
environment = 'production';
@ -24,12 +23,14 @@ if (environment === 'production') {
process.env.ALLOW_CONFIG_MUTATIONS = '';
process.env.SUPPRESS_NO_CONFIG_WARNING = '';
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '';
process.env.SIGNAL_ENABLE_HTTP = '';
}
// We load config after we've made our modifications to NODE_ENV
const config = require('config');
config.environment = environment;
config.enableHttp = process.env.SIGNAL_ENABLE_HTTP;
// Log resulting env vars in use by config
[
@ -40,6 +41,7 @@ config.environment = environment;
'HOSTNAME',
'NODE_APP_INSTANCE',
'SUPPRESS_NO_CONFIG_WARNING',
'SIGNAL_ENABLE_HTTP',
].forEach(s => {
console.log(`${s} ${config.util.getEnv(s)}`);
});

View File

@ -19,12 +19,17 @@ exports.createTemplate = (options, messages) => {
showDebugLog,
showKeyboardShortcuts,
showSettings,
showStickerCreator,
} = options;
const template = [
{
label: messages.mainMenuFile.message,
submenu: [
{
label: messages.mainMenuCreateStickers.message,
click: showStickerCreator,
},
{
label: messages.mainMenuSettings.message,
accelerator: 'CommandOrControl+,',
@ -199,49 +204,18 @@ exports.createTemplate = (options, messages) => {
};
function updateForMac(template, messages, options) {
const {
includeSetup,
setupAsNewDevice,
setupAsStandalone,
setupWithImport,
showAbout,
showSettings,
showWindow,
} = options;
const { showAbout, showSettings, showWindow } = options;
// Remove About item and separator from Help menu, since it's on the first menu
// Remove About item and separator from Help menu, since they're in the app menu
template[4].submenu.pop();
template[4].submenu.pop();
// Remove File menu
template.shift();
if (includeSetup) {
// Add a File menu just for these setup options. Because we're using unshift(), we add
// the file menu first, though it ends up to the right of the Signal Desktop menu.
const fileMenu = {
label: messages.mainMenuFile.message,
submenu: [
{
label: messages.menuSetupWithImport.message,
click: setupWithImport,
},
{
label: messages.menuSetupAsNewDevice.message,
click: setupAsNewDevice,
},
],
};
if (options.development) {
fileMenu.submenu.push({
label: messages.menuSetupAsStandalone.message,
click: setupAsStandalone,
});
}
template.unshift(fileMenu);
}
// Remove preferences, separator, and quit from the File menu, since they're
// in the app menu
const fileMenu = template[0];
fileMenu.submenu.pop();
fileMenu.submenu.pop();
fileMenu.submenu.pop();
// Add the OSX-specific Signal Desktop menu at the far left
template.unshift({
@ -285,8 +259,7 @@ function updateForMac(template, messages, options) {
});
// Add to Edit menu
const editIndex = includeSetup ? 2 : 1;
template[editIndex].submenu.push(
template[2].submenu.push(
{
type: 'separator',
},
@ -306,9 +279,8 @@ function updateForMac(template, messages, options) {
);
// Replace Window menu
const windowMenuTemplateIndex = includeSetup ? 4 : 3;
// eslint-disable-next-line no-param-reassign
template[windowMenuTemplateIndex].submenu = [
template[4].submenu = [
{
label: messages.windowMenuClose.message,
accelerator: 'CmdOrCtrl+W',

View File

@ -75,7 +75,7 @@ function _disabledHandler(request, callback) {
return callback();
}
function installWebHandler({ protocol }) {
function installWebHandler({ protocol, enableHttp }) {
protocol.interceptFileProtocol('about', _disabledHandler);
protocol.interceptFileProtocol('content', _disabledHandler);
protocol.interceptFileProtocol('chrome', _disabledHandler);
@ -84,12 +84,15 @@ function installWebHandler({ protocol }) {
protocol.interceptFileProtocol('filesystem', _disabledHandler);
protocol.interceptFileProtocol('ftp', _disabledHandler);
protocol.interceptFileProtocol('gopher', _disabledHandler);
protocol.interceptFileProtocol('http', _disabledHandler);
protocol.interceptFileProtocol('https', _disabledHandler);
protocol.interceptFileProtocol('javascript', _disabledHandler);
protocol.interceptFileProtocol('mailto', _disabledHandler);
protocol.interceptFileProtocol('ws', _disabledHandler);
protocol.interceptFileProtocol('wss', _disabledHandler);
if (!enableHttp) {
protocol.interceptFileProtocol('http', _disabledHandler);
protocol.interceptFileProtocol('https', _disabledHandler);
protocol.interceptFileProtocol('ws', _disabledHandler);
protocol.interceptFileProtocol('wss', _disabledHandler);
}
}
module.exports = {

View File

@ -399,6 +399,12 @@
),
});
},
installStickerPack: async (packId, key) => {
window.Signal.Stickers.downloadStickerPack(packId, key, {
finalStatus: 'installed',
});
},
};
if (isIndexedDBPresent) {

View File

@ -398,6 +398,7 @@ const URL_CALLS = {
messages: 'v1/messages',
profile: 'v1/profile',
signed: 'v2/keys/signed',
getStickerPackUpload: 'v1/sticker/pack/form',
};
module.exports = {
@ -457,6 +458,7 @@ function initialize({
getStickerPackManifest,
makeProxiedRequest,
putAttachment,
putStickers,
registerKeys,
registerSupportForUnauthenticatedDelivery,
removeSignalingKey,
@ -865,35 +867,10 @@ function initialize({
});
}
async function getAttachment(id) {
// This is going to the CDN, not the service, so we use _outerAjax
return _outerAjax(`${cdnUrl}/attachments/${id}`, {
certificateAuthority,
proxyUrl,
responseType: 'arraybuffer',
timeout: 0,
type: 'GET',
});
}
async function putAttachment(encryptedBin) {
const response = await _ajax({
call: 'attachmentId',
httpType: 'GET',
responseType: 'json',
});
const {
key,
credential,
acl,
algorithm,
date,
policy,
signature,
attachmentIdString,
} = response;
function makePutParams(
{ key, credential, acl, algorithm, date, policy, signature },
encryptedBin
) {
// Note: when using the boundary string in the POST body, it needs to be prefixed by
// an extra --, and the final boundary string at the end gets a -- prefix and a --
// suffix.
@ -932,17 +909,92 @@ function initialize({
contentLength
);
// This is going to the CDN, not the service, so we use _outerAjax
await _outerAjax(`${cdnUrl}/attachments/`, {
certificateAuthority,
contentType: `multipart/form-data; boundary=${boundaryString}`,
return {
data,
proxyUrl,
timeout: 0,
type: 'POST',
contentType: `multipart/form-data; boundary=${boundaryString}`,
headers: {
'Content-Length': contentLength,
},
};
}
async function putStickers(
encryptedManifest,
encryptedStickers,
onProgress
) {
// Get manifest and sticker upload parameters
const { packId, manifest, stickers } = await _ajax({
call: 'getStickerPackUpload',
responseType: 'json',
type: 'GET',
urlParameters: `/${encryptedStickers.length}`,
});
// Upload manifest
const manifestParams = makePutParams(manifest, encryptedManifest);
// This is going to the CDN, not the service, so we use _outerAjax
await _outerAjax(`${cdnUrl}/`, {
...manifestParams,
key: 'stickers/asdfasdf/manifest.proto',
certificateAuthority,
proxyUrl,
timeout: 0,
type: 'POST',
processData: false,
});
// Upload stickers
await Promise.all(
stickers.map(async (s, id) => {
const stickerParams = makePutParams(s, encryptedStickers[id]);
await _outerAjax(`${cdnUrl}/`, {
...stickerParams,
certificateAuthority,
proxyUrl,
timeout: 0,
type: 'POST',
processData: false,
});
if (onProgress) {
onProgress();
}
})
);
// Done!
return packId;
}
async function getAttachment(id) {
// This is going to the CDN, not the service, so we use _outerAjax
return _outerAjax(`${cdnUrl}/attachments/${id}`, {
certificateAuthority,
proxyUrl,
responseType: 'arraybuffer',
timeout: 0,
type: 'GET',
});
}
async function putAttachment(encryptedBin) {
const response = await _ajax({
call: 'attachmentId',
httpType: 'GET',
responseType: 'json',
});
const { attachmentIdString } = response;
const params = makePutParams(response, encryptedBin);
// This is going to the CDN, not the service, so we use _outerAjax
await _outerAjax(`${cdnUrl}/attachments/`, {
...params,
certificateAuthority,
proxyUrl,
timeout: 0,
type: 'POST',
processData: false,
});

100
main.js
View File

@ -20,6 +20,7 @@ const getRealPath = pify(fs.realpath);
const {
app,
BrowserWindow,
dialog,
ipcMain: ipc,
Menu,
protocol: electronProtocol,
@ -141,9 +142,11 @@ let logger;
let locale;
function prepareURL(pathSegments, moreKeys) {
const parsed = url.parse(path.join(...pathSegments));
return url.format({
pathname: path.join.apply(null, pathSegments),
protocol: 'file:',
...parsed,
protocol: parsed.protocol || 'file:',
slashes: true,
query: {
name: packageJson.productName,
@ -168,8 +171,10 @@ function prepareURL(pathSegments, moreKeys) {
async function handleUrl(event, target) {
event.preventDefault();
const { protocol } = url.parse(target);
if (protocol === 'http:' || protocol === 'https:') {
const { protocol, hostname } = url.parse(target);
const isDevServer = config.enableHttp && hostname === 'localhost';
// We only want to specially handle urls that aren't requesting the dev server
if ((protocol === 'http:' || protocol === 'https:') && !isDevServer) {
try {
await shell.openExternal(target);
} catch (error) {
@ -557,6 +562,81 @@ async function showSettingsWindow() {
});
}
async function getIsLinked() {
try {
const number = await sql.getItemById('number_id');
const password = await sql.getItemById('password');
return Boolean(number && password);
} catch (e) {
return false;
}
}
let stickerCreatorWindow;
async function showStickerCreator() {
if (!await getIsLinked()) {
const { message } = locale.messages[
'StickerCreator--Authentication--error'
];
dialog.showMessageBox({
type: 'warning',
message,
});
return;
}
if (stickerCreatorWindow) {
stickerCreatorWindow.show();
return;
}
const { x = 0, y = 0 } = windowConfig || {};
const options = {
x: x + 100,
y: y + 100,
width: 800,
minWidth: 800,
height: 650,
title: locale.messages.signalDesktopStickerCreator,
autoHideMenuBar: true,
backgroundColor: '#2090EA',
show: false,
webPreferences: {
nodeIntegration: false,
nodeIntegrationInWorker: false,
contextIsolation: false,
preload: path.join(__dirname, 'sticker-creator/preload.js'),
nativeWindowOpen: true,
},
};
stickerCreatorWindow = new BrowserWindow(options);
captureClicks(stickerCreatorWindow);
const appUrl = config.enableHttp
? prepareURL(['http://localhost:6380/sticker-creator/dist/index.html'])
: prepareURL([__dirname, 'sticker-creator/dist/index.html']);
stickerCreatorWindow.loadURL(appUrl);
stickerCreatorWindow.on('closed', () => {
stickerCreatorWindow = null;
});
stickerCreatorWindow.once('ready-to-show', () => {
stickerCreatorWindow.show();
if (config.get('openDevTools')) {
// Open the DevTools.
stickerCreatorWindow.webContents.openDevTools();
}
});
}
let debugLogWindow;
async function showDebugLogWindow() {
if (debugLogWindow) {
@ -672,6 +752,7 @@ app.on('ready', async () => {
}
installWebHandler({
enableHttp: config.enableHttp,
protocol: electronProtocol,
});
@ -778,13 +859,15 @@ app.on('ready', async () => {
function setupMenu(options) {
const { platform } = process;
const menuOptions = Object.assign({}, options, {
const menuOptions = {
...options,
development,
showDebugLog: showDebugLogWindow,
showKeyboardShortcuts,
showWindow,
showAbout,
showSettings: showSettingsWindow,
showStickerCreator,
openReleaseNotes,
openNewBugForm,
openSupportPage,
@ -793,7 +876,7 @@ function setupMenu(options) {
setupWithImport,
setupAsNewDevice,
setupAsStandalone,
});
};
const template = createTemplate(menuOptions, locale.messages);
const menu = Menu.buildFromTemplate(template);
Menu.setApplicationMenu(menu);
@ -1090,3 +1173,8 @@ function handleSgnlLink(incomingUrl) {
console.error('Unhandled sgnl link');
}
}
ipc.on('install-sticker-pack', (_event, packId, packKeyHex) => {
const packKey = Buffer.from(packKeyHex, 'hex').toString('base64');
mainWindow.webContents.send('install-sticker-pack', { packId, packKey });
});

View File

@ -17,8 +17,7 @@
"grunt": "grunt",
"icon-gen": "electron-icon-maker --input=images/icon_1024.png --output=./build",
"generate": "yarn icon-gen && yarn grunt",
"build": "electron-builder --config.extraMetadata.environment=$SIGNAL_ENV",
"build-release": "SIGNAL_ENV=production npm run build -- --config.directories.output=release",
"build-release": "npm run build",
"sign-release": "node ts/updater/generateSignature.js",
"notarize": "node ts/build/notarize.js",
"build-module-protobuf": "pbjs --target static-module --wrap commonjs --out ts/protobuf/compiled.js protos/*.proto && pbts --out ts/protobuf/compiled.d.ts ts/protobuf/compiled.js",
@ -42,11 +41,25 @@
"clean-transpile": "rimraf ts/**/*.js && rimraf ts/*.js",
"open-coverage": "open coverage/lcov-report/index.html",
"styleguide": "styleguidist server",
"ready": "yarn clean-transpile && yarn grunt && yarn lint && yarn test-node && yarn test-electron && yarn lint-deps"
"ready": "yarn clean-transpile && yarn grunt && yarn lint && yarn test-node && yarn test-electron && yarn lint-deps",
"dev": "run-p --print-label dev:*",
"dev:webpack": "NODE_ENV=development webpack-dev-server --hot",
"dev:typed-scss": "yarn build:typed-scss -w",
"dev:storybook": "start-storybook -p 6006 -s ./",
"build": "run-s --print-label build:grunt build:typed-scss build:webpack build:release",
"build:grunt": "yarn grunt",
"build:typed-scss": "tsm sticker-creator",
"build:webpack": "cross-env NODE_ENV=production webpack",
"build:electron": "electron-builder --config.extraMetadata.environment=$SIGNAL_ENV",
"build:release": "cross-env SIGNAL_ENV=production npm run build:electron -- --config.directories.output=release",
"preverify:ts": "yarn build:typed-scss",
"verify": "run-p --print-label verify:*",
"verify:ts": "tsc --noEmit"
},
"dependencies": {
"@journeyapps/sqlcipher": "https://github.com/scottnonnenberg-signal/node-sqlcipher.git#00fd0f8a6623c6683280976d2a92b41d09c744bc",
"@sindresorhus/is": "0.8.0",
"array-move": "2.1.0",
"backbone": "1.3.3",
"blob-util": "1.3.0",
"blueimp-canvas-to-blob": "3.14.0",
@ -54,11 +67,11 @@
"bunyan": "1.8.12",
"classnames": "2.2.5",
"config": "1.28.1",
"copy-text-to-clipboard": "2.1.0",
"curve25519-n": "https://github.com/scottnonnenberg-signal/node-curve25519.git#1bd0580843dcf836284dee7f1c4dfb4c698f7969",
"draft-js": "0.10.5",
"electron-context-menu": "0.11.0",
"electron-editor-context-menu": "1.1.1",
"electron-is-dev": "0.3.0",
"electron-mocha": "8.1.1",
"electron-notarize": "0.1.1",
"emoji-datasource": "4.1.0",
@ -73,6 +86,7 @@
"google-libphonenumber": "3.2.6",
"got": "8.2.0",
"he": "1.2.0",
"history": "4.9.0",
"intl-tel-input": "12.1.15",
"jquery": "3.4.1",
"js-yaml": "3.13.1",
@ -94,22 +108,30 @@
"react": "16.8.3",
"react-contextmenu": "2.11.0",
"react-dom": "16.8.3",
"react-dropzone": "10.1.7",
"react-hot-loader": "4.12.11",
"react-measure": "2.3.0",
"react-popper": "1.3.3",
"react-redux": "6.0.1",
"react-redux": "7.1.0",
"react-router-dom": "5.0.1",
"react-sortable-hoc": "1.9.1",
"react-virtualized": "9.21.0",
"read-last-lines": "1.3.0",
"redux": "4.0.1",
"redux-logger": "3.0.6",
"redux-promise-middleware": "6.1.0",
"redux-ts-utils": "3.2.2",
"reselect": "4.0.0",
"rimraf": "2.6.2",
"sanitize.css": "11.0.0",
"semver": "5.4.1",
"sharp": "0.23.0",
"spellchecker": "3.7.0",
"tar": "4.4.8",
"testcheck": "1.0.0-rc.2",
"tmp": "0.0.33",
"to-arraybuffer": "1.0.1",
"typeface-inter": "^3.10.0",
"underscore": "1.9.0",
"uuid": "3.3.2",
"websocket": "1.0.28"
@ -118,6 +140,14 @@
"fbjs/isomorphic-fetch/node-fetch": "https://github.com/scottnonnenberg-signal/node-fetch.git#3e5f51e08c647ee5f20c43b15cf2d352d61c36b4"
},
"devDependencies": {
"@babel/core": "7.5.5",
"@babel/plugin-proposal-class-properties": "^7.7.4",
"@babel/preset-react": "7.0.0",
"@babel/preset-typescript": "7.3.3",
"@storybook/addon-actions": "5.1.11",
"@storybook/addon-knobs": "5.1.11",
"@storybook/addons": "5.1.11",
"@storybook/react": "5.1.11",
"@types/chai": "4.1.2",
"@types/classnames": "2.2.3",
"@types/config": "0.0.34",
@ -126,6 +156,8 @@
"@types/fs-extra": "5.0.5",
"@types/google-libphonenumber": "7.4.14",
"@types/got": "9.4.1",
"@types/history": "4.7.2",
"@types/html-webpack-plugin": "3.2.1",
"@types/jquery": "3.3.29",
"@types/js-yaml": "3.12.0",
"@types/linkify-it": "2.0.3",
@ -138,17 +170,29 @@
"@types/react": "16.8.5",
"@types/react-dom": "16.8.2",
"@types/react-measure": "2.0.5",
"@types/react-redux": "7.0.1",
"@types/react-redux": "7.1.2",
"@types/react-router-dom": "4.3.4",
"@types/react-sortable-hoc": "0.6.5",
"@types/react-virtualized": "9.18.12",
"@types/redux-logger": "3.0.7",
"@types/rimraf": "2.0.2",
"@types/semver": "5.5.0",
"@types/sinon": "4.3.1",
"@types/storybook__addon-actions": "3.4.3",
"@types/storybook__addon-knobs": "5.0.3",
"@types/storybook__react": "4.0.2",
"@types/uuid": "3.4.4",
"@types/webpack": "4.39.0",
"@types/webpack-dev-server": "3.1.7",
"arraybuffer-loader": "1.0.3",
"asar": "0.14.0",
"babel-core": "7.0.0-bridge.0",
"babel-loader": "8.0.6",
"babel-plugin-lodash": "3.3.4",
"bower": "1.8.2",
"chai": "4.1.2",
"cross-env": "5.2.0",
"css-loader": "3.2.0",
"dashdash": "1.14.1",
"electron": "6.1.4",
"electron-builder": "21.2.0",
@ -160,6 +204,7 @@
"eslint-plugin-mocha": "4.12.1",
"eslint-plugin-more": "0.3.1",
"extract-zip": "1.6.6",
"file-loader": "4.2.0",
"grunt": "1.0.1",
"grunt-cli": "1.2.0",
"grunt-contrib-concat": "1.0.1",
@ -168,24 +213,32 @@
"grunt-exec": "3.0.0",
"grunt-gitinfo": "0.1.7",
"grunt-sass": "3.0.1",
"html-webpack-plugin": "3.2.0",
"jsdoc": "3.6.2",
"mocha": "4.1.0",
"mocha-testcheck": "1.0.0-rc.0",
"node-sass": "4.12.0",
"node-sass-import-once": "1.2.0",
"npm-run-all": "4.1.5",
"nyc": "11.4.1",
"patch-package": "6.1.2",
"prettier": "1.12.0",
"react-docgen-typescript": "1.2.6",
"react-styleguidist": "7.0.1",
"sass-loader": "7.2.0",
"sinon": "4.4.2",
"spectron": "5.0.0",
"style-loader": "1.0.0",
"ts-loader": "4.1.0",
"ts-node": "8.3.0",
"tslint": "5.13.0",
"tslint-microsoft-contrib": "6.0.0",
"tslint-microsoft-contrib": "6.2.0",
"tslint-react": "3.6.0",
"typed-scss-modules": "0.0.11",
"typescript": "3.3.3333",
"webpack": "4.4.1"
"webpack": "4.39.2",
"webpack-cli": "3.3.7",
"webpack-dev-server": "3.8.0"
},
"engines": {
"node": "12.4.0"
@ -280,6 +333,7 @@
"!js/register.js",
"app/*",
"preload.js",
"preload_utils.js",
"about_preload.js",
"settings_preload.js",
"permissions_popup_preload.js",
@ -289,6 +343,8 @@
"fonts/**",
"build/assets",
"node_modules/**",
"sticker-creator/preload.js",
"sticker-creator/dist/**",
"!node_modules/emoji-datasource/emoji_pretty.json",
"!node_modules/emoji-datasource/*.png",
"!node_modules/emoji-datasource-apple/emoji_pretty.json",
@ -307,6 +363,7 @@
"node_modules/socks/build/common/*.js",
"node_modules/socks/build/client/*.js",
"node_modules/smart-buffer/build/*.js",
"node_modules/sharp/build/**",
"!node_modules/@journeyapps/sqlcipher/deps/*",
"!node_modules/@journeyapps/sqlcipher/build/*",
"!node_modules/@journeyapps/sqlcipher/lib/binding/node-*",

View File

@ -3,6 +3,7 @@
const { ipcRenderer, remote } = require('electron');
const url = require('url');
const i18n = require('./js/modules/i18n');
const { makeGetter, makeSetter } = require('./preload_utils');
const { systemPreferences } = remote.require('electron');
@ -43,31 +44,3 @@ window.getMediaPermissions = makeGetter('media-permissions');
window.setMediaPermissions = makeSetter('media-permissions');
window.getThemeSetting = makeGetter('theme-setting');
window.setThemeSetting = makeSetter('theme-setting');
function makeGetter(name) {
return () =>
new Promise((resolve, reject) => {
ipcRenderer.once(`get-success-${name}`, (event, error, value) => {
if (error) {
return reject(error);
}
return resolve(value);
});
ipcRenderer.send(`get-${name}`);
});
}
function makeSetter(name) {
return value =>
new Promise((resolve, reject) => {
ipcRenderer.once(`set-success-${name}`, (event, error) => {
if (error) {
return reject(error);
}
return resolve();
});
ipcRenderer.send(`set-${name}`, value);
});
}

View File

@ -3,6 +3,7 @@
const electron = require('electron');
const semver = require('semver');
const curve = require('curve25519-n');
const { installGetter, installSetter } = require('./preload_utils');
const { deferredToPromise } = require('./js/modules/deferred_to_promise');
@ -197,6 +198,14 @@ ipc.on('show-sticker-pack', (_event, info) => {
}
});
ipc.on('install-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) {
@ -216,49 +225,6 @@ ipc.on('get-ready-for-shutdown', async () => {
}
});
function installGetter(name, functionName) {
ipc.on(`get-${name}`, async () => {
const getFn = window.Events[functionName];
if (!getFn) {
ipc.send(
`get-success-${name}`,
`installGetter: ${functionName} not found for event ${name}`
);
return;
}
try {
ipc.send(`get-success-${name}`, null, await getFn());
} catch (error) {
ipc.send(
`get-success-${name}`,
error && error.stack ? error.stack : error
);
}
});
}
function installSetter(name, functionName) {
ipc.on(`set-${name}`, async (_event, value) => {
const setFn = window.Events[functionName];
if (!setFn) {
ipc.send(
`set-success-${name}`,
`installSetter: ${functionName} not found for event ${name}`
);
return;
}
try {
await setFn(value);
ipc.send(`set-success-${name}`);
} catch (error) {
ipc.send(
`set-success-${name}`,
error && error.stack ? error.stack : error
);
}
});
}
window.addSetupMenuItems = () => ipc.send('add-setup-menu-items');
window.removeSetupMenuItems = () => ipc.send('remove-setup-menu-items');

74
preload_utils.js Normal file
View File

@ -0,0 +1,74 @@
/* global window */
const { ipcRenderer: ipc } = require('electron');
exports.installGetter = function installGetter(name, functionName) {
ipc.on(`get-${name}`, async () => {
const getFn = window.Events[functionName];
if (!getFn) {
ipc.send(
`get-success-${name}`,
`installGetter: ${functionName} not found for event ${name}`
);
return;
}
try {
ipc.send(`get-success-${name}`, null, await getFn());
} catch (error) {
ipc.send(
`get-success-${name}`,
error && error.stack ? error.stack : error
);
}
});
};
exports.installSetter = function installSetter(name, functionName) {
ipc.on(`set-${name}`, async (_event, value) => {
const setFn = window.Events[functionName];
if (!setFn) {
ipc.send(
`set-success-${name}`,
`installSetter: ${functionName} not found for event ${name}`
);
return;
}
try {
await setFn(value);
ipc.send(`set-success-${name}`);
} catch (error) {
ipc.send(
`set-success-${name}`,
error && error.stack ? error.stack : error
);
}
});
};
exports.makeGetter = function makeGetter(name) {
return () =>
new Promise((resolve, reject) => {
ipc.once(`get-success-${name}`, (event, error, value) => {
if (error) {
return reject(error);
}
return resolve(value);
});
ipc.send(`get-${name}`);
});
};
exports.makeSetter = function makeSetter(name) {
return value =>
new Promise((resolve, reject) => {
ipc.once(`set-success-${name}`, (event, error) => {
if (error) {
return reject(error);
}
return resolve();
});
ipc.send(`set-${name}`, value);
});
};

View File

@ -0,0 +1,9 @@
@mixin light-theme() {
@content;
}
@mixin dark-theme() {
:global(.dark-theme) & {
@content;
}
}

View File

@ -0,0 +1,16 @@
@import '../mixins';
@import '../../stylesheets/variables';
.container {
display: grid;
height: 100vh;
grid-template-rows: 47px calc(100vh - 47px - 68px) 68px;
@include light-theme() {
background-color: $color-white;
}
@include dark-theme() {
background-color: $color-gray-90;
}
}

View File

@ -0,0 +1,38 @@
import * as React from 'react';
import { Redirect, Route, Switch } from 'react-router-dom';
import { DropStage } from './stages/DropStage';
import { EmojiStage } from './stages/EmojiStage';
import { UploadStage } from './stages/UploadStage';
import { MetaStage } from './stages/MetaStage';
import { ShareStage } from './stages/ShareStage';
import * as styles from './index.scss';
import { PageHeader } from '../elements/PageHeader';
import { useI18n } from '../util/i18n';
export const App = () => {
const i18n = useI18n();
return (
<div className={styles.container}>
<PageHeader>{i18n('StickerCreator--title')}</PageHeader>
<Switch>
<Route path="/drop">
<DropStage />
</Route>
<Route path="/add-emojis">
<EmojiStage />
</Route>
<Route path="/add-meta">
<MetaStage />
</Route>
<Route path="/upload">
<UploadStage />
</Route>
<Route path="/share">
<ShareStage />
</Route>
<Redirect to="/drop" />
</Switch>
</div>
);
};

View File

@ -0,0 +1,41 @@
.padded {
padding: 0 16px;
}
.main {
composes: padded;
padding-top: 16px;
display: grid;
height: 100%;
grid-template-rows: 26px 36px calc(100% - 26px - 36px);
overflow: auto;
}
.no-message {
composes: main;
grid-template-rows: 26px calc(100% - 26px);
}
.empty {
composes: main;
grid-template-rows: 100%;
}
.footer {
composes: padded;
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
}
.button {
margin-left: 12px;
}
.toaster {
position: fixed;
bottom: 16px;
left: 50%;
transform: translate(-50%, 0px);
}

View File

@ -0,0 +1,81 @@
import * as React from 'react';
import * as styles from './AppStage.scss';
import { history } from '../../util/history';
import { Button } from '../../elements/Button';
import { useI18n } from '../../util/i18n';
export type Props = {
readonly children: React.ReactNode;
readonly empty?: boolean;
readonly prev?: string;
readonly prevText?: string;
readonly next?: string;
readonly nextActive?: boolean;
readonly noMessage?: boolean;
readonly onNext?: () => unknown;
readonly onPrev?: () => unknown;
readonly nextText?: string;
};
const getClassName = ({ noMessage, empty }: Props) => {
if (noMessage) {
return styles.noMessage;
}
if (empty) {
return styles.empty;
}
return styles.main;
};
export const AppStage = (props: Props) => {
const {
children,
next,
nextActive,
nextText,
onNext,
onPrev,
prev,
prevText,
} = props;
const i18n = useI18n();
const handleNext = React.useCallback(
() => {
history.push(next);
},
[next]
);
const handlePrev = React.useCallback(
() => {
history.push(prev);
},
[prev]
);
return (
<>
<main className={getClassName(props)}>{children}</main>
<footer className={styles.footer}>
{prev || onPrev ? (
<Button className={styles.button} onClick={onPrev || handlePrev}>
{prevText || i18n('StickerCreator--AppStage--prev')}
</Button>
) : null}
{next || onNext ? (
<Button
className={styles.button}
onClick={onNext || handleNext}
primary={true}
disabled={!nextActive}
>
{nextText || i18n('StickerCreator--AppStage--next')}
</Button>
) : null}
</footer>
</>
);
};

View File

@ -0,0 +1,24 @@
.message {
max-width: 450px;
}
.main {
flex-grow: 1;
margin-top: 16px;
display: flex;
flex-direction: column;
}
.info {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: flex-start;
}
.sticker-grid {
display: grid;
grid-template-columns: repeat(auto-fill, 186px);
grid-gap: 8px;
justify-content: center;
}

View File

@ -0,0 +1,79 @@
import * as React from 'react';
import { AppStage } from './AppStage';
import * as styles from './DropStage.scss';
import * as appStyles from './AppStage.scss';
import { H2, Text } from '../../elements/Typography';
import { LabeledCheckbox } from '../../elements/LabeledCheckbox';
import { Toast } from '../../elements/Toast';
import { StickerGrid } from '../../components/StickerGrid';
import { stickersDuck } from '../../store';
import { useI18n } from '../../util/i18n';
const renderToaster = ({
hasTooLarge,
numberAdded,
resetStatus,
i18n,
}: {
hasTooLarge: boolean;
numberAdded: number;
resetStatus: () => unknown;
i18n: ReturnType<typeof useI18n>;
}) => {
if (hasTooLarge) {
return (
<div className={appStyles.toaster}>
<Toast onClick={resetStatus}>
{i18n('StickerCreator--Toasts--tooLarge')}
</Toast>
</div>
);
}
if (numberAdded > 0) {
return (
<div className={appStyles.toaster}>
<Toast onClick={resetStatus}>
{i18n('StickerCreator--Toasts--imagesAdded', [numberAdded])}
</Toast>
</div>
);
}
return null;
};
export const DropStage = () => {
const i18n = useI18n();
const stickerPaths = stickersDuck.useStickerOrder();
const stickersReady = stickersDuck.useStickersReady();
const haveStickers = stickerPaths.length > 0;
const hasTooLarge = stickersDuck.useHasTooLarge();
const numberAdded = stickersDuck.useImageAddedCount();
const [showGuide, setShowGuide] = React.useState<boolean>(true);
const { resetStatus } = stickersDuck.useStickerActions();
React.useEffect(() => {
resetStatus();
}, []);
return (
<AppStage next="/add-emojis" nextActive={stickersReady}>
<H2>{i18n('StickerCreator--DropStage--title')}</H2>
<div className={styles.info}>
<Text className={styles.message}>
{i18n('StickerCreator--DropStage--help')}
</Text>
{haveStickers ? (
<LabeledCheckbox onChange={setShowGuide} value={showGuide}>
{i18n('StickerCreator--DropStage--showMargins')}
</LabeledCheckbox>
) : null}
</div>
<div className={styles.main}>
<StickerGrid mode="add" showGuide={showGuide} />
</div>
{renderToaster({ hasTooLarge, numberAdded, resetStatus, i18n })}
</AppStage>
);
};

View File

@ -0,0 +1,26 @@
import * as React from 'react';
import { AppStage } from './AppStage';
import * as styles from './DropStage.scss';
import { H2, Text } from '../../elements/Typography';
import { StickerGrid } from '../../components/StickerGrid';
import { stickersDuck } from '../../store';
import { useI18n } from '../../util/i18n';
export const EmojiStage = () => {
const i18n = useI18n();
const emojisReady = stickersDuck.useEmojisReady();
return (
<AppStage next="/add-meta" prev="/drop" nextActive={emojisReady}>
<H2>{i18n('StickerCreator--EmojiStage--title')}</H2>
<div className={styles.info}>
<Text className={styles.message}>
{i18n('StickerCreator--EmojiStage--help')}
</Text>
</div>
<div className={styles.main}>
<StickerGrid mode="pick-emoji" />
</div>
</AppStage>
);
};

View File

@ -0,0 +1,72 @@
@import '../../../stylesheets/variables';
@import '../../mixins';
.main {
display: flex;
flex-direction: column;
align-items: center;
}
.row {
margin-bottom: 18px;
width: 448px;
}
.cover-container {
display: flex;
flex-direction: row;
justify-content: center;
padding: 18px;
}
.label {
user-select: none;
font-size: 13px;
font-family: $inter;
font-weight: 500;
margin: 0;
@include light-theme() {
color: $color-gray-90;
}
@include dark-theme() {
color: $color-white;
}
}
.cover-image {
width: 178px;
height: 178px;
}
.cover-frame {
composes: cover-image;
overflow: hidden;
border: {
radius: 4px;
style: solid;
width: 1px;
}
@include light-theme() {
border-color: $color-gray-60;
}
@include dark-theme() {
border-color: $color-gray-25;
}
}
.cover-frame-active {
composes: cover-frame;
@include light-theme() {
border-color: $color-signal-blue;
}
@include dark-theme() {
border-color: $color-signal-blue;
}
}

View File

@ -0,0 +1,112 @@
import * as React from 'react';
import { FileWithPath, useDropzone } from 'react-dropzone';
import { AppStage } from './AppStage';
import * as styles from './MetaStage.scss';
import { convertToWebp } from '../../util/preload';
import { history } from '../../util/history';
import { H2, Text } from '../../elements/Typography';
import { LabeledInput } from '../../elements/LabeledInput';
import { ConfirmModal } from '../../components/ConfirmModal';
import { stickersDuck } from '../../store';
import { useI18n } from '../../util/i18n';
// tslint:disable-next-line max-func-body-length
export const MetaStage = () => {
const i18n = useI18n();
const actions = stickersDuck.useStickerActions();
const valid = stickersDuck.useAllDataValid();
const cover = stickersDuck.useCover();
const title = stickersDuck.useTitle();
const author = stickersDuck.useAuthor();
const [confirming, setConfirming] = React.useState(false);
const onDrop = React.useCallback(
async ([{ path }]: Array<FileWithPath>) => {
const webp = await convertToWebp(path);
actions.setCover(webp);
},
[actions]
);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: ['image/png'],
});
const onNext = React.useCallback(
() => {
setConfirming(true);
},
[setConfirming]
);
const onCancel = React.useCallback(
() => {
setConfirming(false);
},
[setConfirming]
);
const onConfirm = React.useCallback(
() => {
history.push('/upload');
},
[setConfirming]
);
const coverFrameClass = isDragActive
? styles.coverFrameActive
: styles.coverFrame;
return (
<AppStage
onNext={onNext}
nextActive={valid}
noMessage={true}
prev="/add-emojis"
>
{confirming ? (
<ConfirmModal
title={i18n('StickerCreator--MetaStage--ConfirmDialog--title')}
confirm={i18n('StickerCreator--MetaStage--ConfirmDialog--confirm')}
onCancel={onCancel}
onConfirm={onConfirm}
>
{i18n('StickerCreator--MetaStage--ConfirmDialog--text')}
</ConfirmModal>
) : null}
<H2>{i18n('StickerCreator--MetaStage--title')}</H2>
<div className={styles.main}>
<div className={styles.row}>
<LabeledInput value={title} onChange={actions.setTitle}>
{i18n('StickerCreator--MetaStage--Field--title')}
</LabeledInput>
</div>
<div className={styles.row}>
<LabeledInput value={author} onChange={actions.setAuthor}>
{i18n('StickerCreator--MetaStage--Field--author')}
</LabeledInput>
</div>
<div className={styles.row}>
<h3 className={styles.label}>
{i18n('StickerCreator--MetaStage--Field--cover')}
</h3>
<Text>{i18n('StickerCreator--MetaStage--Field--cover--help')}</Text>
<div className={styles.coverContainer}>
<div {...getRootProps()} className={coverFrameClass}>
{cover.src ? (
<img
className={styles.coverImage}
src={cover.src}
alt="Cover"
/>
) : null}
{/* tslint:disable-next-line react-a11y-input-elements */}
<input {...getInputProps()} />
</div>
</div>
</div>
</div>
</AppStage>
);
};

View File

@ -0,0 +1,24 @@
@import '../../../stylesheets/variables';
@import '../../mixins';
.main {
display: flex;
flex-direction: column;
align-items: center;
}
.message {
max-width: 450px;
}
.call-to-action {
max-width: 500px;
}
.row {
margin-bottom: 18px;
display: flex;
flex-direction: row;
justify-content: center;
flex-shrink: 0;
}

View File

@ -0,0 +1,92 @@
import * as React from 'react';
import { AppStage } from './AppStage';
import * as styles from './ShareStage.scss';
import * as appStyles from './AppStage.scss';
import { history } from '../../util/history';
import { H2, Text } from '../../elements/Typography';
import { CopyText } from '../../elements/CopyText';
import { Toast } from '../../elements/Toast';
import { ShareButtons } from '../../components/ShareButtons';
import { StickerPackPreview } from '../../components/StickerPackPreview';
import { stickersDuck } from '../../store';
import { useI18n } from '../../util/i18n';
import { Intl } from '../../../ts/components/Intl';
export const ShareStage = () => {
const i18n = useI18n();
const actions = stickersDuck.useStickerActions();
const title = stickersDuck.useTitle();
const author = stickersDuck.useAuthor();
const images = stickersDuck.useOrderedImagePaths();
const shareUrl = stickersDuck.usePackUrl();
const [linkCopied, setLinkCopied] = React.useState(false);
const onCopy = React.useCallback(() => setLinkCopied(true), [setLinkCopied]);
const resetLinkCopied = React.useCallback(() => setLinkCopied(false), [
setLinkCopied,
]);
const handleNext = React.useCallback(() => {
window.close();
}, []);
const handlePrev = React.useCallback(() => {
actions.reset();
history.push('/');
}, []);
return (
<AppStage
nextText={i18n('StickerCreator--ShareStage--close')}
onNext={handleNext}
nextActive={true}
prevText={i18n('StickerCreator--ShareStage--createAnother')}
onPrev={handlePrev}
>
{shareUrl ? (
<>
<H2>{i18n('StickerCreator--ShareStage--title')}</H2>
<Text className={styles.message}>
{i18n('StickerCreator--ShareStage--help')}
</Text>
<div className={styles.main}>
<div className={styles.row}>
<StickerPackPreview
title={title}
author={author}
images={images}
/>
</div>
<div className={styles.row}>
<CopyText
value={shareUrl}
label={i18n('StickerCreator--ShareStage--copyTitle')}
onCopy={onCopy}
/>
</div>
<div className={styles.row}>
<Text className={styles.callToAction} center={true}>
<Intl
i18n={i18n}
id="StickerCreator--ShareStage--callToAction"
components={[
<strong key="hashtag">#makeprivacystick</strong>,
]}
/>
</Text>
</div>
<div className={styles.row}>
<ShareButtons value={shareUrl} />
</div>
</div>
{linkCopied ? (
<div className={appStyles.toaster}>
<Toast onClick={resetLinkCopied}>
{i18n('StickerCreator--Toasts--linkedCopied')}
</Toast>
</div>
) : null}
</>
) : null}
</AppStage>
);
};

View File

@ -0,0 +1,10 @@
.base {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.progress {
margin: 24px 0;
}

View File

@ -0,0 +1,64 @@
import * as React from 'react';
import { noop } from 'lodash';
import { AppStage } from './AppStage';
import * as styles from './UploadStage.scss';
import { history } from '../../util/history';
import { ProgressBar } from '../../elements/ProgressBar';
import { H2, Text } from '../../elements/Typography';
import { Button } from '../../elements/Button';
import { stickersDuck } from '../../store';
import { encryptAndUpload } from '../../util/preload';
import { useI18n } from '../../util/i18n';
const handleCancel = () => history.push('/add-meta');
export const UploadStage = () => {
const i18n = useI18n();
const actions = stickersDuck.useStickerActions();
const cover = stickersDuck.useCover();
const title = stickersDuck.useTitle();
const author = stickersDuck.useAuthor();
const orderedData = stickersDuck.useSelectOrderedData();
const total = orderedData.length;
const [complete, setComplete] = React.useState(0);
React.useEffect(
() => {
(async () => {
const onProgress = () => setComplete(i => i + 1);
try {
const packMeta = await encryptAndUpload(
{ title, author },
orderedData,
cover,
onProgress
);
actions.setPackMeta(packMeta);
history.push('/share');
} catch (e) {
history.push('/add-meta');
}
})();
return noop;
},
[title, author, cover, orderedData]
);
return (
<AppStage empty={true}>
<div className={styles.base}>
<H2>{i18n('StickerCreator--UploadStage--title')}</H2>
<Text>
{i18n('StickerCreator--UploadStage-uploaded', [complete, total])}
</Text>
<ProgressBar
count={complete}
total={total}
className={styles.progress}
/>
<Button onClick={handleCancel}>{i18n('cancel')}</Button>
</div>
</AppStage>
);
};

View File

@ -0,0 +1,11 @@
.facade {
background: rgba(0, 0, 0, 0.33);
width: 100vw;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
position: fixed;
left: 0;
top: 0;
}

View File

@ -0,0 +1,44 @@
import * as React from 'react';
import { createPortal } from 'react-dom';
import * as styles from './ConfirmModal.scss';
import { ConfirmDialog, Props } from '../elements/ConfirmDialog';
export type Mode = 'removable' | 'pick-emoji' | 'add';
export const ConfirmModal = React.memo(
// tslint:disable-next-line max-func-body-length
(props: Props) => {
const { onCancel } = props;
const [popperRoot, setPopperRoot] = React.useState<HTMLDivElement>();
// Create popper root and handle outside clicks
React.useEffect(
() => {
const root = document.createElement('div');
setPopperRoot(root);
document.body.appendChild(root);
const handleOutsideClick = ({ target }: MouseEvent) => {
if (!root.contains(target as Node)) {
onCancel();
}
};
document.addEventListener('click', handleOutsideClick);
return () => {
document.body.removeChild(root);
document.removeEventListener('click', handleOutsideClick);
};
},
[onCancel]
);
return popperRoot
? createPortal(
<div className={styles.facade}>
<ConfirmDialog {...props} />
</div>,
popperRoot
)
: null;
}
);

View File

@ -0,0 +1,27 @@
@import '../../stylesheets/variables';
@import '../mixins';
.container {
display: flex;
justify-content: center;
}
.text {
@include light-theme() {
border: 1px solid $color-gray-15;
color: $color-gray-90;
}
@include dark-theme() {
border: 1px solid $color-gray-60;
color: $color-white;
}
}
.button {
width: 32px;
height: 32px;
background: transparent;
border: none;
margin-left: 12px;
}

View File

@ -0,0 +1,16 @@
import * as React from 'react';
import { StoryRow } from '../elements/StoryRow';
import { ShareButtons } from './ShareButtons';
import { storiesOf } from '@storybook/react';
import { text } from '@storybook/addon-knobs';
storiesOf('Sticker Creator/components', module).add('ShareButtons', () => {
const value = text('value', 'https://signal.org');
return (
<StoryRow>
<ShareButtons value={value} />
</StoryRow>
);
});

View File

@ -0,0 +1,70 @@
import * as React from 'react';
import * as styles from './ShareButtons.scss';
import { useI18n } from '../util/i18n';
export type Props = {
value: string;
};
export const ShareButtons = React.memo(({ value }: Props) => {
const i18n = useI18n();
const buttonPaths = React.useMemo<Array<[string, string, string, string]>>(
() => {
const packUrl = encodeURIComponent(value);
const text = encodeURIComponent(
`${i18n('StickerCreator--ShareStage--socialMessage')} ${value}`
);
return [
// Facebook
[
i18n('StickerCreator--ShareButtons--facebook'),
'#4267B2',
'M20.155 10.656l-1.506.001c-1.181 0-1.41.561-1.41 1.384v1.816h2.817l-.367 2.845h-2.45V24h-2.937v-7.298h-2.456v-2.845h2.456V11.76c0-2.435 1.487-3.76 3.658-3.76 1.04 0 1.934.077 2.195.112v2.544z',
`https://www.facebook.com/sharer/sharer.php?u=${packUrl}`,
],
// Twitter
[
i18n('StickerCreator--ShareButtons--twitter'),
'#1CA1F2',
'M22.362 12.737c.006.141.01.282.01.425 0 4.337-3.302 9.339-9.34 9.339A9.294 9.294 0 018 21.027c.257.03.518.045.783.045a6.584 6.584 0 004.077-1.405 3.285 3.285 0 01-3.067-2.279 3.312 3.312 0 001.483-.057 3.283 3.283 0 01-2.633-3.218v-.042c.442.246.949.394 1.487.411a3.282 3.282 0 01-1.016-4.383 9.32 9.32 0 006.766 3.43 3.283 3.283 0 015.593-2.994 6.568 6.568 0 002.085-.796 3.299 3.299 0 01-1.443 1.816A6.587 6.587 0 0024 11.038a6.682 6.682 0 01-1.638 1.699',
`https://twitter.com/intent/tweet?text=${text}`,
],
// Pinterest
// [
// i18n('StickerCreator--ShareButtons--pinterest'),
// '#BD081C',
// 'M17.234 19.563c-.992 0-1.926-.536-2.245-1.146 0 0-.534 2.118-.646 2.527-.398 1.444-1.569 2.889-1.66 3.007-.063.083-.203.057-.218-.052-.025-.184-.324-2.007.028-3.493l1.182-5.008s-.293-.587-.293-1.454c0-1.362.789-2.379 1.772-2.379.836 0 1.239.628 1.239 1.38 0 .84-.535 2.097-.811 3.261-.231.975.489 1.77 1.451 1.77 1.74 0 2.913-2.236 2.913-4.886 0-2.014-1.356-3.522-3.824-3.522-2.787 0-4.525 2.079-4.525 4.402 0 .8.237 1.365.607 1.802.17.201.194.282.132.512-.045.17-.145.576-.188.738-.061.233-.249.316-.46.23-1.283-.524-1.882-1.931-1.882-3.511C9.806 11.13 12.008 8 16.374 8c3.51 0 5.819 2.538 5.819 5.265 0 3.605-2.005 6.298-4.959 6.298',
// `https://pinterest.com/pin/create/button/?url=${packUrl}`,
// ],
// Whatsapp
[
i18n('StickerCreator--ShareButtons--whatsapp'),
'#25D366',
'M16.033 23.862h-.003a7.914 7.914 0 01-3.79-.965L8.035 24l1.126-4.109a7.907 7.907 0 01-1.059-3.964C8.104 11.556 11.661 8 16.033 8c2.121 0 4.113.826 5.61 2.325a7.878 7.878 0 012.321 5.609c-.002 4.371-3.56 7.928-7.931 7.928zm3.88-5.101c-.165.463-.957.885-1.338.942a2.727 2.727 0 01-1.248-.078 11.546 11.546 0 01-1.13-.418c-1.987-.858-3.286-2.859-3.385-2.991-.1-.132-.81-1.074-.81-2.049 0-.975.513-1.455.695-1.653a.728.728 0 01.528-.248c.132 0 .264.001.38.007.122.006.285-.046.446.34.165.397.56 1.372.61 1.471.05.099.083.215.017.347-.066.132-.1.215-.198.331-.1.115-.208.258-.297.347-.1.098-.203.206-.087.404.116.198.513.847 1.102 1.372.757.675 1.396.884 1.594.984.198.099.314.082.429-.05.116-.132.496-.578.628-.777.132-.198.264-.165.446-.099.18.066 1.156.545 1.354.645.198.099.33.148.38.231.049.083.049.479-.116.942zm-3.877-9.422c-3.636 0-6.594 2.956-6.595 6.589 0 1.245.348 2.458 1.008 3.507l.157.249-.666 2.432 2.495-.654.24.142a6.573 6.573 0 003.355.919h.003a6.6 6.6 0 006.592-6.59 6.55 6.55 0 00-1.93-4.662 6.549 6.549 0 00-4.66-1.932z',
`https://wa.me?text=${text}`,
],
];
},
[i18n, value]
);
return (
<div className={styles.container}>
{buttonPaths.map(([title, fill, path, url]) => (
<button
key={path}
className={styles.button}
onClick={() => window.open(url)}
title={title}
>
<svg width={32} height={32}>
<circle cx="16" cy="16" r="16" fill={fill} />
<path d={path} fill="#FFF" fillRule="evenodd" />
</svg>
</button>
))}
</div>
);
});

View File

@ -0,0 +1,140 @@
@import '../mixins';
@import '../../stylesheets/variables';
$width: 186px;
$height: 186px;
$guide-offset: 6px;
$border-width: 1px;
.container {
position: relative;
width: $width;
height: $height;
border: {
radius: 4px;
width: $border-width;
style: solid;
}
overflow: hidden;
user-select: none;
@include light-theme() {
border-color: $color-gray-25;
background: $color-white;
}
@include dark-theme() {
border-color: $color-gray-60;
background: $color-gray-90;
}
}
.dragActive {
composes: container;
@include light-theme() {
border-color: $color-signal-blue;
}
@include dark-theme() {
border-color: $color-signal-blue;
}
}
.image {
width: $width;
height: $height;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.spinner {
composes: image;
animation: spin 1s linear infinite;
display: flex;
justify-content: center;
align-items: center;
@include light-theme() {
color: $color-gray-25;
}
@include dark-theme() {
color: $color-gray-60;
}
}
.guide {
width: $width - (2 * $guide-offset);
height: $height - (2 * $guide-offset);
position: absolute;
left: $guide-offset - $border-width;
top: $guide-offset - $border-width;
border: {
radius: 0px;
width: $border-width;
style: dashed;
}
pointer-events: none;
@include light-theme() {
border-color: $color-gray-25;
}
@include dark-theme() {
border-color: $color-gray-60;
}
}
.close-button {
width: 16px;
height: 16px;
position: absolute;
display: flex;
justify-content: center;
align-items: center;
top: 8px;
right: 8px;
font-family: $inter;
border: none;
background: none;
padding: 0;
&-icon {
@include light-theme() {
color: $color-black;
}
@include dark-theme() {
color: $color-white;
}
}
}
.emoji-button {
width: 41px;
height: 28px;
position: absolute;
top: 6px;
right: 6px;
border: none;
border-radius: 13.5px;
padding: 0;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
@include light-theme() {
background-color: $color-gray-05;
color: $color-gray-90;
}
@include dark-theme() {
background-color: $color-gray-75;
color: rgba(255, 255, 255, 0.75);
}
}

View File

@ -0,0 +1,34 @@
import * as React from 'react';
import { StoryRow } from '../elements/StoryRow';
import { StickerFrame } from './StickerFrame';
import { storiesOf } from '@storybook/react';
import { boolean, select, text } from '@storybook/addon-knobs';
import { action } from '@storybook/addon-actions';
storiesOf('Sticker Creator/components', module).add('StickerFrame', () => {
const image = text('image url', '/fixtures/512x515-thumbs-up-lincoln.webp');
const showGuide = boolean('show guide', true);
const mode = select('mode', [null, 'removable', 'pick-emoji', 'add'], null);
const onRemove = action('onRemove');
const onDrop = action('onDrop');
const [skinTone, setSkinTone] = React.useState(0);
const [emoji, setEmoji] = React.useState(undefined);
return (
<StoryRow top={true}>
<StickerFrame
id="1337"
emojiData={emoji}
image={image}
mode={mode}
showGuide={showGuide}
onRemove={onRemove}
skinTone={skinTone}
onSetSkinTone={setSkinTone}
onPickEmoji={e => setEmoji(e.emoji)}
onDrop={onDrop}
/>
</StoryRow>
);
});

View File

@ -0,0 +1,271 @@
import * as React from 'react';
import { createPortal } from 'react-dom';
import { SortableHandle } from 'react-sortable-hoc';
import { noop } from 'lodash';
import {
Manager as PopperManager,
Popper,
Reference as PopperReference,
} from 'react-popper';
import { AddEmoji } from '../elements/icons';
import { DropZone, Props as DropZoneProps } from '../elements/DropZone';
import { StickerPreview } from '../elements/StickerPreview';
import * as styles from './StickerFrame.scss';
import {
EmojiPickDataType,
EmojiPicker,
Props as EmojiPickerProps,
} from '../../ts/components/emoji/EmojiPicker';
import { Emoji } from '../../ts/components/emoji/Emoji';
import { useI18n } from '../util/i18n';
export type Mode = 'removable' | 'pick-emoji' | 'add';
export type Props = Partial<
Pick<EmojiPickerProps, 'skinTone' | 'onSetSkinTone'>
> &
Partial<Pick<DropZoneProps, 'onDrop'>> & {
readonly id?: string;
readonly emojiData?: EmojiPickDataType;
readonly image?: string;
readonly mode?: Mode;
readonly showGuide?: boolean;
onPickEmoji?({ id: string, emoji: EmojiPickData }): unknown;
onRemove?(id: string): unknown;
};
const spinnerSvg = (
<svg width={56} height={56}>
<path d="M52.36 14.185A27.872 27.872 0 0156 28c0 15.464-12.536 28-28 28v-2c14.36 0 26-11.64 26-26 0-4.66-1.226-9.033-3.372-12.815l1.732-1z" />
</svg>
);
const closeSvg = (
<svg
viewBox="0 0 16 16"
width="16px"
height="16px"
className={styles.closeButtonIcon}
>
<path d="M13.4 3.3l-.8-.6L8 7.3 3.3 2.7l-.6.6L7.3 8l-4.6 4.6.6.8L8 8.7l4.6 4.7.8-.8L8.7 8z" />
</svg>
);
const ImageHandle = SortableHandle((props: { src: string }) => (
<img className={styles.image} {...props} alt="Sticker" />
));
export const StickerFrame = React.memo(
// tslint:disable-next-line max-func-body-length
({
id,
emojiData,
image,
showGuide,
mode,
onRemove,
onPickEmoji,
skinTone,
onSetSkinTone,
onDrop,
}: Props) => {
const i18n = useI18n();
const [emojiPickerOpen, setEmojiPickerOpen] = React.useState(false);
const [
emojiPopperRoot,
setEmojiPopperRoot,
] = React.useState<HTMLElement | null>(null);
const [previewActive, setPreviewActive] = React.useState(false);
const [
previewPopperRoot,
setPreviewPopperRoot,
] = React.useState<HTMLElement | null>(null);
const timerRef = React.useRef<number>();
const handleToggleEmojiPicker = React.useCallback(
() => {
setEmojiPickerOpen(open => !open);
},
[setEmojiPickerOpen]
);
const handlePickEmoji = React.useCallback(
(emoji: EmojiPickDataType) => {
onPickEmoji({ id, emoji });
setEmojiPickerOpen(false);
},
[id, onPickEmoji, setEmojiPickerOpen]
);
const handleRemove = React.useCallback(
() => {
onRemove(id);
},
[onRemove, id]
);
const handleMouseEnter = React.useCallback(
() => {
window.clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(() => {
setPreviewActive(true);
}, 500);
},
[timerRef, setPreviewActive]
);
const handleMouseLeave = React.useCallback(
() => {
clearTimeout(timerRef.current);
setPreviewActive(false);
},
[timerRef, setPreviewActive]
);
React.useEffect(
() => () => {
clearTimeout(timerRef.current);
},
[timerRef]
);
// Create popper root and handle outside clicks
React.useEffect(
() => {
if (emojiPickerOpen) {
const root = document.createElement('div');
setEmojiPopperRoot(root);
document.body.appendChild(root);
const handleOutsideClick = ({ target }: MouseEvent) => {
if (!root.contains(target as Node)) {
setEmojiPickerOpen(false);
}
};
document.addEventListener('click', handleOutsideClick);
return () => {
document.body.removeChild(root);
document.removeEventListener('click', handleOutsideClick);
};
}
return noop;
},
[emojiPickerOpen, setEmojiPickerOpen, setEmojiPopperRoot]
);
React.useEffect(
() => {
if (mode !== 'pick-emoji' && image && previewActive) {
const root = document.createElement('div');
setPreviewPopperRoot(root);
document.body.appendChild(root);
return () => {
document.body.removeChild(root);
};
}
return noop;
},
[mode, image, previewActive, setPreviewPopperRoot]
);
const [dragActive, setDragActive] = React.useState<boolean>(false);
const containerClass = dragActive ? styles.dragActive : styles.container;
return (
<PopperManager>
<PopperReference>
{({ ref: rootRef }) => (
<div
className={containerClass}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
ref={rootRef}
>
{mode !== 'add' ? (
image ? (
<ImageHandle src={image} />
) : (
<div className={styles.spinner}>{spinnerSvg}</div>
)
) : null}
{showGuide && mode !== 'add' ? (
<div className={styles.guide} />
) : null}
{mode === 'add' && onDrop ? (
<DropZone
onDrop={onDrop}
inner={true}
onDragActive={setDragActive}
/>
) : null}
{mode === 'removable' ? (
<button className={styles.closeButton} onClick={handleRemove}>
{closeSvg}
</button>
) : null}
{mode === 'pick-emoji' ? (
<PopperManager>
<PopperReference>
{({ ref }) => (
<button
ref={ref}
className={styles.emojiButton}
onClick={handleToggleEmojiPicker}
>
{emojiData ? (
<Emoji {...emojiData} size={24} />
) : (
<AddEmoji />
)}
</button>
)}
</PopperReference>
{emojiPickerOpen && emojiPopperRoot
? createPortal(
<Popper placement="bottom-start">
{({ ref, style }) => (
<EmojiPicker
ref={ref}
style={{ ...style, marginTop: '8px' }}
i18n={i18n}
onPickEmoji={handlePickEmoji}
skinTone={skinTone}
onSetSkinTone={onSetSkinTone}
onClose={handleToggleEmojiPicker}
/>
)}
</Popper>,
emojiPopperRoot
)
: null}
</PopperManager>
) : null}
{mode !== 'pick-emoji' &&
image &&
previewActive &&
previewPopperRoot
? createPortal(
<Popper placement="bottom">
{({ ref, style, arrowProps, placement }) => (
<StickerPreview
ref={ref}
style={style}
image={image}
arrowProps={arrowProps}
placement={placement}
/>
)}
</Popper>,
previewPopperRoot
)
: null}
</div>
)}
</PopperReference>
</PopperManager>
);
}
);

View File

@ -0,0 +1,13 @@
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, 186px);
grid-template-rows: repeat(auto-fill, 186px);
grid-gap: 8px;
justify-content: center;
}
.drop {
display: flex;
flex-direction: column;
flex-grow: 1;
}

View File

@ -0,0 +1,110 @@
import * as React from 'react';
import * as PQueue from 'p-queue';
import {
SortableContainer,
SortableElement,
SortEndHandler,
} from 'react-sortable-hoc';
import * as styles from './StickerGrid.scss';
import { Props as StickerFrameProps, StickerFrame } from './StickerFrame';
import { stickersDuck } from '../store';
import { DropZone, Props as DropZoneProps } from '../elements/DropZone';
import { convertToWebp } from '../util/preload';
const queue = new PQueue({ concurrency: 5 });
const SmartStickerFrame = SortableElement(
({ id, showGuide, mode }: StickerFrameProps) => {
const data = stickersDuck.useStickerData(id);
const actions = stickersDuck.useStickerActions();
const image = data.webp ? data.webp.src : undefined;
return (
<StickerFrame
id={id}
showGuide={showGuide}
mode={mode}
image={image}
onRemove={actions.removeSticker}
onPickEmoji={actions.setEmoji}
emojiData={data.emoji}
/>
);
}
);
export type Props = Pick<StickerFrameProps, 'showGuide' | 'mode'>;
export type InnerGridProps = Props & {
ids: Array<string>;
};
const InnerGrid = SortableContainer(
({ ids, mode, showGuide }: InnerGridProps) => {
const containerClassName = ids.length > 0 ? styles.grid : styles.drop;
const frameMode = mode === 'add' ? 'removable' : 'pick-emoji';
const actions = stickersDuck.useStickerActions();
const handleDrop = React.useCallback<DropZoneProps['onDrop']>(
async paths => {
actions.initializeStickers(paths);
paths.forEach(path => {
queue.add(async () => {
const webp = await convertToWebp(path);
actions.addWebp(webp);
});
});
},
[actions]
);
return (
<div className={containerClassName}>
{ids.length > 0 ? (
<>
{ids.map((p, i) => (
<SmartStickerFrame
key={p}
index={i}
id={p}
showGuide={showGuide}
mode={frameMode}
/>
))}
{mode === 'add' && ids.length < stickersDuck.maxStickers ? (
<StickerFrame
showGuide={showGuide}
mode="add"
onDrop={handleDrop}
/>
) : null}
</>
) : (
<DropZone onDrop={handleDrop} />
)}
</div>
);
}
);
export const StickerGrid = SortableContainer((props: Props) => {
const ids = stickersDuck.useStickerOrder();
const actions = stickersDuck.useStickerActions();
const handleSortEnd = React.useCallback<SortEndHandler>(
sortEnd => {
actions.moveSticker(sortEnd);
},
[actions]
);
return (
<InnerGrid
{...props}
ids={ids}
axis="xy"
onSortEnd={handleSortEnd}
useDragHandle={true}
/>
);
});

View File

@ -0,0 +1,126 @@
@import '../mixins';
@import '../../stylesheets/variables';
@mixin background() {
@include light-theme() {
background: $color-white;
}
@include dark-theme() {
background: $color-gray-75;
}
}
.container {
position: relative;
width: 330px;
height: 270px;
border-radius: 3px;
overflow: hidden;
box-shadow: 0 3px 9px 0px rgba(0, 0, 0, 0.2);
@include background();
}
.title-bar {
height: 27px;
padding: 0 12px;
display: flex;
flex-direction: row;
align-items: center;
font: {
family: $inter;
size: 10.5px;
weight: 500;
}
@include background();
@include light-theme {
color: $color-gray-90;
}
@include dark-theme {
color: $color-gray-05;
}
}
.scroller {
height: calc(100% - 27px);
padding-bottom: 57px;
overflow: auto;
}
.grid {
display: grid;
grid-gap: 6px;
padding: 0 16px 0 12px;
grid-template-columns: repeat(4, 1fr);
overflow: auto;
justify-items: center;
}
.sticker {
width: 72px;
height: 72px;
}
.meta {
width: 306px;
height: 39px;
border-radius: 3px;
padding: 0 9px;
display: flex;
flex-direction: column;
justify-content: center;
position: absolute;
left: 12px;
bottom: 12px;
@include light-theme {
background: $color-gray-05;
}
@include dark-theme {
background: $color-gray-60;
}
}
.text {
font-family: $inter;
}
.meta-title {
composes: text;
height: 15px;
line-height: 15px;
font: {
size: 12px;
weight: 500;
}
@include light-theme {
color: $color-gray-90;
}
@include dark-theme {
color: $color-gray-05;
}
}
.meta-author {
composes: text;
height: 14px;
line-height: 14px;
font: {
size: 10px;
weight: normal;
}
@include light-theme {
color: $color-gray-60;
}
@include dark-theme {
color: $color-gray-25;
}
}

View File

@ -0,0 +1,22 @@
import * as React from 'react';
import { StoryRow } from '../elements/StoryRow';
import { StickerPackPreview } from './StickerPackPreview';
import { storiesOf } from '@storybook/react';
import { text } from '@storybook/addon-knobs';
storiesOf('Sticker Creator/components', module).add(
'StickerPackPreview',
() => {
const image = text('image url', '/fixtures/512x515-thumbs-up-lincoln.webp');
const title = text('title', 'Sticker pack title');
const author = text('author', 'Sticker pack author');
const images = React.useMemo(() => Array(39).fill(image), [image]);
return (
<StoryRow top={true}>
<StickerPackPreview images={images} title={title} author={author} />
</StoryRow>
);
}
);

View File

@ -0,0 +1,34 @@
import * as React from 'react';
import * as styles from './StickerPackPreview.scss';
import { useI18n } from '../util/i18n';
export type Props = {
images: Array<string>;
title: string;
author: string;
};
export const StickerPackPreview = React.memo(
({ images, title, author }: Props) => {
const i18n = useI18n();
return (
<div className={styles.container}>
<div className={styles.titleBar}>
{i18n('StickerCreator--Preview--title')}
</div>
<div className={styles.scroller}>
<div className={styles.grid}>
{images.map((src, id) => (
<img key={id} className={styles.sticker} src={src} alt={src} />
))}
</div>
</div>
<div className={styles.meta}>
<div className={styles.metaTitle}>{title}</div>
<div className={styles.metaAuthor}>{author}</div>
</div>
</div>
);
}
);

View File

@ -0,0 +1,80 @@
@import '../mixins';
@import '../../stylesheets/variables';
.base {
border: none;
min-width: 80px;
height: 36px;
padding: 0 25px;
border-radius: 4px;
display: flex;
justify-content: center;
align-items: center;
font-family: $inter;
font-weight: normal;
font-size: 14px;
white-space: nowrap;
@include light-theme() {
background-color: $color-gray-05;
color: $color-gray-90;
}
@include dark-theme() {
background-color: $color-gray-75;
color: $color-white;
}
&:disabled {
opacity: 0.4;
}
}
.primary {
composes: base;
@include light-theme() {
background-color: $color-signal-blue;
color: $color-white;
}
@include dark-theme() {
background-color: $color-signal-blue;
color: $color-white;
}
}
.pill {
composes: base;
height: 28px;
border-radius: 15px;
padding: 0 17px;
@include light-theme() {
color: $color-gray-90;
border: 1px solid $color-gray-90;
background: transparent;
}
@include dark-theme() {
color: $color-white;
border: 1px solid $color-white;
background: transparent;
}
}
.pill-primary {
composes: pill;
@include light-theme() {
border: none;
background-color: $color-signal-blue;
color: $color-white;
}
@include dark-theme() {
border: none;
background-color: $color-signal-blue;
color: $color-white;
}
}

View File

@ -0,0 +1,55 @@
import * as React from 'react';
import { StoryRow } from './StoryRow';
import { Button } from './Button';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { text } from '@storybook/addon-knobs';
storiesOf('Sticker Creator/elements', module).add('Button', () => {
const onClick = action('onClick');
const child = text('text', 'foo bar');
return (
<>
<StoryRow>
<Button onClick={onClick} primary={true}>
{child}
</Button>
</StoryRow>
<StoryRow>
<Button onClick={onClick} primary={true} disabled={true}>
{child}
</Button>
</StoryRow>
<StoryRow>
<Button onClick={onClick}>{child}</Button>
</StoryRow>
<StoryRow>
<Button onClick={onClick} disabled={true}>
{child}
</Button>
</StoryRow>
<StoryRow>
<Button onClick={onClick} primary={true} pill={true}>
{child}
</Button>
</StoryRow>
<StoryRow>
<Button onClick={onClick} primary={true} pill={true} disabled={true}>
{child}
</Button>
</StoryRow>
<StoryRow>
<Button onClick={onClick} pill={true}>
{child}
</Button>
</StoryRow>
<StoryRow>
<Button onClick={onClick} pill={true} disabled={true}>
{child}
</Button>
</StoryRow>
</>
);
});

View File

@ -0,0 +1,39 @@
import * as React from 'react';
import * as classnames from 'classnames';
import * as styles from './Button.scss';
export type Props = React.HTMLProps<HTMLButtonElement> & {
className?: string;
pill?: boolean;
primary?: boolean;
children: React.ReactNode;
};
const getClassName = ({ primary, pill }: Props) => {
if (pill && primary) {
return styles.pillPrimary;
}
if (pill) {
return styles.pill;
}
if (primary) {
return styles.primary;
}
return styles.base;
};
export const Button = (props: Props) => {
const { className, pill, primary, children, ...otherProps } = props;
return (
<button
className={classnames(getClassName(props), className)}
{...otherProps}
>
{children}
</button>
);
};

View File

@ -0,0 +1,98 @@
@import '../mixins';
@import '../../stylesheets/variables';
.base {
width: 468px;
height: 138px;
padding: 16px 16px 8px 16px;
display: grid;
flex-direction: column;
grid-template-rows: 33px 1fr 28px;
border-radius: 8px;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.08), 0 8px 20px 0 rgba(0, 0, 0, 0.33);
@include light-theme() {
background: $color-white;
}
@include dark-theme() {
background: $color-gray-75;
}
}
.text {
font: {
family: $inter;
size: 14px;
}
margin: 0;
@include light-theme() {
color: $color-gray-90;
}
@include dark-theme() {
color: $color-gray-05;
}
}
.title {
composes: text;
font-weight: 500;
@include light-theme() {
color: $color-gray-90;
}
@include dark-theme() {
color: $color-white;
}
}
.bottom {
display: flex;
flex-direction: row;
justify-content: flex-end;
align-content: flex-end;
}
.button {
width: 64px;
height: 28px;
border-radius: 14px;
background: transparent;
margin-left: 4px;
text-align: center;
font: {
family: $inter;
weight: 500;
size: 13px;
}
@include light-theme() {
color: $color-gray-60;
border-color: $color-gray-60;
}
@include dark-theme() {
color: $color-gray-25;
border-color: $color-gray-25;
}
}
.button-primary {
composes: button;
@include light-theme() {
color: $color-white;
border-color: $color-signal-blue;
background: $color-signal-blue;
}
@include dark-theme() {
color: $color-white;
border-color: $color-signal-blue;
background: $color-signal-blue;
}
}

View File

@ -0,0 +1,29 @@
import * as React from 'react';
import { StoryRow } from './StoryRow';
import { ConfirmDialog } from './ConfirmDialog';
import { storiesOf } from '@storybook/react';
import { text } from '@storybook/addon-knobs';
import { action } from '@storybook/addon-actions';
storiesOf('Sticker Creator/elements', module).add('ConfirmDialog', () => {
const title = text('title', 'Foo bar banana baz?');
const child = text(
'text',
'Yadda yadda yadda yadda yadda yadda foo bar banana baz.'
);
const confirm = text('confirm', 'Upload');
const cancel = text('cancel', 'Cancel');
return (
<StoryRow>
<ConfirmDialog
{...{ title, confirm, cancel }}
onConfirm={action('onConfirm')}
onCancel={action('onCancel')}
>
{child}
</ConfirmDialog>
</StoryRow>
);
});

View File

@ -0,0 +1,39 @@
import * as React from 'react';
import * as styles from './ConfirmDialog.scss';
import { useI18n } from '../util/i18n';
export type Props = {
readonly title: string;
readonly children: React.ReactNode;
readonly confirm: string;
readonly onConfirm: () => unknown;
readonly cancel?: string;
readonly onCancel: () => unknown;
};
export const ConfirmDialog = ({
title,
children,
confirm,
cancel,
onConfirm,
onCancel,
}: Props) => {
const i18n = useI18n();
const cancelText = cancel || i18n('StickerCreator--ConfirmDialog--cancel');
return (
<div className={styles.base}>
<h1 className={styles.title}>{title}</h1>
<p className={styles.text}>{children}</p>
<div className={styles.bottom}>
<button className={styles.button} onClick={onCancel}>
{cancelText}
</button>
<button className={styles.buttonPrimary} onClick={onConfirm}>
{confirm}
</button>
</div>
</div>
);
};

View File

@ -0,0 +1,36 @@
@import '../../stylesheets/variables';
@import '../mixins';
.container {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
width: 330px;
height: 36px;
}
.input {
width: 242px;
height: 36px;
line-height: 34px;
padding: 0 12px;
border-radius: 4px;
background-color: transparent;
font-size: 14px;
font-family: $inter;
&::placeholder {
color: $color-gray-45;
}
@include light-theme() {
border: 1px solid $color-gray-15;
color: $color-gray-90;
}
@include dark-theme() {
border: 1px solid $color-gray-60;
color: $color-white;
}
}

View File

@ -0,0 +1,17 @@
import * as React from 'react';
import { StoryRow } from './StoryRow';
import { CopyText } from './CopyText';
import { storiesOf } from '@storybook/react';
import { text } from '@storybook/addon-knobs';
storiesOf('Sticker Creator/elements', module).add('CopyText', () => {
const label = text('label', 'foo bar');
const value = text('value', 'foo bar');
return (
<StoryRow>
<CopyText label={label} value={value} />
</StoryRow>
);
});

View File

@ -0,0 +1,39 @@
import * as React from 'react';
import copy from 'copy-text-to-clipboard';
import * as styles from './CopyText.scss';
import { Button } from './Button';
import { useI18n } from '../util/i18n';
export type Props = {
value: string;
label: string;
onCopy?: () => unknown;
};
export const CopyText = React.memo(({ label, onCopy, value }: Props) => {
const i18n = useI18n();
const handleClick = React.useCallback(
() => {
copy(value);
if (onCopy) {
onCopy();
}
},
[onCopy, value]
);
return (
<div className={styles.container}>
<input
type="text"
className={styles.input}
value={value}
aria-label={label}
readOnly={true}
/>
<Button onClick={handleClick}>
{i18n('StickerCreator--CopyText--button')}
</Button>
</div>
);
});

View File

@ -0,0 +1,61 @@
@import '../../stylesheets/variables';
@import '../mixins';
.base {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
flex-grow: 1;
@include light-theme() {
color: $color-gray-25;
}
@include dark-theme() {
color: $color-gray-60;
}
}
.text {
margin: 16px 0 0 0;
font-family: $inter;
font-size: 14px;
font-weight: normal;
@include light-theme() {
color: $color-gray-25;
}
@include dark-theme() {
color: $color-gray-60;
}
}
.standalone {
composes: base;
border-radius: 4px;
border: 2px solid;
@include light-theme() {
border-color: $color-gray-25;
}
@include dark-theme() {
border-color: $color-gray-60;
}
}
.active {
composes: standalone;
@include light-theme() {
border-color: $color-signal-blue;
}
@include dark-theme() {
border-color: $color-signal-blue;
}
}

View File

@ -0,0 +1,9 @@
import * as React from 'react';
import { DropZone } from './DropZone';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
storiesOf('Sticker Creator/elements', module).add('DropZone', () => {
return <DropZone onDrop={action('onDrop')} />;
});

View File

@ -0,0 +1,65 @@
import * as React from 'react';
import { useDropzone } from 'react-dropzone';
import * as styles from './DropZone.scss';
import { useI18n } from '../util/i18n';
export type Props = {
readonly inner?: boolean;
onDrop(files: Array<string>): unknown;
onDragActive?(active: boolean): unknown;
};
const getClassName = ({ inner }: Props, isDragActive: boolean) => {
if (inner) {
return styles.base;
}
if (isDragActive) {
return styles.active;
}
return styles.standalone;
};
export const DropZone = (props: Props) => {
const { inner, onDrop, onDragActive } = props;
const i18n = useI18n();
const handleDrop = React.useCallback(
files => {
onDrop(files.map(({ path }) => path));
},
[onDrop]
);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop: handleDrop,
accept: ['image/png'],
});
React.useEffect(
() => {
if (onDragActive) {
onDragActive(isDragActive);
}
},
[isDragActive, onDragActive]
);
return (
<div {...getRootProps({ className: getClassName(props, isDragActive) })}>
{/* tslint:disable-next-line */}
<input {...getInputProps()} />
<svg viewBox="0 0 36 36" width="36px" height="36px">
<path d="M32 17.25H18.75V4h-1.5v13.25H4v1.5h13.25V32h1.5V18.75H32v-1.5z" />
</svg>
{!inner ? (
<p className={styles.text}>
{isDragActive
? i18n('StickerCreator--DropZone--staticText')
: i18n('StickerCreator--DropZone--activeText')}
</p>
) : null}
</div>
);
};

View File

@ -0,0 +1,50 @@
@import '../../stylesheets/variables';
@import '../mixins';
.base {
display: flex;
flex-direction: row;
align-items: center;
}
.input {
position: absolute;
width: 0;
height: 0;
opacity: 0;
}
.checkbox {
width: 18px;
height: 18px;
border-radius: 2px;
display: flex;
justify-content: center;
align-items: center;
border: {
width: 2px;
style: solid;
}
@include light-theme() {
border-color: $color-gray-60;
}
@include dark-theme() {
border-color: $color-gray-25;
}
}
.checkbox-checked {
composes: checkbox;
border: none;
background-color: $color-signal-blue;
color: $color-white;
}
.label {
margin-left: 6px;
position: relative;
top: 1px;
user-select: none;
}

View File

@ -0,0 +1,19 @@
import * as React from 'react';
import { StoryRow } from './StoryRow';
import { LabeledCheckbox } from './LabeledCheckbox';
import { storiesOf } from '@storybook/react';
import { text } from '@storybook/addon-knobs';
storiesOf('Sticker Creator/elements', module).add('Labeled Checkbox', () => {
const child = text('label', 'foo bar');
const [checked, setChecked] = React.useState(false);
return (
<StoryRow>
<LabeledCheckbox value={checked} onChange={setChecked}>
{child}
</LabeledCheckbox>
</StoryRow>
);
});

View File

@ -0,0 +1,43 @@
import * as React from 'react';
import * as styles from './LabeledCheckbox.scss';
import { Inline } from './Typography';
export type Props = {
children: React.ReactNode;
value?: boolean;
onChange?: (value: boolean) => unknown;
};
const checkSvg = (
<svg viewBox="0 0 16 16" width="16px" height="16px">
<path d="M7 11.5c-.2 0-.4-.1-.5-.2L3.3 8.1 4.4 7 7 9.7l4.6-4.6 1.1 1.1-5.2 5.2c-.1 0-.3.1-.5.1z" />
</svg>
);
export const LabeledCheckbox = React.memo(
({ children, value, onChange }: Props) => {
const handleChange = React.useCallback(
() => {
onChange(!value);
},
[onChange, value]
);
const className = value ? styles.checkboxChecked : styles.checkbox;
return (
<label className={styles.base}>
{/* tslint:disable-next-line react-a11y-input-elements */}
<input
type="checkbox"
className={styles.input}
checked={value}
aria-checked={value}
onChange={handleChange}
/>
<span className={className}>{value ? checkSvg : null}</span>
<Inline className={styles.label}>{children}</Inline>
</label>
);
}
);

View File

@ -0,0 +1,55 @@
@import '../../stylesheets/variables';
@import '../mixins';
.container {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: space-between;
height: 56px;
}
.label {
user-select: none;
font-size: 13px;
font-family: $inter;
font-weight: 500;
}
.input {
width: 448px;
height: 34px;
line-height: 34px;
padding: 0 12px;
border-radius: 4px;
background-color: transparent;
font-size: 14px;
font-family: $inter;
&::placeholder {
color: $color-gray-45;
}
@include light-theme() {
border: 1px solid $color-gray-15;
color: $color-gray-90;
}
@include dark-theme() {
border: 1px solid $color-gray-60;
color: $color-white;
}
&:focus {
outline: none;
padding: 0 11px;
@include light-theme() {
border: 2px solid $color-signal-blue;
}
@include dark-theme() {
border: 2px solid $color-signal-blue;
}
}
}

View File

@ -0,0 +1,20 @@
import * as React from 'react';
import { StoryRow } from './StoryRow';
import { LabeledInput } from './LabeledInput';
import { storiesOf } from '@storybook/react';
import { text } from '@storybook/addon-knobs';
storiesOf('Sticker Creator/elements', module).add('LabeledInput', () => {
const child = text('label', 'foo bar');
const placeholder = text('placeholder', 'foo bar');
const [value, setValue] = React.useState('');
return (
<StoryRow>
<LabeledInput value={value} onChange={setValue} placeholder={placeholder}>
{child}
</LabeledInput>
</StoryRow>
);
});

View File

@ -0,0 +1,34 @@
import * as React from 'react';
import * as styles from './LabeledInput.scss';
import { Inline } from './Typography';
export type Props = {
children: React.ReactNode;
placeholder?: string;
value?: string;
onChange?: (value: string) => unknown;
};
export const LabeledInput = React.memo(
({ children, value, placeholder, onChange }: Props) => {
const handleChange = React.useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
onChange(e.currentTarget.value);
},
[onChange]
);
return (
<label className={styles.container}>
<Inline className={styles.label}>{children}</Inline>
<input
type="text"
className={styles.input}
placeholder={placeholder}
value={value}
onChange={handleChange}
/>
</label>
);
}
);

View File

@ -0,0 +1,13 @@
@import '../../stylesheets/variables';
.base {
background-color: $color-signal-blue;
padding: 6px 12px;
border-radius: 16px;
color: $color-white-alpha-90;
font: {
size: 12px;
family: $inter;
weight: normal;
}
}

View File

@ -0,0 +1,17 @@
import * as React from 'react';
import { StoryRow } from './StoryRow';
import { MessageBubble } from './MessageBubble';
import { storiesOf } from '@storybook/react';
import { number, text } from '@storybook/addon-knobs';
storiesOf('Sticker Creator/elements', module).add('MessageBubble', () => {
const child = text('text', 'Foo bar banana baz');
const minutesAgo = number('minutesAgo', 3);
return (
<StoryRow>
<MessageBubble minutesAgo={minutesAgo}>{child}</MessageBubble>
</StoryRow>
);
});

View File

@ -0,0 +1,16 @@
import * as React from 'react';
import * as styles from './MessageBubble.scss';
import { MessageMeta, Props as MessageMetaProps } from './MessageMeta';
export type Props = Pick<MessageMetaProps, 'minutesAgo'> & {
children: React.ReactNode;
};
export const MessageBubble = ({ children, minutesAgo }: Props) => {
return (
<div className={styles.base}>
{children}
<MessageMeta kind="bubble" minutesAgo={minutesAgo} />
</div>
);
};

View File

@ -0,0 +1,33 @@
@import '../mixins';
@import '../../stylesheets/variables';
.base {
display: flex;
flex-direction: row;
justify-content: flex-end;
margin-top: 3px;
}
.item {
margin-left: 6px;
font: {
size: 11px;
family: $inter;
weight: normal;
}
}
.bubble {
composes: item;
color: rgba(255, 255, 255, 0.8);
}
.light {
composes: item;
color: $color-gray-60;
}
.dark {
composes: item;
color: rgba(255, 255, 255, 0.8);
}

View File

@ -0,0 +1,54 @@
import * as React from 'react';
import * as styles from './MessageMeta.scss';
import { useI18n } from '../util/i18n';
export type Props = {
kind?: 'bubble' | 'dark' | 'light';
minutesAgo: number;
};
const getItemClass = ({ kind }: Props) => {
if (kind === 'dark') {
return styles.dark;
}
if (kind === 'light') {
return styles.light;
}
return styles.bubble;
};
export const MessageMeta = React.memo((props: Props) => {
const i18n = useI18n();
const itemClass = getItemClass(props);
return (
<div className={styles.base}>
<svg width={12} height={12} className={itemClass}>
<g fillRule="evenodd">
<path d="M8.5 1.67L9 .804a6 6 0 11-7.919 1.76l.868.504-.008.011L6.25 5.567l-.5.866-4.309-2.488A5 5 0 108.5 1.67z" />
<path d="M6.003 1H6a5.06 5.06 0 00-.5.025V.02A6.08 6.08 0 016 0h.003A6 6 0 0112 6h-1a5 5 0 00-4.997-5zM3.443.572l.502.87a5.06 5.06 0 00-.866.5l-.502-.87a6.08 6.08 0 01.866-.5z" />
</g>
</svg>
<div className={itemClass}>{i18n('minutesAgo', [props.minutesAgo])}</div>
<svg width={18} height={12} className={itemClass}>
<defs>
<path
d="M7.917.313a6.99 6.99 0 00-2.844 6.7L5 7.084l-1.795-1.79L2.5 6 5 8.5l.34-.34a7.015 7.015 0 002.577 3.527A6.002 6.002 0 010 6 6.002 6.002 0 017.917.313zM12 0c3.312 0 6 2.688 6 6s-2.688 6-6 6-6-2.688-6-6 2.688-6 6-6zm-1 8.5L15.5 4l-.705-.71L11 7.085l-1.795-1.79L8.5 6 11 8.5z"
id="prefix__a"
/>
<path id="prefix__c" d="M0 0h18v12H0z" />
</defs>
<g fillRule="evenodd">
<mask id="prefix__b">
<use xlinkHref="#prefix__a" />
</mask>
<g mask="url(#prefix__b)">
<use xlinkHref="#prefix__c" />
</g>
</g>
</svg>
</div>
);
});

View File

@ -0,0 +1,10 @@
@import '../../stylesheets/variables';
.base {
padding: 6px 12px;
}
.image {
width: 116px;
height: 116px;
}

View File

@ -0,0 +1,17 @@
import * as React from 'react';
import { StoryRow } from './StoryRow';
import { MessageSticker } from './MessageSticker';
import { storiesOf } from '@storybook/react';
import { number, text } from '@storybook/addon-knobs';
storiesOf('Sticker Creator/elements', module).add('MessageSticker', () => {
const image = text('image url', '/fixtures/512x515-thumbs-up-lincoln.webp');
const minutesAgo = number('minutesAgo', 3);
return (
<StoryRow>
<MessageSticker image={image} minutesAgo={minutesAgo} />
</StoryRow>
);
});

View File

@ -0,0 +1,16 @@
import * as React from 'react';
import * as styles from './MessageSticker.scss';
import { MessageMeta, Props as MessageMetaProps } from './MessageMeta';
export type Props = MessageMetaProps & {
image: string;
};
export const MessageSticker = ({ image, kind, minutesAgo }: Props) => {
return (
<div className={styles.base}>
<img src={image} alt="Sticker" className={styles.image} />
<MessageMeta kind={kind} minutesAgo={minutesAgo} />
</div>
);
};

View File

@ -0,0 +1,22 @@
@import '../../stylesheets/variables';
@import '../mixins';
.base {
height: 47px;
width: 100%;
border-bottom-width: 1px;
border-bottom-style: solid;
padding: 0 16px;
display: flex;
flex-direction: row;
align-items: center;
flex-shrink: 0;
@include light-theme() {
border-bottom-color: $color-gray-15;
}
@include dark-theme() {
border-bottom-color: $color-gray-75;
}
}

View File

@ -0,0 +1,16 @@
import * as React from 'react';
import { StoryRow } from './StoryRow';
import { PageHeader } from './PageHeader';
import { storiesOf } from '@storybook/react';
import { text } from '@storybook/addon-knobs';
storiesOf('Sticker Creator/elements', module).add('PageHeader', () => {
const child = text('text', 'foo bar');
return (
<StoryRow>
<PageHeader>{child}</PageHeader>
</StoryRow>
);
});

View File

@ -0,0 +1,11 @@
import * as React from 'react';
import * as styles from './PageHeader.scss';
import { H1 } from './Typography';
export type Props = {
children: React.ReactNode;
};
export const PageHeader = React.memo(({ children }: Props) => (
<H1 className={styles.base}>{children}</H1>
));

View File

@ -0,0 +1,24 @@
@import '../../stylesheets/variables';
@import '../mixins';
.base {
height: 4px;
width: 100%;
max-width: 448px;
@include light-theme() {
background: $color-gray-15;
}
@include dark-theme() {
background: $color-gray-75;
}
}
.bar {
height: 4px;
width: 0px;
background: $color-signal-blue;
transition: width 100ms ease-out;
}

View File

@ -0,0 +1,17 @@
import * as React from 'react';
import { StoryRow } from './StoryRow';
import { ProgressBar } from './ProgressBar';
import { storiesOf } from '@storybook/react';
import { number } from '@storybook/addon-knobs';
storiesOf('Sticker Creator/elements', module).add('ProgressBar', () => {
const count = number('count', 5);
const total = number('total', 10);
return (
<StoryRow>
<ProgressBar count={count} total={total} />
</StoryRow>
);
});

View File

@ -0,0 +1,17 @@
import * as React from 'react';
import * as classnames from 'classnames';
import * as styles from './ProgressBar.scss';
export type Props = Pick<React.HTMLProps<HTMLDivElement>, 'className'> & {
readonly count: number;
readonly total: number;
};
export const ProgressBar = React.memo(({ className, count, total }: Props) => (
<div className={classnames(styles.base, className)}>
<div
className={styles.bar}
style={{ width: `${Math.floor(count / total * 100)}%` }}
/>
</div>
));

View File

@ -0,0 +1,120 @@
@import '../mixins';
@import '../../stylesheets/variables';
.base {
width: 380px;
padding: 4px;
display: flex;
flex-direction: row;
border-radius: 8px;
box-shadow: 0 2px 13px 3px rgba(0, 0, 0, 0.3);
position: relative;
@include light-theme() {
background: $color-white;
}
@include dark-theme() {
background: $color-gray-75;
}
}
.frame {
width: 50%;
padding: 12px 12px 3px 12px;
}
.frame-light {
composes: frame;
background: $color-white;
border-radius: 6px 0 0 6px;
}
.frame-dark {
composes: frame;
background: $color-black;
border-radius: 0 6px 6px 0;
}
.bottom {
composes: base;
margin-top: 8px;
}
.top {
composes: base;
margin-bottom: 8px;
}
.left {
composes: base;
margin-right: 8px;
}
.right {
composes: base;
margin-left: 8px;
}
.arrow {
position: absolute;
width: 0;
height: 0;
border-style: solid;
}
.arrow-top {
composes: arrow;
border-width: 0 8px 8px 8px;
top: -8px;
@include light-theme() {
border-color: transparent transparent $color-white transparent;
}
@include dark-theme() {
border-color: transparent transparent $color-gray-75 transparent;
}
}
.arrow-bottom {
composes: arrow;
border-width: 8px 8px 0 8px;
bottom: -8px;
@include light-theme() {
border-color: $color-white transparent transparent transparent;
}
@include dark-theme() {
border-color: $color-gray-75 transparent transparent transparent;
}
}
.arrow-left {
composes: arrow;
border-width: 8px 8px 8px 0;
left: -8px;
@include light-theme() {
border-color: transparent $color-white transparent transparent;
}
@include dark-theme() {
border-color: transparent $color-gray-75 transparent transparent;
}
}
.arrow-right {
composes: arrow;
border-width: 8px 0 8px 8px;
right: -8px;
@include light-theme() {
border-color: transparent transparent transparent $color-white;
}
@include dark-theme() {
border-color: transparent transparent transparent $color-gray-75;
}
}

View File

@ -0,0 +1,16 @@
import * as React from 'react';
import { StoryRow } from './StoryRow';
import { StickerPreview } from './StickerPreview';
import { storiesOf } from '@storybook/react';
import { text } from '@storybook/addon-knobs';
storiesOf('Sticker Creator/elements', module).add('StickerPreview', () => {
const image = text('image url', '/fixtures/512x515-thumbs-up-lincoln.webp');
return (
<StoryRow>
<StickerPreview image={image} />
</StoryRow>
);
});

View File

@ -0,0 +1,90 @@
import * as React from 'react';
import { PopperArrowProps } from 'react-popper';
import { Placement } from 'popper.js';
import * as styles from './StickerPreview.scss';
import { MessageBubble } from './MessageBubble';
import { MessageSticker, Props as MessageStickerProps } from './MessageSticker';
import { useI18n } from '../util/i18n';
export type Props = Pick<React.HTMLProps<HTMLDivElement>, 'style'> & {
image: string;
arrowProps?: PopperArrowProps;
placement?: Placement;
};
const renderMessages = (
text: string,
image: string,
kind: MessageStickerProps['kind']
) => (
<>
<MessageBubble minutesAgo={3}>{text}</MessageBubble>
<MessageSticker image={image} kind={kind} minutesAgo={2} />
</>
);
const getBaseClass = (placement?: Placement) => {
if (placement === 'top') {
return styles.top;
}
if (placement === 'right') {
return styles.right;
}
if (placement === 'left') {
return styles.left;
}
return styles.bottom;
};
const getArrowClass = (placement?: Placement) => {
if (placement === 'top') {
return styles.arrowBottom;
}
if (placement === 'right') {
return styles.arrowLeft;
}
if (placement === 'left') {
return styles.arrowRight;
}
return styles.arrowTop;
};
export const StickerPreview = React.memo(
React.forwardRef<HTMLDivElement, Props>(
({ image, style, arrowProps, placement }: Props, ref) => {
const i18n = useI18n();
return (
<div className={getBaseClass(placement)} ref={ref} style={style}>
{arrowProps ? (
<div
ref={arrowProps.ref}
style={arrowProps.style}
className={getArrowClass(placement)}
/>
) : null}
<div className={styles.frameLight}>
{renderMessages(
i18n('StickerCreator--StickerPreview--light'),
image,
'light'
)}
</div>
<div className={styles.frameDark}>
{renderMessages(
i18n('StickerCreator--StickerPreview--dark'),
image,
'dark'
)}
</div>
</div>
);
}
)
);

View File

@ -0,0 +1,28 @@
.base {
flex: 1;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
width: 100%;
}
.left {
composes: base;
justify-content: flex-start;
}
.right {
composes: base;
justify-content: flex-end;
}
.top {
composes: base;
align-items: flex-start;
}
.bottom {
composes: base;
align-items: flex-end;
}

View File

@ -0,0 +1,34 @@
import * as React from 'react';
import * as styles from './StoryRow.scss';
export type Props = {
children: React.ReactChild;
left?: boolean;
right?: boolean;
top?: boolean;
bottom?: boolean;
};
const getClassName = ({ left, right, top, bottom }: Props) => {
if (left) {
return styles.left;
}
if (right) {
return styles.right;
}
if (top) {
return styles.top;
}
if (bottom) {
return styles.bottom;
}
return styles.base;
};
export const StoryRow = (props: Props) => (
<div className={getClassName(props)}>{props.children}</div>
);

View File

@ -0,0 +1,14 @@
@import '../../stylesheets/variables';
.base {
padding: 8px 12px;
border-radius: 4px;
border: none;
background-color: $color-gray-75;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.08), 0 8px 20px 0px rgba(0, 0, 0, 0.33);
font-family: $inter;
font-weight: normal;
font-size: 14px;
color: $color-gray-05;
line-height: 18px;
}

View File

@ -0,0 +1,16 @@
import * as React from 'react';
import { StoryRow } from './StoryRow';
import { Toast } from './Toast';
import { storiesOf } from '@storybook/react';
import { text } from '@storybook/addon-knobs';
storiesOf('Sticker Creator/elements', module).add('Toast', () => {
const child = text('text', 'foo bar');
return (
<StoryRow>
<Toast>{child}</Toast>
</StoryRow>
);
});

View File

@ -0,0 +1,12 @@
import * as React from 'react';
import * as styles from './Toast.scss';
export type Props = React.HTMLProps<HTMLButtonElement> & {
children: React.ReactNode;
};
export const Toast = React.memo(({ children, ...rest }: Props) => (
<button className={styles.base} {...rest}>
{children}
</button>
));

View File

@ -0,0 +1,65 @@
@import '../../stylesheets/variables';
@import '../mixins';
.base {
font-family: $inter;
margin: 0;
}
.heading {
composes: base;
font-weight: 500;
}
.h1 {
composes: heading;
font-size: 16px;
line-height: 20px;
@include light-theme() {
color: $color-gray-90;
}
@include dark-theme() {
color: $color-gray-05;
}
}
.h2 {
composes: heading;
font-size: 14px;
line-height: 18px;
margin-bottom: 8px;
@include light-theme() {
color: $color-gray-90;
}
@include dark-theme() {
color: $color-white;
}
}
.text {
composes: base;
font-size: 13px;
line-height: 18px;
@include light-theme() {
color: $color-gray-90;
}
@include dark-theme() {
color: $color-white;
}
a {
color: $color-signal-blue;
text-decoration: none;
}
}
.text-center {
composes: text;
text-align: center;
}

View File

@ -0,0 +1,34 @@
import * as React from 'react';
import { StoryRow } from './StoryRow';
import { H1, H2, Text } from './Typography';
import { storiesOf } from '@storybook/react';
import { text } from '@storybook/addon-knobs';
storiesOf('Sticker Creator/elements', module).add('Typography', () => {
const child = text('text', 'foo bar');
return (
<>
<StoryRow left={true}>
<H1>{child}</H1>
</StoryRow>
<StoryRow left={true}>
<H2>{child}</H2>
</StoryRow>
<StoryRow left={true}>
<Text>
{child} {child} {child} {child}
</Text>
</StoryRow>
<StoryRow left={true}>
<Text>
{child} {child} {child} {child}{' '}
<a href="javascript: void 0;">
Something something something dark side.
</a>
</Text>
</StoryRow>
</>
);
});

View File

@ -0,0 +1,52 @@
import * as React from 'react';
import * as classnames from 'classnames';
import * as styles from './Typography.scss';
export type Props = {
children: React.ReactNode;
};
export type HeadingProps = React.HTMLProps<HTMLHeadingElement>;
export type ParagraphProps = React.HTMLProps<HTMLParagraphElement> & {
center?: boolean;
wide?: boolean;
};
export type SpanProps = React.HTMLProps<HTMLSpanElement>;
export const H1 = React.memo(
({ children, className, ...rest }: Props & HeadingProps) => (
<h1 className={classnames(styles.h1, className)} {...rest}>
{children}
</h1>
)
);
export const H2 = React.memo(
({ children, className, ...rest }: Props & HeadingProps) => (
<h2 className={classnames(styles.h2, className)} {...rest}>
{children}
</h2>
)
);
export const Text = React.memo(
({ children, className, center, wide, ...rest }: Props & ParagraphProps) => (
<p
className={classnames(
center ? styles.textCenter : styles.text,
className
)}
{...rest}
>
{children}
</p>
)
);
export const Inline = React.memo(
({ children, className, ...rest }: Props & SpanProps) => (
<span className={classnames(styles.text, className)} {...rest}>
{children}
</span>
)
);

View File

@ -0,0 +1,7 @@
import * as React from 'react';
export const AddEmoji = React.memo(() => (
<svg width="32px" height="20px" viewBox="0 0 32 20">
<path d="M21.993 15.5a5.679 5.679 0 0 1-4.6-2.29.75.75 0 1 1 1.212-.883 4.266 4.266 0 0 0 6.786-.015.749.749 0 0 1 1.216.876 5.671 5.671 0 0 1-4.614 2.312zM22 2.5a7.254 7.254 0 0 0-7.5 7.5 7.254 7.254 0 0 0 7.5 7.5 7.254 7.254 0 0 0 7.5-7.5A7.254 7.254 0 0 0 22 2.5M22 1a8.751 8.751 0 0 1 9 9 8.751 8.751 0 0 1-9 9 8.751 8.751 0 0 1-9-9 8.751 8.751 0 0 1 9-9zm-2.75 5.75A1.476 1.476 0 0 0 18 8.375 1.476 1.476 0 0 0 19.25 10a1.476 1.476 0 0 0 1.25-1.625 1.476 1.476 0 0 0-1.25-1.625zm5.5 0a1.476 1.476 0 0 0-1.25 1.625A1.476 1.476 0 0 0 24.75 10 1.476 1.476 0 0 0 26 8.375a1.476 1.476 0 0 0-1.25-1.625zM10 9.25H6.75V6h-1.5v3.25H2v1.5h3.25V14h1.5v-3.25H10z" />
</svg>
));

View File

@ -0,0 +1 @@
export { AddEmoji } from './AddEmoji';

View File

@ -0,0 +1,13 @@
<!doctype html>
<html>
<head>
<link rel="stylesheet" href="../../stylesheets/manifest_bridge.css" />
</head>
<body>
<div id="root"></div>
<script type="text/javascript" src="../../js/components.js"></script>
<script type="text/javascript" src="../../js/signal_protocol_store.js"></script>
<script type="text/javascript" src="../../js/storage.js"></script>
<script type="text/javascript" src="../../js/libtextsecure.js"></script>
</body>
</html>

10
sticker-creator/index.tsx Normal file
View File

@ -0,0 +1,10 @@
import * as React from 'react';
import { render } from 'react-dom';
import { Root } from './root';
import { preloadImages } from '../ts/components/emoji/lib';
const root = document.getElementById('root');
render(<Root />, root);
preloadImages();

159
sticker-creator/preload.js Normal file
View File

@ -0,0 +1,159 @@
/* global window */
const { ipcRenderer: ipc, remote } = require('electron');
const sharp = require('sharp');
const pify = require('pify');
const { readFile } = require('fs');
const config = require('url').parse(window.location.toString(), true).query;
const { noop, uniqBy } = require('lodash');
const { deriveStickerPackKey } = require('../js/modules/crypto');
const { makeGetter } = require('../preload_utils');
const { dialog } = remote;
const { systemPreferences } = remote.require('electron');
window.ROOT_PATH = window.location.href.startsWith('file') ? '../../' : '/';
window.PROTO_ROOT = '../../protos';
window.getEnvironment = () => config.environment;
window.getVersion = () => config.version;
window.getGuid = require('uuid/v4');
window.localeMessages = ipc.sendSync('locale-data');
require('../js/logging');
const Signal = require('../js/modules/signal');
window.Signal = Signal.setup({});
const { initialize: initializeWebAPI } = require('../js/modules/web_api');
const WebAPI = initializeWebAPI({
url: config.serverUrl,
cdnUrl: config.cdnUrl,
certificateAuthority: config.certificateAuthority,
contentProxyUrl: config.contentProxyUrl,
proxyUrl: config.proxyUrl,
});
window.convertToWebp = async (path, width = 512, height = 512) => {
const pngBuffer = await pify(readFile)(path);
const buffer = await sharp(pngBuffer)
.resize({
width,
height,
fit: 'contain',
background: { r: 0, g: 0, b: 0, alpha: 0 },
})
.webp()
.toBuffer();
return {
path,
buffer,
src: `data:image/webp;base64,${buffer.toString('base64')}`,
};
};
window.encryptAndUpload = async (
manifest,
stickers,
cover,
onProgress = noop
) => {
const usernameItem = await window.Signal.Data.getItemById('number_id');
const passwordItem = await window.Signal.Data.getItemById('password');
if (!usernameItem || !passwordItem) {
const { message } = window.localeMessages[
'StickerCreator--Authentication--error'
];
dialog.showMessageBox({
type: 'warning',
message,
});
throw new Error(message);
}
const { value: username } = usernameItem;
const { value: password } = passwordItem;
const packKey = window.libsignal.crypto.getRandomBytes(32);
const encryptionKey = await deriveStickerPackKey(packKey);
const iv = window.libsignal.crypto.getRandomBytes(16);
const server = WebAPI.connect({ username, password });
const uniqueStickers = uniqBy([...stickers, { webp: cover }], 'webp');
const manifestProto = new window.textsecure.protobuf.StickerPack();
manifestProto.title = manifest.title;
manifestProto.author = manifest.author;
manifestProto.stickers = stickers.map(({ emoji }, id) => {
const s = new window.textsecure.protobuf.StickerPack.Sticker();
s.id = id;
s.emoji = emoji;
return s;
});
const coverSticker = new window.textsecure.protobuf.StickerPack.Sticker();
coverSticker.id =
uniqueStickers.length === stickers.length ? 0 : uniqueStickers.length - 1;
coverSticker.emoji = '';
manifestProto.cover = coverSticker;
const encryptedManifest = await encrypt(
manifestProto.toArrayBuffer(),
encryptionKey,
iv
);
const encryptedStickers = await Promise.all(
uniqueStickers.map(({ webp }) => encrypt(webp.buffer, encryptionKey, iv))
);
const packId = await server.putStickers(
encryptedManifest,
encryptedStickers,
onProgress
);
const hexKey = window.Signal.Crypto.hexFromBytes(packKey);
ipc.send('install-sticker-pack', packId, hexKey);
return { packId, key: hexKey };
};
async function encrypt(data, key, iv) {
const { ciphertext } = await window.textsecure.crypto.encryptAttachment(
// Convert Node Buffer to ArrayBuffer
window.Signal.Crypto.concatenateBytes(data),
key,
iv
);
return ciphertext;
}
const getThemeSetting = makeGetter('theme-setting');
async function resolveTheme() {
const theme = (await getThemeSetting()) || 'light';
if (process.platform === 'darwin' && theme === 'system') {
return systemPreferences.isDarkMode() ? 'dark' : 'light';
}
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);
systemPreferences.subscribeNotification(
'AppleInterfaceThemeChangedNotification',
applyTheme
);

24
sticker-creator/root.tsx Normal file
View File

@ -0,0 +1,24 @@
// tslint:disable-next-line no-submodule-imports
import { hot } from 'react-hot-loader/root';
import * as React from 'react';
import { Provider as ReduxProvider } from 'react-redux';
import { Router } from 'react-router-dom';
import { App } from './app';
import { history } from './util/history';
import { store } from './store';
import { I18n } from './util/i18n';
// @ts-ignore
const { localeMessages } = window;
const ColdRoot = () => (
<ReduxProvider store={store}>
<Router history={history}>
<I18n messages={localeMessages}>
<App />
</I18n>
</Router>
</ReduxProvider>
);
export const Root = hot(ColdRoot);

View File

@ -0,0 +1,250 @@
// tslint:disable no-dynamic-delete
import { useMemo } from 'react';
import {
createAction,
Draft,
handleAction,
reduceReducers,
} from 'redux-ts-utils';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import { clamp, pull, take, uniq } from 'lodash';
import { SortEnd } from 'react-sortable-hoc';
import arrayMove from 'array-move';
import { AppState } from '../reducer';
import { PackMetaData, WebpData } from '../../util/preload';
import { EmojiPickDataType } from '../../../ts/components/emoji/EmojiPicker';
import { convertShortName } from '../../../ts/components/emoji/lib';
export const initializeStickers = createAction<Array<string>>(
'stickers/initializeStickers'
);
export const addWebp = createAction<WebpData>('stickers/addSticker');
export const removeSticker = createAction<string>('stickers/removeSticker');
export const moveSticker = createAction<SortEnd>('stickers/moveSticker');
export const setCover = createAction<WebpData>('stickers/setCover');
export const setEmoji = createAction<{ id: string; emoji: EmojiPickDataType }>(
'stickers/setEmoji'
);
export const setTitle = createAction<string>('stickers/setTitle');
export const setAuthor = createAction<string>('stickers/setAuthor');
export const setPackMeta = createAction<PackMetaData>('stickers/setPackMeta');
export const resetStatus = createAction<void>('stickers/resetStatus');
export const reset = createAction<void>('stickers/reset');
export const minStickers = 4;
export const maxStickers = 40;
export const maxByteSize = 100 * 1024;
export type State = {
readonly order: Array<string>;
readonly cover?: WebpData;
readonly title: string;
readonly author: string;
readonly packId: string;
readonly packKey: string;
readonly tooLarge: number;
readonly imagesAdded: number;
readonly data: {
readonly [src: string]: {
readonly webp?: WebpData;
readonly emoji?: EmojiPickDataType;
};
};
};
const defaultState: State = {
order: [],
data: {},
title: '',
author: '',
packId: '',
packKey: '',
tooLarge: 0,
imagesAdded: 0,
};
const adjustCover = (state: Draft<State>) => {
const first = state.order[0];
if (first) {
state.cover = state.data[first].webp;
} else {
delete state.cover;
}
};
export const reducer = reduceReducers<State>(
[
handleAction(initializeStickers, (state, { payload }) => {
const truncated = take(
uniq([...state.order, ...payload]),
maxStickers - state.order.length
);
truncated.forEach(path => {
if (!state.data[path]) {
state.data[path] = {};
state.order.push(path);
}
});
}),
handleAction(addWebp, (state, { payload }) => {
if (payload.buffer.byteLength > maxByteSize) {
state.tooLarge = clamp(state.tooLarge + 1, 0, state.order.length);
pull(state.order, payload.path);
delete state.data[payload.path];
} else {
const data = state.data[payload.path];
if (data) {
data.webp = payload;
state.imagesAdded = clamp(
state.imagesAdded + 1,
0,
state.order.length
);
}
}
adjustCover(state);
}),
handleAction(removeSticker, (state, { payload }) => {
pull(state.order, payload);
delete state.data[payload];
adjustCover(state);
state.imagesAdded = clamp(state.imagesAdded - 1, 0, state.order.length);
}),
handleAction(moveSticker, (state, { payload }) => {
arrayMove.mutate(state.order, payload.oldIndex, payload.newIndex);
}),
handleAction(setCover, (state, { payload }) => {
state.cover = payload;
}),
handleAction(setEmoji, (state, { payload }) => {
const data = state.data[payload.id];
if (data) {
data.emoji = payload.emoji;
}
}),
handleAction(setTitle, (state, { payload }) => {
state.title = payload;
}),
handleAction(setAuthor, (state, { payload }) => {
state.author = payload;
}),
handleAction(setPackMeta, (state, { payload: { packId, key } }) => {
state.packId = packId;
state.packKey = key;
}),
handleAction(resetStatus, state => {
state.tooLarge = 0;
state.imagesAdded = 0;
}),
handleAction(reset, () => defaultState),
],
defaultState
);
export const useTitle = () =>
useSelector(({ stickers }: AppState) => stickers.title);
export const useAuthor = () =>
useSelector(({ stickers }: AppState) => stickers.author);
export const useCover = () =>
useSelector(({ stickers }: AppState) => stickers.cover);
export const useStickerOrder = () =>
useSelector(({ stickers }: AppState) => stickers.order);
export const useStickerData = (src: string) =>
useSelector(({ stickers }: AppState) => stickers.data[src]);
export const useStickersReady = () =>
useSelector(
({ stickers }: AppState) =>
stickers.order.length >= minStickers &&
stickers.order.length <= maxStickers &&
Object.values(stickers.data).every(({ webp }) => !!webp)
);
export const useEmojisReady = () =>
useSelector(({ stickers }: AppState) =>
Object.values(stickers.data).every(({ emoji }) => !!emoji)
);
export const useAllDataValid = () => {
const stickersReady = useStickersReady();
const emojisReady = useEmojisReady();
const cover = useCover();
const title = useTitle();
const author = useAuthor();
return !!(stickersReady && emojisReady && cover && title && author);
};
const selectUrl = createSelector(
({ stickers }: AppState) => stickers.packId,
({ stickers }: AppState) => stickers.packKey,
(id, key) => `https://signal.art/addstickers/#pack_id=${id}&pack_key=${key}`
);
export const usePackUrl = () => useSelector(selectUrl);
export const useHasTooLarge = () =>
useSelector(({ stickers }: AppState) => stickers.tooLarge > 0);
export const useImageAddedCount = () =>
useSelector(({ stickers }: AppState) => stickers.imagesAdded);
const selectOrderedData = createSelector(
({ stickers }: AppState) => stickers.order,
({ stickers }) => stickers.data,
(order, data) =>
order.map(id => ({
...data[id],
emoji: convertShortName(
data[id].emoji.shortName,
data[id].emoji.skinTone
),
}))
);
export const useSelectOrderedData = () => useSelector(selectOrderedData);
const selectOrderedImagePaths = createSelector(selectOrderedData, data =>
data.map(({ webp }) => webp.src)
);
export const useOrderedImagePaths = () => useSelector(selectOrderedImagePaths);
export const useStickerActions = () => {
const dispatch = useDispatch();
return useMemo(
() => ({
addWebp: (data: WebpData) => dispatch(addWebp(data)),
initializeStickers: (paths: Array<string>) =>
dispatch(initializeStickers(paths)),
removeSticker: (src: string) => dispatch(removeSticker(src)),
moveSticker: (sortEnd: SortEnd) => dispatch(moveSticker(sortEnd)),
setCover: (webp: WebpData) => dispatch(setCover(webp)),
setEmoji: (p: { id: string; emoji: EmojiPickDataType }) =>
dispatch(setEmoji(p)),
setTitle: (title: string) => dispatch(setTitle(title)),
setAuthor: (author: string) => dispatch(setAuthor(author)),
setPackMeta: (e: PackMetaData) => dispatch(setPackMeta(e)),
reset: () => dispatch(reset()),
resetStatus: () => dispatch(resetStatus()),
}),
[dispatch]
);
};

View File

@ -0,0 +1,8 @@
import { createStore } from 'redux';
import { reducer } from './reducer';
import * as stickersDuck from './ducks/stickers';
export { stickersDuck };
export const store = createStore(reducer);

Some files were not shown because too many files have changed in this diff Show More