Sticker Creator
This commit is contained in:
parent
2df1ba6e61
commit
11d47a8eb9
123 changed files with 11287 additions and 1714 deletions
8
.babelrc.js
Normal file
8
.babelrc.js
Normal 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',
|
||||
],
|
||||
};
|
|
@ -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
6
.gitignore
vendored
|
@ -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/*
|
||||
|
|
|
@ -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
2
.storybook/addons.js
Normal file
|
@ -0,0 +1,2 @@
|
|||
import '@storybook/addon-knobs/register';
|
||||
import '@storybook/addon-actions/register';
|
37
.storybook/config.js
Normal file
37
.storybook/config.js
Normal 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);
|
2
.storybook/preview-head.html
Normal file
2
.storybook/preview-head.html
Normal file
|
@ -0,0 +1,2 @@
|
|||
<!-- prettier-ignore -->
|
||||
<link rel="stylesheet" href="../stylesheets/manifest_bridge.css" />
|
21
.storybook/styles.scss
Normal file
21
.storybook/styles.scss
Normal 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;
|
||||
}
|
25
.storybook/webpack.config.js
Normal file
25
.storybook/webpack.config.js
Normal 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;
|
||||
};
|
|
@ -56,6 +56,7 @@ npm install --global yarn # (only if you don’t 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.
|
||||
|
|
|
@ -98,6 +98,7 @@ module.exports = grunt => {
|
|||
dev: {
|
||||
files: {
|
||||
'stylesheets/manifest.css': 'stylesheets/manifest.scss',
|
||||
'stylesheets/manifest_bridge.css': 'stylesheets/manifest_bridge.scss',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)}`);
|
||||
});
|
||||
|
|
58
app/menu.js
58
app/menu.js
|
@ -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',
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -399,6 +399,12 @@
|
|||
),
|
||||
});
|
||||
},
|
||||
|
||||
installStickerPack: async (packId, key) => {
|
||||
window.Signal.Stickers.downloadStickerPack(packId, key, {
|
||||
finalStatus: 'installed',
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
if (isIndexedDBPresent) {
|
||||
|
|
|
@ -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
100
main.js
|
@ -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 });
|
||||
});
|
||||
|
|
73
package.json
73
package.json
|
@ -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-*",
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
52
preload.js
52
preload.js
|
@ -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
74
preload_utils.js
Normal 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);
|
||||
});
|
||||
};
|
9
sticker-creator/_mixins.scss
Normal file
9
sticker-creator/_mixins.scss
Normal file
|
@ -0,0 +1,9 @@
|
|||
@mixin light-theme() {
|
||||
@content;
|
||||
}
|
||||
|
||||
@mixin dark-theme() {
|
||||
:global(.dark-theme) & {
|
||||
@content;
|
||||
}
|
||||
}
|
16
sticker-creator/app/index.scss
Normal file
16
sticker-creator/app/index.scss
Normal 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;
|
||||
}
|
||||
}
|
38
sticker-creator/app/index.tsx
Normal file
38
sticker-creator/app/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
41
sticker-creator/app/stages/AppStage.scss
Normal file
41
sticker-creator/app/stages/AppStage.scss
Normal 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);
|
||||
}
|
81
sticker-creator/app/stages/AppStage.tsx
Normal file
81
sticker-creator/app/stages/AppStage.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
24
sticker-creator/app/stages/DropStage.scss
Normal file
24
sticker-creator/app/stages/DropStage.scss
Normal 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;
|
||||
}
|
79
sticker-creator/app/stages/DropStage.tsx
Normal file
79
sticker-creator/app/stages/DropStage.tsx
Normal 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>
|
||||
);
|
||||
};
|
26
sticker-creator/app/stages/EmojiStage.tsx
Normal file
26
sticker-creator/app/stages/EmojiStage.tsx
Normal 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>
|
||||
);
|
||||
};
|
72
sticker-creator/app/stages/MetaStage.scss
Normal file
72
sticker-creator/app/stages/MetaStage.scss
Normal 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;
|
||||
}
|
||||
}
|
112
sticker-creator/app/stages/MetaStage.tsx
Normal file
112
sticker-creator/app/stages/MetaStage.tsx
Normal 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>
|
||||
);
|
||||
};
|
24
sticker-creator/app/stages/ShareStage.scss
Normal file
24
sticker-creator/app/stages/ShareStage.scss
Normal 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;
|
||||
}
|
92
sticker-creator/app/stages/ShareStage.tsx
Normal file
92
sticker-creator/app/stages/ShareStage.tsx
Normal 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>
|
||||
);
|
||||
};
|
10
sticker-creator/app/stages/UploadStage.scss
Normal file
10
sticker-creator/app/stages/UploadStage.scss
Normal file
|
@ -0,0 +1,10 @@
|
|||
.base {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.progress {
|
||||
margin: 24px 0;
|
||||
}
|
64
sticker-creator/app/stages/UploadStage.tsx
Normal file
64
sticker-creator/app/stages/UploadStage.tsx
Normal 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>
|
||||
);
|
||||
};
|
11
sticker-creator/components/ConfirmModal.scss
Normal file
11
sticker-creator/components/ConfirmModal.scss
Normal 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;
|
||||
}
|
44
sticker-creator/components/ConfirmModal.tsx
Normal file
44
sticker-creator/components/ConfirmModal.tsx
Normal 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;
|
||||
}
|
||||
);
|
27
sticker-creator/components/ShareButtons.scss
Normal file
27
sticker-creator/components/ShareButtons.scss
Normal 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;
|
||||
}
|
16
sticker-creator/components/ShareButtons.stories.tsx
Normal file
16
sticker-creator/components/ShareButtons.stories.tsx
Normal 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>
|
||||
);
|
||||
});
|
70
sticker-creator/components/ShareButtons.tsx
Normal file
70
sticker-creator/components/ShareButtons.tsx
Normal 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>
|
||||
);
|
||||
});
|
140
sticker-creator/components/StickerFrame.scss
Normal file
140
sticker-creator/components/StickerFrame.scss
Normal 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);
|
||||
}
|
||||
}
|
34
sticker-creator/components/StickerFrame.stories.tsx
Normal file
34
sticker-creator/components/StickerFrame.stories.tsx
Normal 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>
|
||||
);
|
||||
});
|
271
sticker-creator/components/StickerFrame.tsx
Normal file
271
sticker-creator/components/StickerFrame.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
);
|
13
sticker-creator/components/StickerGrid.scss
Normal file
13
sticker-creator/components/StickerGrid.scss
Normal 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;
|
||||
}
|
110
sticker-creator/components/StickerGrid.tsx
Normal file
110
sticker-creator/components/StickerGrid.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
});
|
126
sticker-creator/components/StickerPackPreview.scss
Normal file
126
sticker-creator/components/StickerPackPreview.scss
Normal 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;
|
||||
}
|
||||
}
|
22
sticker-creator/components/StickerPackPreview.stories.tsx
Normal file
22
sticker-creator/components/StickerPackPreview.stories.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
);
|
34
sticker-creator/components/StickerPackPreview.tsx
Normal file
34
sticker-creator/components/StickerPackPreview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
);
|
80
sticker-creator/elements/Button.scss
Normal file
80
sticker-creator/elements/Button.scss
Normal 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;
|
||||
}
|
||||
}
|
55
sticker-creator/elements/Button.stories.tsx
Normal file
55
sticker-creator/elements/Button.stories.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
});
|
39
sticker-creator/elements/Button.tsx
Normal file
39
sticker-creator/elements/Button.tsx
Normal 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>
|
||||
);
|
||||
};
|
98
sticker-creator/elements/ConfirmDialog.scss
Normal file
98
sticker-creator/elements/ConfirmDialog.scss
Normal 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;
|
||||
}
|
||||
}
|
29
sticker-creator/elements/ConfirmDialog.stories.tsx
Normal file
29
sticker-creator/elements/ConfirmDialog.stories.tsx
Normal 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>
|
||||
);
|
||||
});
|
39
sticker-creator/elements/ConfirmDialog.tsx
Normal file
39
sticker-creator/elements/ConfirmDialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
36
sticker-creator/elements/CopyText.scss
Normal file
36
sticker-creator/elements/CopyText.scss
Normal 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;
|
||||
}
|
||||
}
|
17
sticker-creator/elements/CopyText.stories.tsx
Normal file
17
sticker-creator/elements/CopyText.stories.tsx
Normal 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>
|
||||
);
|
||||
});
|
39
sticker-creator/elements/CopyText.tsx
Normal file
39
sticker-creator/elements/CopyText.tsx
Normal 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>
|
||||
);
|
||||
});
|
61
sticker-creator/elements/DropZone.scss
Normal file
61
sticker-creator/elements/DropZone.scss
Normal 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;
|
||||
}
|
||||
}
|
9
sticker-creator/elements/DropZone.stories.tsx
Normal file
9
sticker-creator/elements/DropZone.stories.tsx
Normal 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')} />;
|
||||
});
|
65
sticker-creator/elements/DropZone.tsx
Normal file
65
sticker-creator/elements/DropZone.tsx
Normal 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>
|
||||
);
|
||||
};
|
50
sticker-creator/elements/LabeledCheckbox.scss
Normal file
50
sticker-creator/elements/LabeledCheckbox.scss
Normal 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;
|
||||
}
|
19
sticker-creator/elements/LabeledCheckbox.stories.tsx
Normal file
19
sticker-creator/elements/LabeledCheckbox.stories.tsx
Normal 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>
|
||||
);
|
||||
});
|
43
sticker-creator/elements/LabeledCheckbox.tsx
Normal file
43
sticker-creator/elements/LabeledCheckbox.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
);
|
55
sticker-creator/elements/LabeledInput.scss
Normal file
55
sticker-creator/elements/LabeledInput.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
20
sticker-creator/elements/LabeledInput.stories.tsx
Normal file
20
sticker-creator/elements/LabeledInput.stories.tsx
Normal 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>
|
||||
);
|
||||
});
|
34
sticker-creator/elements/LabeledInput.tsx
Normal file
34
sticker-creator/elements/LabeledInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
);
|
13
sticker-creator/elements/MessageBubble.scss
Normal file
13
sticker-creator/elements/MessageBubble.scss
Normal 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;
|
||||
}
|
||||
}
|
17
sticker-creator/elements/MessageBubble.stories.tsx
Normal file
17
sticker-creator/elements/MessageBubble.stories.tsx
Normal 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>
|
||||
);
|
||||
});
|
16
sticker-creator/elements/MessageBubble.tsx
Normal file
16
sticker-creator/elements/MessageBubble.tsx
Normal 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>
|
||||
);
|
||||
};
|
33
sticker-creator/elements/MessageMeta.scss
Normal file
33
sticker-creator/elements/MessageMeta.scss
Normal 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);
|
||||
}
|
54
sticker-creator/elements/MessageMeta.tsx
Normal file
54
sticker-creator/elements/MessageMeta.tsx
Normal 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>
|
||||
);
|
||||
});
|
10
sticker-creator/elements/MessageSticker.scss
Normal file
10
sticker-creator/elements/MessageSticker.scss
Normal file
|
@ -0,0 +1,10 @@
|
|||
@import '../../stylesheets/variables';
|
||||
|
||||
.base {
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
.image {
|
||||
width: 116px;
|
||||
height: 116px;
|
||||
}
|
17
sticker-creator/elements/MessageSticker.stories.tsx
Normal file
17
sticker-creator/elements/MessageSticker.stories.tsx
Normal 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>
|
||||
);
|
||||
});
|
16
sticker-creator/elements/MessageSticker.tsx
Normal file
16
sticker-creator/elements/MessageSticker.tsx
Normal 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>
|
||||
);
|
||||
};
|
22
sticker-creator/elements/PageHeader.scss
Normal file
22
sticker-creator/elements/PageHeader.scss
Normal 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;
|
||||
}
|
||||
}
|
16
sticker-creator/elements/PageHeader.stories.tsx
Normal file
16
sticker-creator/elements/PageHeader.stories.tsx
Normal 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>
|
||||
);
|
||||
});
|
11
sticker-creator/elements/PageHeader.tsx
Normal file
11
sticker-creator/elements/PageHeader.tsx
Normal 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>
|
||||
));
|
24
sticker-creator/elements/ProgressBar.scss
Normal file
24
sticker-creator/elements/ProgressBar.scss
Normal 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;
|
||||
}
|
17
sticker-creator/elements/ProgressBar.stories.tsx
Normal file
17
sticker-creator/elements/ProgressBar.stories.tsx
Normal 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>
|
||||
);
|
||||
});
|
17
sticker-creator/elements/ProgressBar.tsx
Normal file
17
sticker-creator/elements/ProgressBar.tsx
Normal 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>
|
||||
));
|
120
sticker-creator/elements/StickerPreview.scss
Normal file
120
sticker-creator/elements/StickerPreview.scss
Normal 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;
|
||||
}
|
||||
}
|
16
sticker-creator/elements/StickerPreview.stories.tsx
Normal file
16
sticker-creator/elements/StickerPreview.stories.tsx
Normal 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>
|
||||
);
|
||||
});
|
90
sticker-creator/elements/StickerPreview.tsx
Normal file
90
sticker-creator/elements/StickerPreview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
)
|
||||
);
|
28
sticker-creator/elements/StoryRow.scss
Normal file
28
sticker-creator/elements/StoryRow.scss
Normal 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;
|
||||
}
|
34
sticker-creator/elements/StoryRow.tsx
Normal file
34
sticker-creator/elements/StoryRow.tsx
Normal 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>
|
||||
);
|
14
sticker-creator/elements/Toast.scss
Normal file
14
sticker-creator/elements/Toast.scss
Normal 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;
|
||||
}
|
16
sticker-creator/elements/Toast.stories.tsx
Normal file
16
sticker-creator/elements/Toast.stories.tsx
Normal 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>
|
||||
);
|
||||
});
|
12
sticker-creator/elements/Toast.tsx
Normal file
12
sticker-creator/elements/Toast.tsx
Normal 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>
|
||||
));
|
65
sticker-creator/elements/Typography.scss
Normal file
65
sticker-creator/elements/Typography.scss
Normal 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;
|
||||
}
|
34
sticker-creator/elements/Typography.stories.tsx
Normal file
34
sticker-creator/elements/Typography.stories.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
});
|
52
sticker-creator/elements/Typography.tsx
Normal file
52
sticker-creator/elements/Typography.tsx
Normal 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>
|
||||
)
|
||||
);
|
7
sticker-creator/elements/icons/AddEmoji.tsx
Normal file
7
sticker-creator/elements/icons/AddEmoji.tsx
Normal 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>
|
||||
));
|
1
sticker-creator/elements/icons/index.tsx
Normal file
1
sticker-creator/elements/icons/index.tsx
Normal file
|
@ -0,0 +1 @@
|
|||
export { AddEmoji } from './AddEmoji';
|
13
sticker-creator/index.html
Normal file
13
sticker-creator/index.html
Normal 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
10
sticker-creator/index.tsx
Normal 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
159
sticker-creator/preload.js
Normal 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
24
sticker-creator/root.tsx
Normal 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);
|
250
sticker-creator/store/ducks/stickers.ts
Normal file
250
sticker-creator/store/ducks/stickers.ts
Normal 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]
|
||||
);
|
||||
};
|
8
sticker-creator/store/index.ts
Normal file
8
sticker-creator/store/index.ts
Normal 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
Loading…
Reference in a new issue