diff --git a/CHANGELOG.md b/CHANGELOG.md index 30b6db5f45..e008f3a322 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,76 +1,129 @@ # N1 Changelog +### 0.4.19 (3/25/16) + +- Features: + + Inbox Zero: Beautiful new inbox zero artwork and a refined tray icon on Mac OS X! + + Reply from Alias: N1 now chooses the alias you were emailed at for a reply. + + Emoji: The emoji picker is now available in the bottom toolbar of the composer, and includes tabs and search! + + Download All: A new button allows you to quickly download all attachments in a message. + + Drop to Send: You can now drop files on the N1 app icon on Mac OS X to attach them to a new email! + + Default Signature: This version of N1 includes a default signature. You can remove it + by visiting `Preferences > Signatures` + +- Design: + + We've overhauled the multiple-selection UI to avoid toolbar issues. + + Thanks to nearly a dozen pull requests, many of the bundled themes have received visual polish + + Attachments have a refined design and better affordance for interaction. + + The "pop-out" button is always visible when composing in the main window. + + We've cleaned up the variables available to theme developers and created a starter kit for creating themes: + [https://github.com/nylas/N1-theme-starter](https://github.com/nylas/N1-theme-starter) + +- Fixes: + + N1 no longer incorrectly quotes forwarded message bodies. + + N1 API tokens are now stored in the system keychain for enhanced security. + + Filesystem errors (no disk space, wrong permissions, etc.) are presented when uploading or downloading attachments. + + Double-clicking image attachments now opens them. + + When you receive email to an alias, replies are sent from that alias by default. + + Search works more reliably, waits loner for results, and displays errors when results cannot be loaded. + + Read receipts are now visible in the narrow thread list. + + The undo/redo bar no longer appears when returning to your mailbox from Drafts. + + N1 no longer hangs while processing links in very large emails. + + The first input is auto-focused as you move through the Add Account flow. + + Failing API actions are retried more slowly, reducing CPU load when your machine is offline. + + The emoji keyboard now inserts emoji for a wider range of emoji names. + + You can now select a view mode from the View menu. + + Interface zoom is now an "advanced option", and has been removed from the preferences. + +- Developer: + + Composer Extensions using `finalizeSessionBeforeSending:` must now use `applyTransformsToDraft:` + + A new `InjectedComponentSet` allows you to add icons beside user's names in the composer. + + N1 is slowly transitioning to ES6 - 20% of package code was converted to ES6 this month! + +### 0.4.16 (3/18/16) + +This is a small patch release resolving the following issues: + +- The red "Account Error" bar no longer appears incorrectly in some scenarios. +- The "Sent Mail" label is no longer visible on threads (normally this label is hidden) +- Unread counts are now correct and match your mailbox. +- N1 now backs off when API requests fail temporarily (Gmail throttling, etc.) +- Contact sidebar API requests retry on 202s from our backend provider. + ### 0.4.14 (3/10/16) - Features: - + Overhauled Sidebar: The sidebar now shows more accurate contact information, - recent conversations with the selected participant, and more. + + Overhauled Sidebar: The sidebar now shows more accurate contact information, + recent conversations with the selected participant, and more. - + Themes: A brand new theme picker (in the Application Menu) allows you - to quickly try different themes, and we've bundled two great community themes - (Darkside and Taiga) into the app! An updated dark theme is coming soon. + + Themes: A brand new theme picker (in the Application Menu) allows you + to quickly try different themes, and we've bundled two great community themes + (Darkside and Taiga) into the app! An updated dark theme is coming soon. - Fixes: - + Warnings now appear in the main window if we are unable to connect to your email provider. - + The Send Later, Snooze and read receipts plugins now alert you if you are not using our hosted infrastructure. - + The Autoload Images and Snooze date input field is now locale-aware. - - + Composer: - + N1 cleans up drafts properly after sending if an autosave occurred immediately - before your message was sent. - + The emoji picker now matches emoji against more common words, like `:thumbsup`! - + Link tracking correctly modifies only `http://` and `https://` links - + When sending two responses to the same email, the second email no longer appears - to be sending in some scenarios. - - + Reading: - + Messages now show a loading indicator while they're being downloaded, and you can - retry if the download is interrupted. - + The "Sent" view now orders your messages by "last sent message". - + The "At 2:30PM, Mark wrote..." byline is now recognized as part of quoted text. - + Deleted messages are filtered from the conversation view, and you can show them by clicking "Show Deleted Messages." Threads in trash and spam are also excluded from the Starred and Gmail label views. - + "Archive" no longer removes the label you are currently viewing. - + Delete and backspace no longer follow Gmail's "remove from view" behavior. For Gmail's behavior, use the `y` shortcut. - - + Attachments: - + Downloads that fail are now retried properly when you interact with them. - + Changing an attachment name when saving it no longer clears the file extension. - - + Account Setup: - + The "Welcome to N1" screens now emphasize that it is cloud-based. - + You can use IP addresses as IMAP / SMTP and Exchange domains. - + You can now check "Require SSL" during IMAP / SMTP setup and N1 will not try plaintext authentication. - + N1 now displays better error messages for a wide variety of auth issues. - + Themes are no longer applied in the account setup window. + + Warnings now appear in the main window if we are unable to connect to your email provider. + + The Send Later, Snooze and read receipts plugins now alert you if you are not using our hosted infrastructure. + + The Autoload Images and Snooze date input field is now locale-aware. + + + Composer: + + N1 cleans up drafts properly after sending if an autosave occurred immediately + before your message was sent. + + The emoji picker now matches emoji against more common words, like `:thumbsup`! + + Link tracking correctly modifies only `http://` and `https://` links + + When sending two responses to the same email, the second email no longer appears + to be sending in some scenarios. + + + Reading: + + Messages now show a loading indicator while they're being downloaded, and you can + retry if the download is interrupted. + + The "Sent" view now orders your messages by "last sent message". + + The "At 2:30PM, Mark wrote..." byline is now recognized as part of quoted text. + + Deleted messages are filtered from the conversation view, and you can show them by + clicking "Show Deleted Messages." Threads in trash and spam are also excluded from + the Starred and Gmail label views. + + "Archive" no longer removes the label you are currently viewing. + + Delete and backspace no longer follow Gmail's "remove from view" behavior. + For Gmail's behavior, use the `y` shortcut. + + + Attachments: + + Downloads that fail are now retried properly when you interact with them. + + Changing an attachment name when saving it no longer clears the file extension. + + + Account Setup: + + The "Welcome to N1" screens now emphasize that it is cloud-based. + + You can use IP addresses as IMAP / SMTP and Exchange domains. + + You can now check "Require SSL" during IMAP / SMTP setup and N1 will not try plaintext authentication. + + N1 now displays better error messages for a wide variety of auth issues. + + Themes are no longer applied in the account setup window. - Temporary: - + N1 no longer syncs Drafts with Gmail, avoiding several critical issues our - platform team is working to resolve. (Drafts duplicating or appearing sent as you edit.) + + N1 no longer syncs Drafts with Gmail, avoiding several critical issues our + platform team is working to resolve. (Drafts duplicating or appearing sent as you edit.) - Cleanup: - + All sample plugins have been converted from CoffeeScript to ES2016. - + The `` component has been deprecated in favor of `` which is more flexible. - + Running specs from the application no longer resets your account configuration. - + N1 no longer adds `N1` and `apm` to `/usr/bin` + + All sample plugins have been converted from CoffeeScript to ES2016. + + The `` component has been deprecated in favor of `` which is more flexible. + + Running specs from the application no longer resets your account configuration. + + N1 no longer adds `N1` and `apm` to `/usr/bin` ### 0.4.10 (2/25/16) - Fixes: - + Prevent accounts from being wiped by rapid writes to config.cson - + Present nice error messages when sending results in 402 Message Rejected - + Fix a regression in adding / removing aliases - + Fix a regression in the Windows and Linux system tray icons - + Fix an issue with deltas throwing exceptions when messages are not present - + Stop checking for plugin auth once authentication succeeds. Makes "snooze" - animation more fluid and performant. - + Fix "Manage Templates" button in the pop-out composer. - + Right-align timestamps in the wide thread list. - + Fix print styling - + Add "Copy Debug Info to Clipboard", making it easier for users to collect - debugging information about messages. - + Update the email address used for reporting quoted text and rendering issues. + + Prevent accounts from being wiped by rapid writes to config.cson + + Present nice error messages when sending results in 402 Message Rejected + + Fix a regression in adding / removing aliases + + Fix a regression in the Windows and Linux system tray icons + + Fix an issue with deltas throwing exceptions when messages are not present + + Stop checking for plugin auth once authentication succeeds. Makes "snooze" + animation more fluid and performant. + + Fix "Manage Templates" button in the pop-out composer. + + Right-align timestamps in the wide thread list. + + Fix print styling + + Add "Copy Debug Info to Clipboard", making it easier for users to collect + debugging information about messages. + + Update the email address used for reporting quoted text and rendering issues. ### 0.4.9 (2/25/16) @@ -78,37 +131,37 @@ - Features: - + Snooze: Schedules threads to return to your inbox in a few hours, a few days, + + Snooze: Schedules threads to return to your inbox in a few hours, a few days, or whenever you choose. - + Swipe Actions: In the thread list, swipe to archive, trash or snooze your mail. - Swiping works with the Mac trackpad and with Windows / Linux touchscreen devices. + + Swipe Actions: In the thread list, swipe to archive, trash or snooze your mail. + Swiping works with the Mac trackpad and with Windows / Linux touchscreen devices. - + Send Later: Choose “Send later” in the composer and pick when a draft should be sent. - These scheduled drafts are sent via the sync engine, so you don’t need to be online. + + Send Later: Choose “Send later” in the composer and pick when a draft should be sent. + These scheduled drafts are sent via the sync engine, so you don’t need to be online. - + Read Receipts and Link Tracking: Get notified when recipients view your message - and click links with new read receipts and link tracking built in to the composer. + + Read Receipts and Link Tracking: Get notified when recipients view your message + and click links with new read receipts and link tracking built in to the composer. - + Emoji Picker: Type a `:` in the composer followed by the name of an emoji to - insert it into your draft. + + Emoji Picker: Type a `:` in the composer followed by the name of an emoji to + insert it into your draft. - Design: - + This release includes slimmer toolbars and new icons in the composer. + + This release includes slimmer toolbars and new icons in the composer. - + Font sizes throughout the app have been made slightly smaller to match platform + + Font sizes throughout the app have been made slightly smaller to match platform conventions. Like it the old way? Use the zoom feature in Workspace preferences! - + The N1 icon is now more of a "sea foam" green, which helps it stand out among - standard system icons, and features a square design on Windows. + + The N1 icon is now more of a "sea foam" green, which helps it stand out among + standard system icons, and features a square design on Windows. - + Tons and tons of additional polish. + + Tons and tons of additional polish. - Developer: - + A new `MetadataStore` and `model.pluginMetadata` concept allows plugins to associate - arbitrary data with threads and messages (like snooze times and link IDs). + + A new `MetadataStore` and `model.pluginMetadata` concept allows plugins to associate + arbitrary data with threads and messages (like snooze times and link IDs). - Many, many other bug fixes were incorporated into this release. Take a look at closed GitHub issues that made it into this release here: diff --git a/CONFIGURATION.md b/CONFIGURATION.md new file mode 100644 index 0000000000..d120735413 --- /dev/null +++ b/CONFIGURATION.md @@ -0,0 +1,97 @@ +# Configuration + +This document outlines configuration options which aren't exposed via N1's +preferences interface, but may be useful. + +## Running Against Open Source Sync Engine + +N1 needs to fetch mail from a running instance of the [Nylas Sync +Engine](https://github.com/nylas/sync-engine). The Sync Engine is what +abstracts away IMAP, POP, and SMTP to serve your email on any provider +through a modern, RESTful API. + +By default the N1 source points to our hosted version of the sync-engine; +however, the Sync Engine is open source and you can run it yourself. + +1. Install the Nylas Sync Engine in a Vagrant virtual machine by following the + [installation and setup](https://github.com/nylas/sync-engine#installation-and-setup) + instructions. + +2. Once you've installed the sync engine, add accounts by running the inbox-auth + script. For Gmail accounts, the syntax is simple: `bin/inbox-auth you@gmail.com` + +3. Start the sync engine by running `bin/inbox-start` and the API via `bin/inbox-api`. + +4. After you've linked accounts to the Sync Engine, open or create a file at + `~/.nylas/config.cson`. This is the config file that N1 reads at launch. + + Replace `env: "production"` with `env: "local"` at the top level of the config. + This tells N1 to look at `localhost:5555` for the sync engine. If you've deployed + the sync engine elsewhere, add the following block beneath `env: "local"`: + + ``` + syncEngine: + APIRoot: "http://mysite.com:5555" + ``` + + NOTE: If you are using a custom network layout and your sync engine is not on + `localhost:5555`, use `env: custom` instead along with your alternate IP for the + API Root, for example `192.168.1.00:5555` + + ``` + env: "custom" + syncEngine: + APIRoot: "http://192.168.1.100:5555" + ``` + + Copy the JSON array of accounts returned from the Sync Engine's `/accounts` + endpoint (ex. `http://localhost:5555/accounts`) into the config file at the + path `*.nylas.accounts`. + + N1 will look for access tokens for these accounts under `*.nylas.accountTokens`, + but the open source version of the sync engine does not provide access tokens. + When you make requests to the open source API, you provide an account + ID in the HTTP Basic Auth username field instead of an account token. + + For each account you've created, add an entry to `*.nylas.accountTokens` + with the account ID as both the key and value. + + The final `config.cson` file should look something like this: + + "*": + env: "local" + nylas: + accounts: [ + { + server_id: "{ACCOUNT_ID_1}" + object: "account" + account_id: "{ACCOUNT_ID_1}" + name: "{YOUR NAME}" + provider: "{PROVIDER_NAME}" + email_address: "{YOUR_EMAIL_ADDRESS}" + organization_unit: "{folder or label}" + id: "{ACCOUNT_ID_1}" + } + { + server_id: "{ACCOUNT_ID_2}" + object: "account" + account_id: "{ACCOUNT_ID_2}" + name: "{YOUR_NAME}" + provider: "{PROVIDER_NAME}" + email_address: "{YOUR_EMAIL_ADDRESS}" + organization_unit: "{folder or label}" + id: "{ACCOUNT_ID_2}" + } + ] + accountTokens: + "{ACCOUNT_ID_1}": "{ACCOUNT_ID_1}" + "{ACCOUNT_ID_2}": "{ACCOUNT_ID_2}" + +Note: `{ACCOUNT_ID_1}` refers to the database ID of the `Account` object +you create when setting up the Sync Engine. The JSON above should match +fairly closely with the Sync Engine `Account` object. + + +## Other Config Options + +- `core.workspace.interfaceZoom`: If you'd like the N1 interface to be smaller or larger, this option allows you to scale the UI globally. (Default: 1) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4e450c8390..174ace0a42 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,14 +28,14 @@ Linux users on Debian 8 and Ubuntu 15.04 onward must also install libgcrypt11, w ### Running N1 - ./N1.sh --dev + ./N1.sh --dev --enable-logging Once the app boots, you'll be prompted to enter your email credentials. ### Testing N1 - ./N1.sh --test + ./N1.sh --test --enable-logging This will run the full suite of automated unit tests. We use [Jasmine 1.3](http://jasmine.github.io/1.3/introduction.html). @@ -82,88 +82,4 @@ We do have a few heuristics: # Running Against Open Source Sync Engine -N1 needs to fetch mail from a running instance of the [Nylas Sync -Engine](https://github.com/nylas/sync-engine). The Sync Engine is what -abstracts away IMAP, POP, and SMTP to serve your email on any provider -through a modern, RESTful API. - -By default the N1 source points to our hosted version of the sync-engine; -however, the Sync Engine is open source and you can run it yourself. - -1. Install the Nylas Sync Engine in a Vagrant virtual machine by following the - [installation and setup](https://github.com/nylas/sync-engine#installation-and-setup) - instructions. - -2. Once you've installed the sync engine, add accounts by running the inbox-auth - script. For Gmail accounts, the syntax is simple: `bin/inbox-auth you@gmail.com` - -3. Start the sync engine by running `bin/inbox-start` and the API via `bin/inbox-api`. - -4. After you've linked accounts to the Sync Engine, open or create a file at - `~/.nylas/config.cson`. This is the config file that N1 reads at launch. - - Replace `env: "production"` with `env: "local"` at the top level of the config. - This tells N1 to look at `localhost:5555` for the sync engine. If you've deployed - the sync engine elsewhere, add the following block beneath `env: "local"`: - - ``` - syncEngine: - APIRoot: "http://mysite.com:5555" - ``` - - NOTE: If you are using a custom network layout and your sync engine is not on - `localhost:5555`, use `env: custom` instead along with your alternate IP for the - API Root, for example `192.168.1.00:5555` - - ``` - env: "custom" - syncEngine: - APIRoot: "http://192.168.1.100:5555" - ``` - - Copy the JSON array of accounts returned from the Sync Engine's `/accounts` - endpoint (ex. `http://localhost:5555/accounts`) into the config file at the - path `*.nylas.accounts`. - - N1 will look for access tokens for these accounts under `*.nylas.accountTokens`, - but the open source version of the sync engine does not provide access tokens. - When you make requests to the open source API, you provide an account - ID in the HTTP Basic Auth username field instead of an account token. - - For each account you've created, add an entry to `*.nylas.accountTokens` - with the account ID as both the key and value. - - The final `config.cson` file should look something like this: - - "*": - env: "local" - nylas: - accounts: [ - { - server_id: "{ACCOUNT_ID_1}" - object: "account" - account_id: "{ACCOUNT_ID_1}" - name: "{YOUR NAME}" - provider: "{PROVIDER_NAME}" - email_address: "{YOUR_EMAIL_ADDRESS}" - organization_unit: "{folder or label}" - id: "{ACCOUNT_ID_1}" - } - { - server_id: "{ACCOUNT_ID_2}" - object: "account" - account_id: "{ACCOUNT_ID_2}" - name: "{YOUR_NAME}" - provider: "{PROVIDER_NAME}" - email_address: "{YOUR_EMAIL_ADDRESS}" - organization_unit: "{folder or label}" - id: "{ACCOUNT_ID_2}" - } - ] - accountTokens: - "{ACCOUNT_ID_1}": "{ACCOUNT_ID_1}" - "{ACCOUNT_ID_2}": "{ACCOUNT_ID_2}" - -Note: `{ACCOUNT_ID_1}` refers to the database ID of the `Account` object -you create when setting up the Sync Engine. The JSON above should match -fairly closely with the Sync Engine `Account` object. +See [Configuration](https://github.com/nylas/N1/blob/master/CONFIGURATION.md) diff --git a/ISSUE_TEMPLATE.md b/ISSUE_TEMPLATE.md index 4fcc0ecc2c..a3b357d1e0 100644 --- a/ISSUE_TEMPLATE.md +++ b/ISSUE_TEMPLATE.md @@ -1,25 +1,37 @@ + -- [ ] Are there any related issues? Try searching for both open and closed issues here: https://github.com/nylas/N1/issues?q=is%3Aissue. Keep in mind that email features are often described differently on different platforms. (Conversations == threads, shortcuts == hotkeys, etc.) +##### Are there any related issues? + +... -- [ ] What operating system are you using? +##### What operating system are you using? +... -- [ ] What version of N1 are you using? +##### What version of N1 are you using? +... +-- **Bug?** -- [ ] Do you have any third-party plugins installed? +##### Do you have any third-party plugins installed? +... -- [ ] Is the issue related to a specific email provider (Gmail, Exchange, etc.)? +##### Is the issue related to a specific email provider (Gmail, Exchange, etc.)? +... -- [ ] Is the issue reproducible with a particular attachment, message, signature, etc? - Try to provide an example as a file attachment or a screenshot. +##### Is the issue reproducible with a particular attachment, message, signature, etc? + +... +-- **Feature Request?** -- [ ] Does this feature exist in another mail client or tool you use? +##### Does this feature exist in another mail client or tool you use? +... diff --git a/README.md b/README.md index eb73d20c67..4742aa39e5 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ [![Slack Invite Button](http://slack-invite.nylas.com/badge.svg)](http://slack-invite.nylas.com) [![GitHub issues On Deck](https://badge.waffle.io/nylas/N1.png?label=on deck&title=On Deck)](https://waffle.io/nylas/N1) +#### Want help build the future of email? [Nylas is hiring](https://jobs.lever.co/nylas)! + # Download N1 You can download compiled versions of N1 for Windows, Mac OS X, and Linux (.deb) from [https://nylas.com/N1](https://nylas.com/N1). You can also build and run N1 on Fedora. A Fedora distribution is coming soon! @@ -22,9 +24,13 @@ guide](https://github.com/nylas/N1/blob/master/CONTRIBUTING.md). # Plugin List We're working on building a plugin index that makes it super easy to add them to N1. For now, check out the list below! (Feel free to submit a PR if you build a plugin and want it featured here.) -##### Themes +#### Bundled Themes - [Dark](https://github.com/nylas/N1/tree/master/internal_packages/ui-dark) -- [Taiga](http://noahbuscher.github.io/N1-Taiga/) — Mailbox-inspired light theme +- [Darkside](https://github.com/nylas/N1/tree/master/internal_packages/ui-darkside) (designed by [Jamie Wilson](https://github.com/jamiewilson)) +- [Taiga](https://github.com/nylas/N1/tree/master/internal_packages/ui-taiga) (designed by [Noah Buscher](https://github.com/noahbuscher)) + +#### Community Themes +[Create your own theme!](http://github.com/nylas/N1-theme-starter) - [Predawn](https://github.com/adambmedia/N1-Predawn) - [ElementaryOS](https://github.com/edipox/elementary-nylas) - [Ubuntu](https://github.com/ahmedlhanafy/Ubuntu-Ui-Theme-for-Nylas-N1) @@ -32,43 +38,35 @@ We're working on building a plugin index that makes it super easy to add them to - [Solarized Dark](https://github.com/NSHenry/N1-Solarized-Dark) - [Berend](https://github.com/Frique/N1-Berend) - [LevelUp](https://github.com/stolinski/level-up-nylas-n1-theme) -- [Darkside](http://jamiewilson.io/darkside/) - [Sunrise](https://github.com/jackiehluo/n1-sunrise) - [Less Is More](https://github.com/P0WW0W/less-is-more/) - [ToogaBooga](https://github.com/brycedorn/N1-ToogaBooga) - [Material](https://github.com/jackiehluo/n1-material) -[Create your own theme!](http://github.com/nylas/N1-theme-starter) - -##### Composer +#### Bundled Plugins +Great starting points for creating your own plugins! - [Translate](https://github.com/nylas/N1/tree/master/internal_packages/composer-translate) — Works with 10 languages - [Quick Schedule](https://github.com/nylas/N1/tree/master/internal_packages/quick-schedule) — Show your availability to schedule a meeting with someone - [Quick Replies](https://github.com/nylas/N1/tree/master/internal_packages/composer-templates) — Send emails faster with templates - [Send Later](https://github.com/nylas/N1/tree/master/internal_packages/send-later) — Schedule your emails to be sent at a later time - [Open Tracking](https://github.com/nylas/N1/tree/master/internal_packages/open-tracking) — See if your emails have been read - [Link Tracking](https://github.com/nylas/N1/tree/master/internal_packages/link-tracking) — See if your links have been clicked -- [Emoji Keyboard](https://github.com/nylas/N1/tree/master/internal_packages/composer-emojis) — Insert emojis by typing a colon (:) followed by the name of an emoji symbol -- [Jiffy](http://noahbuscher.github.io/N1-Jiffy/) — Insert animated GIFs -- In Development: [Cypher](https://github.com/mbilker/cypher) (PGP Encryption) - -##### Sidebar +- [Emoji Keyboard](https://github.com/nylas/N1/tree/master/internal_packages/composer-emoji) — Insert emoji by typing a colon (:) followed by the name of an emoji symbol - [GitHub Sidebar Info](https://github.com/nylas/N1/tree/master/internal_packages/github-contact-card) -- [Weather](https://github.com/jackiehluo/n1-weather) -- [Todoist](https://github.com/anopensourceguy/TodoistN1) - -##### Navbar - [View on GitHub](https://github.com/nylas/N1/tree/master/internal_packages/message-view-on-github) +- [Personal Level Indicators](https://github.com/nylas/N1/tree/master/internal_packages/personal-level-indicators) +- [Phishing Detection](https://github.com/nylas/N1/tree/master/internal_packages/phishing-detection) -##### Threadlist -- [Personal-level Indicators](https://github.com/nylas/N1/tree/master/internal_packages/personal-level-indicators) +#### Community Plugins +- [Jiffy](http://noahbuscher.github.io/N1-Jiffy/) — Insert animated GIFs +- [Weather](https://github.com/jackiehluo/n1-weather) +- [Todoist](https://github.com/anopensourceguy/TodoistN1) - [Unsubscribe](https://github.com/colinking/n1-unsubscribe) - -##### Messages -- [Phishing Detection](https://github.com/nylas/N1/tree/master/internal_packages/phishing-detection) - [Squirt Speed Reader](https://github.com/HarleyKwyn/squirt-reader-N1-plugin/) +- In Development: [Cypher](https://github.com/mbilker/cypher) (PGP Encryption) # Running Locally -By default the N1 source points to our hosted version of the Nylas Sync Engine; however, the Sync Engine is open source and you can [run it yourself](https://github.com/nylas/N1/blob/master/CONTRIBUTING.md#running-against-open-source-sync-engine). +By default the N1 source points to our hosted version of the Nylas Sync Engine; however, the Sync Engine is open source and you can [run it yourself](https://github.com/nylas/N1/blob/master/CONFIGURATION.md). # Feature Requests / Plugin Ideas diff --git a/build/config/eslint.json b/build/config/eslint.json index 54f00c6717..c2d4d40ab2 100644 --- a/build/config/eslint.json +++ b/build/config/eslint.json @@ -3,7 +3,11 @@ "globals": { "NylasEnv": false, "$n": false, - "waitsForPromise": false + "waitsForPromise": false, + "advanceClock": false, + "TEST_ACCOUNT_ID": false, + "TEST_ACCOUNT_NAME": false, + "TEST_ACCOUNT_ALIAS_EMAIL": false }, "env": { "browser": true, @@ -13,6 +17,7 @@ "rules": { "react/prop-types": [2, {"ignore": ["children"]}], "react/no-multi-comp": [0], + "react/sort-comp": [2], "eqeqeq": [2, "smart"], "id-length": [0], "object-curly-spacing": [0], diff --git a/build/resources/asar-ordering-hint.txt b/build/resources/asar-ordering-hint.txt index bad405f370..1ed24ed06c 100644 --- a/build/resources/asar-ordering-hint.txt +++ b/build/resources/asar-ordering-hint.txt @@ -1406,7 +1406,7 @@ 4208627: node_modules/request/lib/cookies.js 4209596: node_modules/less-cache/node_modules/less/node_modules/request/node_modules/tough-cookie/lib/cookie.js 4247150: node_modules/less-cache/node_modules/less/node_modules/request/node_modules/tough-cookie/lib/pubsuffix.js -6156359: src/flux/stores/unread-badge-store.js +6156359: src/flux/stores/badge-store.js 6165032: src/flux/stores/file-download-store.js 6196816: node_modules/request-progress/package.json 6202896: node_modules/request-progress/index.js @@ -1828,7 +1828,7 @@ 6063542: src/flux/stores/file-upload-store.js 1676695: node_modules/mkdirp/package.json 1678098: node_modules/mkdirp/index.js -6156359: src/flux/stores/unread-badge-store.js +6156359: src/flux/stores/badge-store.js 6165032: src/flux/stores/file-download-store.js 6196816: node_modules/request-progress/package.json 6202896: node_modules/request-progress/index.js @@ -3709,7 +3709,7 @@ 6063542: src/flux/stores/file-upload-store.js 1676695: node_modules/mkdirp/package.json 1678098: node_modules/mkdirp/index.js -6156359: src/flux/stores/unread-badge-store.js +6156359: src/flux/stores/badge-store.js 6165032: src/flux/stores/file-download-store.js 6196816: node_modules/request-progress/package.json 6202896: node_modules/request-progress/index.js @@ -5201,7 +5201,7 @@ 6063542: src/flux/stores/file-upload-store.js 1676695: node_modules/mkdirp/package.json 1678098: node_modules/mkdirp/index.js -6156359: src/flux/stores/unread-badge-store.js +6156359: src/flux/stores/badge-store.js 6165032: src/flux/stores/file-download-store.js 6196816: node_modules/request-progress/package.json 6202896: node_modules/request-progress/index.js diff --git a/build/resources/linux/debian/control.in b/build/resources/linux/debian/control.in index fb623507a1..18ba612e60 100644 --- a/build/resources/linux/debian/control.in +++ b/build/resources/linux/debian/control.in @@ -1,7 +1,6 @@ Package: <%= name %> Version: <%= version %> -Depends: git, gconf2, gconf-service, libgtk2.0-0, libudev0 | libudev1, libgcrypt11 | libgcrypt20, libnotify4, libxtst6, libnss3, python, gvfs-bin, xdg-utils -Suggests: libgnome-keyring0, gir1.2-gnomekeyring-1.0 +Depends: libgnome-keyring0, gir1.2-gnomekeyring-1.0, git, gconf2, gconf-service, libgtk2.0-0, libudev0 | libudev1, libgcrypt11 | libgcrypt20, libnotify4, libxtst6, libnss3, python, gvfs-bin, xdg-utils Section: <%= section %> Priority: optional Architecture: <%= arch %> diff --git a/build/resources/linux/redhat/nylas.spec.in b/build/resources/linux/redhat/nylas.spec.in index d4a228063c..3302012d57 100644 --- a/build/resources/linux/redhat/nylas.spec.in +++ b/build/resources/linux/redhat/nylas.spec.in @@ -2,10 +2,12 @@ Name: <%= name %> Version: <%= version %> Release: 0.1%{?dist} Summary: <%= description %> -License: Proprietary +License: GPLv3 URL: https://nylas.com/N1 AutoReqProv: no # Avoid libchromiumcontent.so missing dependency +requires: libgnome-keyring0, gir1.2-gnomekeyring-1.0 + %description <%= description %> diff --git a/build/resources/mac/nylas-Info.plist b/build/resources/mac/nylas-Info.plist index dcaa24f306..983fe4c7c9 100644 --- a/build/resources/mac/nylas-Info.plist +++ b/build/resources/mac/nylas-Info.plist @@ -61,6 +61,18 @@ CFBundleDocumentTypes + + LSHandlerRank + Alternate + CFBundleTypeRole + Viewer + CFBundleTypeName + File + CFBundleTypeExtensions + + * + + diff --git a/build/resources/nylas b/build/resources/nylas index db3ffe9037..cd182d38de 160000 --- a/build/resources/nylas +++ b/build/resources/nylas @@ -1 +1 @@ -Subproject commit db3ffe9037251956ec88a8ad424d2cf82e1df788 +Subproject commit cd182d38de2611b2056e12433a6b3289e7e83d10 diff --git a/docs/ComposerExtensions.md b/docs/ComposerExtensions.md index 27885cce17..7ef6d625e6 100644 --- a/docs/ComposerExtensions.md +++ b/docs/ComposerExtensions.md @@ -35,12 +35,12 @@ class ProductsExtension extends ComposerExtension return ["with the word '#{word}'?"] return [] - @finalizeSessionBeforeSending: ({session}) -> - draft = session.draft() - if @warningsForSending({draft}) - bodyWithWarning = draft.body += "
This email \ - contains competitor's product names \ - or trademarks used in context." - return session.changes.add(body: bodyWithWarning) - else return Promise.resolve() + @applyTransformsToDraft: ({draft}) -> + if @warningsForSending({draft}) + updated = draft.clone() + updated.body += "
This email \ + contains competitor's product names \ + or trademarks used in context." + return updated + return draft ``` diff --git a/internal_packages/account-sidebar/lib/components/account-switcher.cjsx b/internal_packages/account-sidebar/lib/components/account-switcher.cjsx index 8d2e4f100d..b574b11736 100644 --- a/internal_packages/account-sidebar/lib/components/account-switcher.cjsx +++ b/internal_packages/account-sidebar/lib/components/account-switcher.cjsx @@ -20,12 +20,17 @@ class AccountSwitcher extends React.Component ) template = template.concat [ {type: 'separator'} + {label: 'Add Account...', click: @_onAddAccount} {label: 'Manage Accounts...', click: @_onManageAccounts} ] return template # Handlers + _onAddAccount: => + ipc = require('electron').ipcRenderer + ipc.send('command', 'application:add-account') + _onManageAccounts: => Actions.switchPreferencesTab('Accounts') Actions.openPreferences() diff --git a/internal_packages/attachments/lib/attachment-component.cjsx b/internal_packages/attachments/lib/attachment-component.cjsx deleted file mode 100644 index 76781dc4d8..0000000000 --- a/internal_packages/attachments/lib/attachment-component.cjsx +++ /dev/null @@ -1,97 +0,0 @@ -_ = require 'underscore' -path = require 'path' -fs = require 'fs' -React = require 'react' -{RetinaImg, Flexbox} = require 'nylas-component-kit' -{Actions, Utils, FileDownloadStore} = require 'nylas-exports' - -class AttachmentComponent extends React.Component - @displayName: 'AttachmentComponent' - - @propTypes: - file: React.PropTypes.object.isRequired - download: React.PropTypes.object - removable: React.PropTypes.bool - targetPath: React.PropTypes.string - messageClientId: React.PropTypes.string - - constructor: (@props) -> - @state = progressPercent: 0 - - render: => -
- - - - - - - - {@props.file.displayName()} - {@_renderFileActions()} - -
- - _renderFileActions: => - if @props.removable -
- {@_renderRemoveIcon()} -
- else if @_isDownloading() and @_canAbortDownload() -
- {@_renderRemoveIcon()} -
- else -
- {@_renderDownloadButton()} -
- - _downloadProgressStyle: => - width: "#{@props.download?.percent ? 0}%" - - _canAbortDownload: -> true - - _canClickToView: => not @props.removable - - _isDownloading: => @props.download?.state is "downloading" - - _renderRemoveIcon: -> - - - _renderDownloadButton: -> - - - _onDragStart: (event) => - filePath = FileDownloadStore.pathForFile(@props.file) - if fs.existsSync(filePath) - # Note: From trial and error, it appears that the second param /MUST/ be the - # same as the last component of the filePath URL, or the download fails. - DownloadURL = "#{@props.file.contentType}:#{path.basename(filePath)}:file://#{filePath}" - event.dataTransfer.setData("DownloadURL", DownloadURL) - event.dataTransfer.setData("text/nylas-file-url", DownloadURL) - else - event.preventDefault() - return - - _onClickView: => - Actions.fetchAndOpenFile(@props.file) if @_canClickToView() - - _onClickRemove: (event) => - Actions.removeFile - file: @props.file - messageClientId: @props.messageClientId - event.stopPropagation() # Prevent 'onClickView' - - _onClickDownload: (event) => - Actions.fetchAndSaveFile(@props.file) - event.stopPropagation() # Prevent 'onClickView' - - _onClickAbort: (event) => - Actions.abortFetchFile(@props.file) - event.stopPropagation() # Prevent 'onClickView' - - -module.exports = AttachmentComponent diff --git a/internal_packages/attachments/lib/attachment-component.jsx b/internal_packages/attachments/lib/attachment-component.jsx new file mode 100644 index 0000000000..164069ea8e --- /dev/null +++ b/internal_packages/attachments/lib/attachment-component.jsx @@ -0,0 +1,154 @@ +import fs from 'fs' +import path from 'path' +import React, {Component, PropTypes} from 'react' +import {RetinaImg, Flexbox} from 'nylas-component-kit' +import {Actions, FileDownloadStore} from 'nylas-exports' + + +class AttachmentComponent extends Component { + static displayName = 'AttachmentComponent'; + + static propTypes = { + file: PropTypes.object.isRequired, + download: PropTypes.object, + removable: PropTypes.bool, + targetPath: PropTypes.string, + messageClientId: PropTypes.string, + }; + + constructor() { + super() + this.state = {progressPercent: 0} + } + + static containerRequired = false; + + _isDownloading() { + const {download} = this.props + const state = download ? download.state : null + return state === 'downloading' + } + + _canClickToView() { + return !this.props.removable + } + + _canAbortDownload() { + return true + } + + _downloadProgressStyle() { + const {download} = this.props + const percent = download ? download.percent || 0 : 0; + return { + width: `${percent}%`, + } + } + + _onDragStart = (event) => { + const {file} = this.props + const filePath = FileDownloadStore.pathForFile(file) + if (fs.existsSync(filePath)) { + // Note: From trial and error, it appears that the second param /MUST/ be the + // same as the last component of the filePath URL, or the download fails. + const DownloadURL = `${file.contentType}:${path.basename(filePath)}:file://${filePath}` + event.dataTransfer.setData("DownloadURL", DownloadURL) + event.dataTransfer.setData("text/nylas-file-url", DownloadURL) + } else { + event.preventDefault() + } + }; + + _onClickView = () => { + if (this._canClickToView()) { + Actions.fetchAndOpenFile(this.props.file) + } + }; + + _onClickRemove = (event) => { + Actions.removeFile({ + file: this.props.file, + messageClientId: this.props.messageClientId, + }) + event.stopPropagation() // Prevent 'onClickView' + }; + + _onClickDownload = (event) => { + Actions.fetchAndSaveFile(this.props.file) + event.stopPropagation() // Prevent 'onClickView' + }; + + _onClickAbort = (event) => { + Actions.abortFetchFile(this.props.file) + event.stopPropagation() // Prevent 'onClickView' + }; + + _renderRemoveIcon() { + return ( + + ) + } + + _renderDownloadButton() { + return ( + + ) + } + + _renderFileActionIcon() { + if (this.props.removable) { + return ( +
+ {this._renderRemoveIcon()} +
+ ) + } else if (this._isDownloading() && this._canAbortDownload()) { + return ( +
+ {this._renderRemoveIcon()} +
+ ) + } + return ( +
+ {this._renderDownloadButton()} +
+ ) + } + + render() { + const {file, download} = this.props; + const downloadState = download ? download.state || "" : ""; + + return ( +
+ + + + + + +
+ + {file.displayName()} + {file.displayFileSize()} +
+ {this._renderFileActionIcon()} +
+
+ ) + } +} + +export default AttachmentComponent diff --git a/internal_packages/attachments/lib/image-attachment-component.cjsx b/internal_packages/attachments/lib/image-attachment-component.cjsx deleted file mode 100644 index 248cf92a6d..0000000000 --- a/internal_packages/attachments/lib/image-attachment-component.cjsx +++ /dev/null @@ -1,44 +0,0 @@ -path = require 'path' -React = require 'react' -AttachmentComponent = require './attachment-component' -{RetinaImg, Spinner, DraggableImg} = require 'nylas-component-kit' - -class ImageAttachmentComponent extends AttachmentComponent - @displayName: 'ImageAttachmentComponent' - - render: => -
- - - - - - {@_renderFileActions()} - -
-
-
{@props.file.displayName()}
-
- {@_imgOrLoader()} -
-
- - _canAbortDownload: -> false - - _renderRemoveIcon: -> - - - _renderDownloadButton: -> - - - _imgOrLoader: -> - if @props.download and @props.download.percent <= 5 -
- -
- else if @props.download and @props.download.percent < 100 - - else - - -module.exports = ImageAttachmentComponent diff --git a/internal_packages/attachments/lib/image-attachment-component.jsx b/internal_packages/attachments/lib/image-attachment-component.jsx new file mode 100644 index 0000000000..21eb594215 --- /dev/null +++ b/internal_packages/attachments/lib/image-attachment-component.jsx @@ -0,0 +1,77 @@ +import React, {PropTypes} from 'react' +import {RetinaImg, Spinner, DraggableImg} from 'nylas-component-kit' +import AttachmentComponent from './attachment-component' + + +class ImageAttachmentComponent extends AttachmentComponent { + static displayName = 'ImageAttachmentComponent'; + + static propTypes = { + file: PropTypes.object.isRequired, + download: PropTypes.object, + targetPath: PropTypes.string, + }; + + static containerRequired = false; + + _canAbortDownload() { + return false + } + + _imgOrLoader() { + const {download, targetPath} = this.props + if (download && download.percent <= 5) { + return ( +
+ +
+ ) + } else if (download && download.percent < 100) { + return ( + + ) + } + return + } + + _renderRemoveIcon() { + return ( + + ) + } + + _renderDownloadButton() { + return ( + + ) + } + + render() { + const {download, file} = this.props + const state = download ? download.state || "" : "" + const displayName = file.displayName() + return ( +
+ + + + + {this._renderFileActionIcon()} +
+
+
{displayName}
+
+ {this._imgOrLoader()} +
+
+ ) + } +} + +export default ImageAttachmentComponent diff --git a/internal_packages/attachments/lib/main.cjsx b/internal_packages/attachments/lib/main.cjsx deleted file mode 100644 index cf736ad11f..0000000000 --- a/internal_packages/attachments/lib/main.cjsx +++ /dev/null @@ -1,18 +0,0 @@ -{ComponentRegistry} = require 'nylas-exports' - -AttachmentComponent = require "./attachment-component" -ImageAttachmentComponent = require "./image-attachment-component" - -module.exports = - activate: (@state={}) -> - ComponentRegistry.register AttachmentComponent, - role: 'Attachment' - - ComponentRegistry.register ImageAttachmentComponent, - role: 'Attachment:Image' - - deactivate: -> - ComponentRegistry.unregister(AttachmentComponent) - ComponentRegistry.unregister(ImageAttachmentComponent) - - serialize: -> @state diff --git a/internal_packages/attachments/lib/main.es6 b/internal_packages/attachments/lib/main.es6 new file mode 100644 index 0000000000..94635cc29d --- /dev/null +++ b/internal_packages/attachments/lib/main.es6 @@ -0,0 +1,14 @@ +import {ComponentRegistry} from 'nylas-exports' +import AttachmentComponent from "./attachment-component" +import ImageAttachmentComponent from "./image-attachment-component" + + +export function activate() { + ComponentRegistry.register(AttachmentComponent, {role: 'Attachment'}) + ComponentRegistry.register(ImageAttachmentComponent, {role: 'Attachment:Image'}) +} + +export function deactivate() { + ComponentRegistry.unregister(AttachmentComponent) + ComponentRegistry.unregister(ImageAttachmentComponent) +} diff --git a/internal_packages/attachments/stylesheets/attachments.less b/internal_packages/attachments/stylesheets/attachments.less index 4a881d8104..a1f2d81db8 100644 --- a/internal_packages/attachments/stylesheets/attachments.less +++ b/internal_packages/attachments/stylesheets/attachments.less @@ -12,18 +12,65 @@ -webkit-user-drag: element; .inner { - border-radius: 4px; + border-radius: 2px; color: @text-color; background: @background-off-primary; - box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.09); - padding: 0 @spacing-standard; - height:46px; + height: 37px; } - &:hover { - cursor: default; - .inner { - box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.18); + .file-info-wrap { + display: flex; + align-items: center; + padding-left: @spacing-half + 1; + width: 100%; + height: 100%; + border: solid 1px rgba(0, 0, 0, 0.09); + border-right: none; + + .file-icon { + margin-right: 10px; + flex-shrink:0; + } + .file-name { + font-weight: @font-weight-medium; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 235px; + } + .file-size { + @file-size-color: #b8b8b8; + + margin-left: auto; + margin-right: @spacing-three-quarters; + color: @file-size-color; + } + } + + .file-action-icon { + @file-icon-color: #c7c7c7; + + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + margin-left: auto; + padding-top: 1px; + height: 100%; + width: 37px; + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.09); + + img { + background-color: @file-icon-color; + } + + &:hover img { + background-color: darken(@file-icon-color, 20%); + } + &:active { + // box-shadow: inset 0 0 0 1px #27496d, inset 0 5px 30px #193047; + background-color: darken(@btn-default-bg-color, 5%); } } @@ -61,7 +108,7 @@ width: 0; // Changed by React z-index: 3; display: block; - background: @progress-bar-fill; + background: @blue-light; border-bottom-left-radius:4px; transition: width .3s linear; @@ -74,27 +121,12 @@ width: 100%; z-index: 2; display: block; - background: @progress-bar-background; + background: @background-color-pending; border-bottom-left-radius:4px; border-bottom-right-radius:4px; } } - .file-icon { - margin-right: 10px; - flex-shrink:0; - } - .file-name { - font-weight: @font-weight-medium; - flex: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - .file-action-icon { - margin-left: 10px; - flex-shrink:0; - } } .file-wrap.file-image-wrap { @@ -132,6 +164,10 @@ top: -8px; width: 26px; border-radius: 0 0 0 3px; + box-shadow: none; + img { + background: none; + } } .file-preview { @@ -170,7 +206,7 @@ z-index: 1; max-width: 100%; background: url(../static/images/attachments/transparency-background.png) top left repeat; - background-size:8px; + background-size: 8px; } } diff --git a/internal_packages/category-picker/lib/category-picker-popover.jsx b/internal_packages/category-picker/lib/category-picker-popover.jsx index c95902a81f..4de340f1e9 100644 --- a/internal_packages/category-picker/lib/category-picker-popover.jsx +++ b/internal_packages/category-picker/lib/category-picker-popover.jsx @@ -206,6 +206,10 @@ export default class CategoryPickerPopover extends Component { }) Actions.queueTask(applyTask) } + if (account.usesFolders()) { + // In case we are drilled down into a message + Actions.popSheet() + } Actions.closePopover() }; diff --git a/internal_packages/category-picker/lib/category-picker.cjsx b/internal_packages/category-picker/lib/category-picker.cjsx index b91346bc8b..d15807e42a 100644 --- a/internal_packages/category-picker/lib/category-picker.cjsx +++ b/internal_packages/category-picker/lib/category-picker.cjsx @@ -18,38 +18,30 @@ class CategoryPicker extends React.Component @containerRequired: false @propTypes: - thread: React.PropTypes.object items: React.PropTypes.array @contextTypes: sheetDepth: React.PropTypes.number constructor: (@props) -> - @_threads = @_getThreads(@props) - @_account = AccountStore.accountForItems(@_threads) + @_account = AccountStore.accountForItems(@props.items) # If the threads we're picking categories for change, (like when they # get their categories updated), we expect our parents to pass us new # props. We don't listen to the DatabaseStore ourselves. componentWillReceiveProps: (nextProps) -> - @_threads = @_getThreads(nextProps) - @_account = AccountStore.accountForItems(@_threads) - - _getThreads: (props = @props) => - if props.items then return (props.items ? []) - else if props.thread then return [props.thread] - else return [] + @_account = AccountStore.accountForItems(nextProps.items) _keymapHandlers: -> "application:change-category": @_onOpenCategoryPopover _onOpenCategoryPopover: => - return unless @_threads.length > 0 + return unless @props.items.length > 0 return unless @context.sheetDepth is WorkspaceStore.sheetStack().length - 1 buttonRect = React.findDOMNode(@refs.button).getBoundingClientRect() Actions.openPopover( , {originRect: buttonRect, direction: 'down'} ) diff --git a/internal_packages/category-picker/lib/main.cjsx b/internal_packages/category-picker/lib/main.cjsx index ea4a37b9d1..bf7f186704 100644 --- a/internal_packages/category-picker/lib/main.cjsx +++ b/internal_packages/category-picker/lib/main.cjsx @@ -6,7 +6,7 @@ CategoryPicker = require "./category-picker" module.exports = activate: (@state={}) -> ComponentRegistry.register CategoryPicker, - roles: ['thread:BulkAction', 'message:Toolbar'] + role: 'ThreadActionsToolbarButton' deactivate: -> ComponentRegistry.unregister(CategoryPicker) diff --git a/internal_packages/composer-emojis/assets/icon.png b/internal_packages/composer-emoji/assets/icon.png similarity index 100% rename from internal_packages/composer-emojis/assets/icon.png rename to internal_packages/composer-emoji/assets/icon.png diff --git a/internal_packages/composer-emoji/lib/categorized-emoji.js b/internal_packages/composer-emoji/lib/categorized-emoji.js new file mode 100644 index 0000000000..0afdbda532 --- /dev/null +++ b/internal_packages/composer-emoji/lib/categorized-emoji.js @@ -0,0 +1,1313 @@ +/** @babel */ +export default categorizedEmojiList = { + 'People': [ + 'grinning', + 'grimacing', + 'grin', + 'joy', + 'smiley', + 'smile', + 'sweat_smile', + 'laughing', + 'innocent', + 'wink', + 'blush', + 'slightly_smiling_face', + 'upside_down_face', + 'relaxed', + 'yum', + 'relieved', + 'heart_eyes', + 'kissing_heart', + 'kissing', + 'kissing_smiling_eyes', + 'kissing_closed_eyes', + 'stuck_out_tongue_winking_eye', + 'stuck_out_tongue_closed_eyes', + 'stuck_out_tongue', + 'money_mouth_face', + 'nerd_face', + 'sunglasses', + 'hugging_face', + 'smirk', + 'no_mouth', + 'neutral_face', + 'expressionless', + 'unamused', + 'face_with_rolling_eyes', + 'thinking_face', + 'flushed', + 'disappointed', + 'worried', + 'angry', + 'rage', + 'pensive', + 'confused', + 'slightly_frowning_face', + 'white_frowning_face', + 'persevere', + 'confounded', + 'tired_face', + 'weary', + 'triumph', + 'open_mouth', + 'scream', + 'fearful', + 'cold_sweat', + 'hushed', + 'frowning', + 'anguished' + ], + 'More People': [ + 'cry', + 'disappointed_relieved', + 'sleepy', + 'sweat', + 'sob', + 'dizzy_face', + 'astonished', + 'zipper_mouth_face', + 'mask', + 'face_with_thermometer', + 'face_with_head_bandage', + 'sleeping', + 'zzz', + 'poop', + 'smiling_imp', + 'imp', + 'japanese_ogre', + 'japanese_goblin', + 'skull', + 'ghost', + 'alien', + 'robot_face', + 'smiley_cat', + 'smile_cat', + 'joy_cat', + 'heart_eyes_cat', + 'smirk_cat', + 'kissing_cat', + 'scream_cat', + 'crying_cat_face', + 'pouting_cat', + 'raised_hands', + 'clap', + 'wave', + 'thumbsup', + 'thumbsdown', + 'punch', + 'fist', + 'v', + 'ok_hand', + 'hand', + 'open_hands', + 'muscle', + 'pray', + 'point_up', + 'point_up_2', + 'point_down', + 'point_left', + 'point_right', + 'middle_finger', + 'raised_hand_with_fingers_splayed', + 'the_horns', + 'spock-hand', + 'writing_hand', + 'nail_care', + 'lips', + 'tongue', + 'ear', + 'nose', + 'eye', + 'eyes', + 'bust_in_silhouette', + 'busts_in_silhouette', + 'speaking_head_in_silhouette', + 'baby', + 'boy', + 'girl', + 'man', + 'woman', + 'person_with_blond_hair', + 'older_man', + 'older_woman', + 'man_with_gua_pi_mao', + 'man_with_turban', + 'cop', + 'construction_worker', + 'guardsman', + 'sleuth_or_spy', + 'santa', + 'angel', + 'princess', + 'bride_with_veil', + 'walking', + 'running', + 'dancer', + 'dancers', + 'couple', + 'two_men_holding_hands', + 'two_women_holding_hands', + 'bow', + 'information_desk_person', + 'no_good', + 'ok_woman', + 'raising_hand', + 'person_with_pouting_face', + 'person_frowning', + 'haircut', + 'massage', + 'couple_with_heart', + 'woman-heart-woman', + 'man-heart-man', + 'couplekiss', + 'woman-kiss-woman', + 'man-kiss-man', + 'family', + 'man-woman-girl', + 'man-woman-girl-boy', + 'man-woman-boy-boy', + 'man-woman-girl-girl', + 'woman-woman-boy', + 'woman-woman-girl', + 'woman-woman-girl-boy', + 'woman-woman-boy-boy', + 'woman-woman-girl-girl', + 'man-man-boy', + 'man-man-girl', + 'man-man-girl-boy', + 'man-man-boy-boy', + 'man-man-girl-girl', + 'womans_clothes', + 'shirt', + 'jeans', + 'necktie', + 'dress', + 'bikini', + 'kimono', + 'lipstick', + 'kiss', + 'footprints', + 'high_heel', + 'sandal', + 'boot', + 'mans_shoe', + 'athletic_shoe', + 'womans_hat', + 'tophat', + 'helmet_with_white_cross', + 'mortar_board', + 'crown', + 'school_satchel', + 'pouch', + 'purse', + 'handbag', + 'briefcase', + 'eyeglasses', + 'dark_sunglasses', + 'ring', + 'closed_umbrella' + ], + 'Nature': [ + 'dog', + 'cat', + 'mouse', + 'hamster', + 'rabbit', + 'bear', + 'panda_face', + 'koala', + 'tiger', + 'lion_face', + 'cow', + 'pig', + 'pig_nose', + 'frog', + 'octopus', + 'monkey_face', + 'see_no_evil', + 'hear_no_evil', + 'speak_no_evil', + 'monkey', + 'chicken', + 'penguin', + 'bird', + 'baby_chick', + 'hatching_chick', + 'hatched_chick', + 'wolf', + 'boar', + 'horse', + 'unicorn_face', + 'bee', + 'bug', + 'snail', + 'beetle', + 'ant', + 'spider', + 'scorpion', + 'crab', + 'snake', + 'turtle', + 'tropical_fish', + 'fish', + 'blowfish', + 'dolphin', + 'whale', + 'whale2', + 'crocodile', + 'leopard', + 'tiger2', + 'water_buffalo', + 'ox', + 'cow2', + 'dromedary_camel', + 'camel', + 'elephant', + 'goat', + 'ram', + 'sheep', + 'racehorse', + 'pig2', + 'rat', + 'mouse2', + 'rooster', + 'turkey', + 'dove_of_peace', + 'dog2', + 'poodle', + 'cat2', + 'rabbit2', + 'chipmunk', + 'paw_prints', + 'dragon', + 'dragon_face', + 'cactus', + 'christmas_tree', + 'evergreen_tree', + 'deciduous_tree', + 'palm_tree', + 'seedling', + 'herb', + 'shamrock', + 'four_leaf_clover', + 'bamboo', + 'tanabata_tree', + 'leaves', + 'fallen_leaf', + 'maple_leaf', + 'ear_of_rice', + 'hibiscus', + 'sunflower', + 'rose', + 'tulip', + 'blossom', + 'cherry_blossom', + 'bouquet', + 'mushroom', + 'chestnut', + 'jack_o_lantern', + 'shell', + 'spider_web', + 'earth_americas', + 'earth_africa', + 'earth_asia', + 'full_moon', + 'waning_gibbous_moon', + 'last_quarter_moon', + 'waning_crescent_moon', + 'new_moon', + 'waxing_crescent_moon', + 'first_quarter_moon', + 'moon', + 'new_moon_with_face', + 'full_moon_with_face', + 'first_quarter_moon_with_face', + 'last_quarter_moon_with_face', + 'sun_with_face', + 'crescent_moon', + 'star', + 'star2', + 'dizzy', + 'sparkles', + 'comet', + 'sunny', + 'mostly_sunny', + 'partly_sunny', + 'barely_sunny', + 'partly_sunny_rain', + 'cloud', + 'rain_cloud', + 'thunder_cloud_and_rain', + 'lightning', + 'zap', + 'fire', + 'boom', + 'snowflake', + 'snow_cloud', + 'showman', + 'snowman', + 'wind_blowing_face', + 'dash', + 'tornado', + 'fog', + 'umbrella', + 'droplet', + 'sweat_drops', + 'ocean' + ], + 'Food and Drink': [ + 'green_apple', + 'apple', + 'pear', + 'tangerine', + 'lemon', + 'banana', + 'watermelon', + 'grapes', + 'strawberry', + 'melon', + 'cherries', + 'peach', + 'pineapple', + 'tomato', + 'eggplant', + 'hot_pepper', + 'corn', + 'sweet_potato', + 'honey_pot', + 'bread', + 'cheese_wedge', + 'poultry_leg', + 'meat_on_bone', + 'fried_shrimp', + 'egg', + 'hamburger', + 'fries', + 'hotdog', + 'pizza', + 'spaghetti', + 'taco', + 'burrito', + 'ramen', + 'stew', + 'fish_cake', + 'sushi', + 'bento', + 'curry', + 'rice_ball', + 'rice', + 'rice_cracker', + 'oden', + 'dango', + 'shaved_ice', + 'ice_cream', + 'icecream', + 'cake', + 'birthday', + 'custard', + 'candy', + 'lollipop', + 'chocolate_bar', + 'popcorn', + 'doughnut', + 'cookie', + 'beer', + 'beers', + 'wine_glass', + 'cocktail', + 'tropical_drink', + 'champagne', + 'sake', + 'tea', + 'coffee', + 'baby_bottle', + 'fork_and_knife', + 'knife_fork_plate' + ], + 'Activity': [ + 'soccer', + 'basketball', + 'football', + 'baseball', + 'tennis', + 'volleyball', + 'rugby_football', + '8ball', + 'golf', + 'golfer', + 'table_tennis_paddle_and_ball', + 'badminton_racquet_and_shuttlecock', + 'ice_hockey_stick_and_puck', + 'field_hockey_stick_and_ball', + 'cricket_bat_and_ball', + 'ski', + 'skier', + 'snowboarder', + 'ice_skate', + 'bow_and_arrow', + 'fishing_pole_and_fish', + 'rowboat', + 'swimmer', + 'surfer', + 'bath', + 'person_with_ball', + 'weight_lifter', + 'bicyclist', + 'mountain_bicyclist', + 'horse_racing', + 'man_in_business_suit_levitating', + 'trophy', + 'running_shirt_with_sash', + 'sports_medal', + 'medal', + 'reminder_ribbon', + 'rosette', + 'ticket', + 'admission_tickets', + 'performing_arts', + 'art', + 'circus_tent', + 'microphone', + 'headphones', + 'musical_score', + 'musical_keyboard', + 'saxophone', + 'trumpet', + 'guitar', + 'violin', + 'clapper', + 'video_game', + 'space_invader', + 'dart', + 'game_die', + 'slot_machine', + 'bowling' + ], + 'Travel and Places': [ + 'car', + 'taxi', + 'blue_car', + 'bus', + 'trolleybus', + 'racing_car', + 'police_car', + 'ambulance', + 'fire_engine', + 'minibus', + 'truck', + 'articulated_lorry', + 'tractor', + 'racing_motorcycle', + 'bike', + 'rotating_light', + 'oncoming_police_car', + 'oncoming_bus', + 'oncoming_automobile', + 'oncoming_taxi', + 'aerial_tramway', + 'mountain_cableway', + 'suspension_railway', + 'railway_car', + 'train', + 'monorail', + 'bullettrain_side', + 'bullettrain_front', + 'light_rail', + 'mountain_railway', + 'steam_locomotive', + 'train2', + 'metro', + 'tram', + 'station', + 'helicopter', + 'small_airplane', + 'airplane', + 'airplane_departure', + 'airplane_arriving', + 'boat', + 'motor_boat', + 'speedboat', + 'ferry', + 'passenger_ship', + 'rocket', + 'satellite', + 'seat', + 'anchor', + 'construction', + 'fuelpump', + 'busstop', + 'vertical_traffic_light', + 'traffic_light', + 'checkered_flag', + 'ship', + 'ferris_wheel', + 'roller_coaster', + 'carousel_horse', + 'building_construction', + 'foggy', + 'tokyo_tower', + 'factory', + 'fountain', + 'rice_scene', + 'mountain', + 'snow_capped_mountain', + 'mount_fuji', + 'volcano', + 'japan', + 'camping', + 'tent', + 'national_park', + 'motorway', + 'railway_track', + 'sunrise', + 'sunrise_over_mountains', + 'desert', + 'beach_with_umbrella', + 'desert_island', + 'city_sunrise', + 'city_sunset', + 'cityscape', + 'night_with_stars', + 'bridge_at_night', + 'milky_way', + 'stars', + 'sparkler', + 'fireworks', + 'rainbow', + 'house_buildings', + 'european_castle', + 'japanese_castle', + 'stadium', + 'statue_of_liberty', + 'house', + 'house_with_garden', + 'derelict_house_building', + 'office', + 'department_store', + 'post_office', + 'european_post_office', + 'hospital', + 'bank', + 'hotel', + 'convenience_store', + 'school', + 'love_hotel', + 'wedding', + 'classical_building', + 'church', + 'mosque', + 'synagogue', + 'kaaba', + 'shinto_shrine' + ], + 'Objects': [ + 'watch', + 'iphone', + 'calling', + 'computer', + 'keyboard', + 'desktop_computer', + 'printer', + 'three_button_mouse', + 'trackball', + 'joystick', + 'compression', + 'minidisc', + 'floppy_disk', + 'cd', + 'dvd', + 'vhs', + 'camera', + 'camera_with_flash', + 'video_camera', + 'movie_camera', + 'film_projector', + 'film_frames', + 'telephone_receiver', + 'phone', + 'pager', + 'fax', + 'tv', + 'radio', + 'studio_microphone', + 'level_slider', + 'control_knobs', + 'stopwatch', + 'timer_clock', + 'alarm_clock', + 'mantelpiece_clock', + 'hourglass_flowing_sand', + 'hourglass', + 'battery', + 'electric_plug', + 'bulb', + 'flashlight', + 'candle', + 'wastebasket', + 'oil_drum', + 'money_with_wings', + 'dollar', + 'yen', + 'euro', + 'pound', + 'moneybag', + 'credit_card', + 'gem', + 'scales', + 'wrench', + 'hammer', + 'hammer_and_pick', + 'hammer_and_wrench', + 'pick', + 'nut_and_bolt', + 'gear', + 'chains', + 'gun', + 'bomb', + 'knife', + 'dagger_knife', + 'crossed_swords', + 'shield', + 'smoking', + 'skull_and_crossbones', + 'coffin', + 'funeral_urn', + 'amphora', + 'crystal_ball', + 'prayer_beads', + 'barber', + 'alembic', + 'telescope', + 'microscope', + 'hole', + 'pill', + 'syringe', + 'thermometer', + 'label', + 'bookmark', + 'toilet', + 'shower', + 'bathtub', + 'key', + 'old_key', + 'couch_and_lamp', + 'sleeping_accommodation', + 'bed', + 'door', + 'bellhop_bell', + 'frame_with_picture', + 'world_map', + 'umbrella_on_ground', + 'moyai', + 'shopping_bags', + 'balloon', + 'flags', + 'ribbon', + 'gift', + 'confetti_ball', + 'tada', + 'dolls', + 'wind_chime', + 'crossed_flags', + 'izakaya_lantern', + 'envelope', + 'envelope_with_arrow', + 'incoming_envelope', + 'e-mail', + 'love_letter', + 'postbox', + 'mailbox_closed', + 'mailbox', + 'mailbox_with_mail', + 'mailbox_with_no_mail', + 'package', + 'postal_horn', + 'inbox_tray', + 'outbox_tray', + 'scroll', + 'page_with_curl', + 'bookmark_tabs', + 'bar_chart', + 'chart_with_upwards_trend', + 'chart_with_downwards_trend', + 'page_facing_up', + 'date', + 'calendar', + 'spiral_calendar_pad', + 'card_index', + 'card_file_box', + 'ballot_box_with_ballot', + 'file_cabinet', + 'clipboard', + 'spiral_note_pad', + 'file_folder', + 'open_file_folder', + 'card_index_dividers', + 'rolled_up_newspaper', + 'newspaper', + 'notebook', + 'closed_book', + 'green_book', + 'blue_book', + 'orange_book', + 'notebook_with_decorative_cover', + 'ledger', + 'books', + 'book', + 'link', + 'paperclip', + 'linked_paperclips', + 'scissors', + 'triangular_ruler', + 'straight_ruler', + 'pushpin', + 'round_pushpin', + 'triangular_flag_on_post', + 'waving_white_flag', + 'waving_black_flag', + 'closed_lock_with_key', + 'lock', + 'unlock', + 'lock_with_ink_pen', + 'lower_left_ballpoint_pen', + 'lower_left_fountain_pen', + 'black_nib', + 'memo', + 'pencil2', + 'lower_left_crayon', + 'lower_left_paintbrush', + 'mag', + 'mag_right' + ], + 'Symbols': [ + 'heart', + 'yellow_heart', + 'green_heart', + 'blue_heart', + 'purple_heart', + 'broken_heart', + 'heavy_heart_exclamation_mark_ornament', + 'two_hearts', + 'revolving_hearts', + 'heartbeat', + 'heartpulse', + 'sparkling_heart', + 'cupid', + 'gift_heart', + 'heart_decoration', + 'peace_symbol', + 'latin_cross', + 'star_and_crescent', + 'om_symbol', + 'wheel_of_dharma', + 'star_of_david', + 'six_pointed_star', + 'menorah_with_nine_branches', + 'yin_yang', + 'orthodox_cross', + 'place_of_worship', + 'ophiuchus', + 'aries', + 'taurus', + 'gemini', + 'cancer', + 'leo', + 'virgo', + 'libra', + 'scorpius', + 'sagittarius', + 'capricorn', + 'aquarius', + 'pisces', + 'id', + 'atom_symbol', + 'u7a7a', + 'u5272', + 'radioactive_sign', + 'biohazard_sign', + 'mobile_phone_off', + 'vibration_mode', + 'u6709', + 'u7121', + 'u7533', + 'u55b6', + 'u6708', + 'eight_pointed_black_star', + 'vs', + 'accept', + 'white_flower', + 'ideograph_advantage', + 'secret', + 'congratulations', + 'u5408', + 'u6e80', + 'u7981', + 'a', + 'b', + 'ab', + 'cl', + 'o2', + 'sos', + 'no_entry', + 'name_badge', + 'no_entry_sign', + 'x', + 'o', + 'anger', + 'hotsprings', + 'no_pedestrians', + 'do_not_litter', + 'no_bicycles', + 'non-potable_water', + 'underage', + 'no_mobile_phones', + 'exclamation', + 'grey_exclamation', + 'question', + 'grey_question', + 'bangbang', + 'interrobang', + '100', + 'low_brightness', + 'high_brightness', + 'trident', + 'fleur_de_lis', + 'part_alternation_mark', + 'warning', + 'children_crossing', + 'beginner', + 'recycle', + 'u6307', + 'chart', + 'sparkle', + 'eight_spoked_asterisk', + 'negative_squared_cross_mark', + 'white_check_mark', + 'diamond_shape_with_a_dot_inside', + 'cyclone', + 'loop', + 'globe_with_meridians', + 'm', + 'atm', + 'sa', + 'passport_control', + 'customs', + 'baggage_claim', + 'left_luggage', + 'wheelchair', + 'no_smoking', + 'wc', + 'parking', + 'potable_water', + 'mens', + 'womens', + 'baby_symbol', + 'restroom', + 'put_litter_in_its_place', + 'cinema', + 'signal_strength', + 'koko', + 'ng', + 'ok', + 'up', + 'cool', + 'new', + 'free', + 'zero', + 'one', + 'two', + 'three', + 'four', + 'five', + 'six', + 'seven', + 'eight', + 'nine', + 'keycap_ten', + 'keycap_star', + '1234', + 'arrow_forward', + 'double_vertical_bar', + 'black_right_pointing_triangle_with_double_vertical_bar', + 'black_square_for_stop', + 'black_circle_for_record', + 'black_right_pointing_double_triangle_with_vertical_bar', + 'black_left_pointing_double_triangle_with_vertical_bar', + 'fast_forward', + 'rewind', + 'twisted_rightwards_arrows', + 'repeat', + 'repeat_one', + 'arrow_backward', + 'arrow_up_small', + 'arrow_down_small', + 'arrow_double_up', + 'arrow_double_down', + 'arrow_right', + 'arrow_left', + 'arrow_up', + 'arrow_down', + 'arrow_upper_right', + 'arrow_lower_right', + 'arrow_lower_left', + 'arrow_upper_left', + 'arrow_up_down', + 'left_right_arrow', + 'arrows_counterclockwise', + 'arrow_right_hook', + 'leftwards_arrow_with_hook', + 'arrow_heading_up', + 'arrow_heading_down', + 'hash', + 'information_source', + 'abc', + 'abcd', + 'capital_abcd', + 'symbols', + 'musical_note', + 'notes', + 'wavy_dash', + 'curly_loop', + 'heavy_check_mark', + 'arrows_clockwise', + 'heavy_plus_sign', + 'heavy_minus_sign', + 'heavy_division_sign', + 'heavy_multiplication_x', + 'heavy_dollar_sign', + 'currency_exchange', + 'copyright', + 'registered', + 'tm', + 'end', + 'back', + 'on', + 'top', + 'soon', + 'ballot_box_with_check', + 'radio_button', + 'white_circle', + 'black_circle', + 'red_circle', + 'large_blue_circle', + 'small_orange_diamond', + 'small_blue_diamond', + 'large_orange_diamond', + 'large_blue_diamond', + 'small_red_triangle', + 'black_small_square', + 'white_small_square', + 'black_large_square', + 'white_large_square', + 'small_red_triangle_down', + 'black_medium_square', + 'white_medium_square', + 'black_medium_small_square', + 'white_medium_small_square', + 'black_square_button', + 'white_square_button', + 'speaker', + 'sound', + 'loud_sound', + 'mute', + 'mega', + 'loudspeaker', + 'bell', + 'no_bell', + 'black_joker', + 'mahjong', + 'spades', + 'clubs', + 'hearts', + 'diamonds', + 'flower_playing_cards', + 'thought_balloon', + 'right_anger_bubble', + 'speech_balloon', + 'left_speech_bubble', + 'clock1', + 'clock2', + 'clock3', + 'clock4', + 'clock5', + 'clock6', + 'clock7', + 'clock8', + 'clock9', + 'clock10', + 'clock11', + 'clock12', + 'clock130', + 'clock230', + 'clock330', + 'clock430', + 'clock530', + 'clock630', + 'clock730', + 'clock830', + 'clock930', + 'clock1030', + 'clock1130', + 'clock1230' + ], + 'Flags': [ + 'flag-ac', + 'flag-ad', + 'flag-ae', + 'flag-af', + 'flag-ag', + 'flag-ai', + 'flag-al', + 'flag-am', + 'flag-ao', + 'flag-aq', + 'flag-ar', + 'flag-as', + 'flag-at', + 'flag-au', + 'flag-aw', + 'flag-ax', + 'flag-az', + 'flag-ba', + 'flag-bb', + 'flag-bd', + 'flag-be', + 'flag-bf', + 'flag-bg', + 'flag-bh', + 'flag-bi', + 'flag-bj', + 'flag-bl', + 'flag-bm', + 'flag-bn', + 'flag-bo', + 'flag-bq', + 'flag-br', + 'flag-bs', + 'flag-bt', + 'flag-bv', + 'flag-bw', + 'flag-by', + 'flag-bz', + 'flag-ca', + 'flag-cc', + 'flag-cd', + 'flag-cf', + 'flag-cg', + 'flag-ch', + 'flag-ci', + 'flag-ck', + 'flag-cl', + 'flag-cm', + 'flag-cn', + 'flag-co', + 'flag-cp', + 'flag-cr', + 'flag-cu', + 'flag-cv', + 'flag-cw', + 'flag-cx', + 'flag-cy', + 'flag-cz', + 'flag-de', + 'flag-dg', + 'flag-dj', + 'flag-dk', + 'flag-dm', + 'flag-do', + 'flag-dz', + 'flag-ea', + 'flag-ec', + 'flag-ee', + 'flag-eg', + 'flag-eh', + 'flag-er', + 'flag-es', + 'flag-et', + 'flag-eu', + 'flag-fi', + 'flag-fj', + 'flag-fk', + 'flag-fm', + 'flag-fo', + 'flag-fr', + 'flag-ga', + 'flag-gb', + 'flag-gd', + 'flag-ge', + 'flag-gf', + 'flag-gg', + 'flag-gh', + 'flag-gi', + 'flag-gl', + 'flag-gm', + 'flag-gn', + 'flag-gp', + 'flag-gq', + 'flag-gr', + 'flag-gs', + 'flag-gt', + 'flag-gu', + 'flag-gw', + 'flag-gy', + 'flag-hk', + 'flag-hm', + 'flag-hn', + 'flag-hr', + 'flag-ht', + 'flag-hu', + 'flag-ic', + 'flag-id', + 'flag-ie', + 'flag-il', + 'flag-im', + 'flag-in', + 'flag-io', + 'flag-iq', + 'flag-ir', + 'flag-is', + 'flag-it', + 'flag-je', + 'flag-jm', + 'flag-jo', + 'flag-jp', + 'flag-ke', + 'flag-kg', + 'flag-kh', + 'flag-ki', + 'flag-km', + 'flag-kn', + 'flag-kp', + 'flag-kr', + 'flag-kw', + 'flag-ky', + 'flag-kz', + 'flag-la', + 'flag-lb', + 'flag-lc', + 'flag-li', + 'flag-lk', + 'flag-lr', + 'flag-ls', + 'flag-lt', + 'flag-lu', + 'flag-lv', + 'flag-ly', + 'flag-ma', + 'flag-mc', + 'flag-md', + 'flag-me', + 'flag-mf', + 'flag-mg', + 'flag-mh', + 'flag-mk', + 'flag-ml', + 'flag-mm', + 'flag-mn', + 'flag-mo', + 'flag-mp', + 'flag-mq', + 'flag-mr', + 'flag-ms', + 'flag-mt', + 'flag-mu', + 'flag-mv', + 'flag-mw', + 'flag-mx', + 'flag-my', + 'flag-mz', + 'flag-na', + 'flag-nc', + 'flag-ne', + 'flag-nf', + 'flag-ng', + 'flag-ni', + 'flag-nl', + 'flag-no', + 'flag-np', + 'flag-nr', + 'flag-nu', + 'flag-nz', + 'flag-om', + 'flag-pa', + 'flag-pe', + 'flag-pf', + 'flag-pg', + 'flag-ph', + 'flag-pk', + 'flag-pl', + 'flag-pm', + 'flag-pn', + 'flag-pr', + 'flag-ps', + 'flag-pt', + 'flag-pw', + 'flag-py', + 'flag-qa', + 'flag-re', + 'flag-ro', + 'flag-rs', + 'flag-ru', + 'flag-rw', + 'flag-sa', + 'flag-sb', + 'flag-sc', + 'flag-sd', + 'flag-se', + 'flag-sg', + 'flag-sh', + 'flag-si', + 'flag-sj', + 'flag-sk', + 'flag-sl', + 'flag-sm', + 'flag-sn', + 'flag-so', + 'flag-sr', + 'flag-ss', + 'flag-st', + 'flag-sv', + 'flag-sx', + 'flag-sy', + 'flag-sz', + 'flag-ta', + 'flag-tc', + 'flag-td', + 'flag-tf', + 'flag-tg', + 'flag-th', + 'flag-tj', + 'flag-tk', + 'flag-tl', + 'flag-tm', + 'flag-tn', + 'flag-to', + 'flag-tr', + 'flag-tt', + 'flag-tv', + 'flag-tw', + 'flag-tz', + 'flag-ua', + 'flag-ug', + 'flag-um', + 'flag-us', + 'flag-uy', + 'flag-uz', + 'flag-va', + 'flag-vc', + 'flag-ve', + 'flag-vg', + 'flag-vi', + 'flag-vn', + 'flag-vu', + 'flag-wf', + 'flag-ws', + 'flag-xk', + 'flag-ye', + 'flag-yt', + 'flag-za', + 'flag-zm', + 'flag-zw' + ] +} \ No newline at end of file diff --git a/internal_packages/composer-emojis/lib/emoji-actions.js b/internal_packages/composer-emoji/lib/emoji-actions.js similarity index 85% rename from internal_packages/composer-emojis/lib/emoji-actions.js rename to internal_packages/composer-emoji/lib/emoji-actions.js index 42c7f746fb..4e80762e6b 100644 --- a/internal_packages/composer-emojis/lib/emoji-actions.js +++ b/internal_packages/composer-emoji/lib/emoji-actions.js @@ -2,7 +2,8 @@ import Reflux from 'reflux'; EmojiActions = Reflux.createActions([ - "selectEmoji" + "selectEmoji", + "useEmoji" ]); for (key in EmojiActions) { diff --git a/internal_packages/composer-emoji/lib/emoji-button-popover.jsx b/internal_packages/composer-emoji/lib/emoji-button-popover.jsx new file mode 100644 index 0000000000..e88195279f --- /dev/null +++ b/internal_packages/composer-emoji/lib/emoji-button-popover.jsx @@ -0,0 +1,328 @@ +import React from 'react'; +import {Actions} from 'nylas-exports'; +import {RetinaImg} from 'nylas-component-kit'; + +import EmojiStore from './emoji-store'; +import EmojiActions from './emoji-actions'; +import emoji from 'node-emoji'; +import categorizedEmojiList from './categorized-emoji'; +import missingEmojiList from './missing-emoji'; + +class EmojiButtonPopover extends React.Component { + static displayName = 'EmojiButtonPopover'; + + constructor() { + super(); + const {categoryNames, + categorizedEmoji, + categoryPositions} = this.getStateFromStore(); + this.state = { + emojiName: "Emoji Picker", + categoryNames: categoryNames, + categorizedEmoji: categorizedEmoji, + categoryPositions: categoryPositions, + searchValue: "", + activeTab: Object.keys(categorizedEmoji)[0], + }; + } + + componentDidMount() { + this._mounted = true; + this.renderCanvas(); + } + + componentWillUnmount() { + this._mounted = false; + } + + onMouseDown = (event) => { + const emojiName = this.calcEmojiByPosition(this.calcPosition(event)); + if (!emojiName) return null; + EmojiActions.selectEmoji({emojiName, replaceSelection: false}); + Actions.closePopover(); + } + + onScroll = () => { + const emojiContainer = document.querySelector(".emoji-finder-container"); + const tabContainer = document.querySelector(".emoji-tabs"); + tabContainer.className = emojiContainer.scrollTop ? "emoji-tabs shadow" : "emoji-tabs"; + if (emojiContainer.scrollTop === 0) { + this.setState({activeTab: Object.keys(this.state.categorizedEmoji)[0]}); + } else { + for (const category in this.state.categoryPositions) { + if (this.state.categoryPositions.hasOwnProperty(category)) { + if (emojiContainer.scrollTop >= this.state.categoryPositions[category].top && + emojiContainer.scrollTop <= this.state.categoryPositions[category].bottom) { + if (category === 'More People') { + this.setState({activeTab: 'People'}); + } else { + this.setState({activeTab: category}); + } + } + } + } + } + } + + onHover = (event) => { + const emojiName = this.calcEmojiByPosition(this.calcPosition(event)); + if (emojiName) { + this.setState({emojiName: emojiName}); + } else { + this.setState({emojiName: "Emoji Picker"}); + } + } + + onMouseOut = () => { + this.setState({emojiName: "Emoji Picker"}); + } + + onChange = (event) => { + const searchValue = event.target.value; + if (searchValue.length > 0) { + const searchMatches = this.findSearchMatches(searchValue); + this.setState({ + categorizedEmoji: { + 'Search Results': searchMatches, + }, + categoryPositions: { + 'Search Results': { + top: 25, + bottom: 25 + Math.ceil(searchMatches.length / 8) * 24, + }, + }, + searchValue: searchValue, + activeTab: null, + }, this.renderCanvas); + } else { + this.setState(this.getStateFromStore, () => { + this.setState({ + searchValue: searchValue, + activeTab: Object.keys(this.state.categorizedEmoji)[0], + }, this.renderCanvas); + }); + } + } + + getStateFromStore = () => { + let categorizedEmoji = categorizedEmojiList; + const categoryPositions = {}; + let categoryNames = [ + 'People', + 'More People', + 'Nature', + 'Food and Drink', + 'Activity', + 'Travel and Places', + 'Objects', + 'Symbols', + 'Flags', + ]; + const frequentlyUsedEmoji = EmojiStore.frequentlyUsedEmoji(); + if (frequentlyUsedEmoji.length > 0) { + categorizedEmoji = {'Frequently Used': frequentlyUsedEmoji}; + for (const category in categorizedEmojiList) { + if (categorizedEmojiList.hasOwnProperty(category)) { + categorizedEmoji[category] = categorizedEmojiList[category]; + } + } + categoryNames = ["Frequently Used"].concat(categoryNames); + } + // Calculates where each category should be (variable because Frequently + // Used may or may not be present) + for (const name of categoryNames) { + categoryPositions[name] = {top: 0, bottom: 0}; + } + let verticalPos = 25; + for (const category in categoryPositions) { + if (categoryPositions.hasOwnProperty(category)) { + const height = Math.ceil(categorizedEmoji[category].length / 8) * 24; + categoryPositions[category].top = verticalPos; + verticalPos += height; + categoryPositions[category].bottom = verticalPos; + if (category !== 'People') { + verticalPos += 24; + } + } + } + return { + categoryNames: categoryNames, + categorizedEmoji: categorizedEmoji, + categoryPositions: categoryPositions, + }; + } + + scrollToCategory(category) { + const container = document.querySelector(".emoji-finder-container"); + if (this.state.searchValue.length > 0) { + this.setState({searchValue: ""}); + this.setState(this.getStateFromStore, () => { + this.renderCanvas(); + container.scrollTop = this.state.categoryPositions[category].top + 16; + }); + } else { + container.scrollTop = this.state.categoryPositions[category].top + 16; + } + this.setState({activeTab: category}) + } + + findSearchMatches(searchValue) { + // TODO: Find matches for aliases, too. + const searchMatches = []; + for (const category of Object.keys(categorizedEmojiList)) { + categorizedEmojiList[category].forEach((emojiName) => { + if (emojiName.indexOf(searchValue) !== -1) { + searchMatches.push(emojiName); + } + }); + } + return searchMatches; + } + + calcPosition = (event) => { + const rect = event.target.getBoundingClientRect(); + const position = { + x: event.pageX - rect.left / 2, + y: event.pageY - rect.top / 2, + }; + return position; + } + + calcEmojiByPosition = (position) => { + for (const category in this.state.categoryPositions) { + if (this.state.categoryPositions.hasOwnProperty(category)) { + const LEFT_BOUNDARY = 8; + const RIGHT_BOUNDARY = 204; + const EMOJI_WIDTH = 24.5; + const EMOJI_HEIGHT = 24; + const EMOJI_PER_ROW = 8; + if (position.x >= LEFT_BOUNDARY && + position.x <= RIGHT_BOUNDARY && + position.y >= this.state.categoryPositions[category].top && + position.y <= this.state.categoryPositions[category].bottom) { + const x = Math.round((position.x + 5) / EMOJI_WIDTH); + const y = Math.round((position.y - this.state.categoryPositions[category].top + 10) / EMOJI_HEIGHT); + const index = x + (y - 1) * EMOJI_PER_ROW - 1; + return this.state.categorizedEmoji[category][index]; + } + } + } + return null; + } + + renderTabs() { + const tabs = []; + this.state.categoryNames.forEach((category) => { + if (category !== 'More People') { + let className = `emoji-tab ${(category.replace(/ /g, '-')).toLowerCase()}` + if (category === this.state.activeTab) { + className += " active"; + } + tabs.push( +
+ this.scrollToCategory(category)} /> +
+ ); + } + }); + return tabs; + } + + renderCanvas() { + const canvas = document.getElementById("emoji-canvas"); + const keys = Object.keys(this.state.categoryPositions); + canvas.height = this.state.categoryPositions[keys[keys.length - 1]].bottom * 2; + const ctx = canvas.getContext("2d"); + ctx.clearRect(0, 0, canvas.width, canvas.height); + const position = { + x: 15, + y: 45, + } + Object.keys(this.state.categorizedEmoji).forEach((category, i) => { + if (i > 0) { + setTimeout(() => this.renderCategory(category, i, ctx, position), i * 50); + } else { + this.renderCategory(category, i, ctx, position); + } + }); + } + + renderCategory(category, i, ctx, position) { + if (!this._mounted) return; + if (category !== "More People") { + if (i > 0) { + position.x = 18; + position.y += 48; + } + ctx.font = "24px Nylas-Pro"; + ctx.fillStyle = 'rgba(0, 0, 0, 0.5)'; + ctx.fillText(category, position.x, position.y); + } + position.x = 18; + position.y += 48; + ctx.font = "32px Arial"; + ctx.fillStyle = 'black'; + if (this.state.categorizedEmoji[category].length === 0) return; + this.state.categorizedEmoji[category].forEach((emojiName, j) => { + if (process.platform === "darwin" && missingEmojiList.indexOf(emojiName) === -1) { + const emojiChar = emoji.get(emojiName); + ctx.fillText(emojiChar, position.x, position.y); + } else { + const img = new Image(); + img.src = `images/composer-emoji/missing-emoji/${emojiName}.png`; + const x = position.x; + const y = position.y; + img.onload = () => { + ctx.drawImage(img, x, y - 30, 32, 32); + } + } + if (position.x > 325 && j < this.state.categorizedEmoji[category].length - 1) { + position.x = 18; + position.y += 48; + } else { + position.x += 50; + } + }) + } + + render() { + return ( +
+
+ {this.renderTabs()} +
+
+
+ +
+ + +
+
+ {this.state.emojiName} +
+
+ ); + } +} + +export default EmojiButtonPopover; diff --git a/internal_packages/composer-emoji/lib/emoji-button.jsx b/internal_packages/composer-emoji/lib/emoji-button.jsx new file mode 100644 index 0000000000..7dfe0c6ac0 --- /dev/null +++ b/internal_packages/composer-emoji/lib/emoji-button.jsx @@ -0,0 +1,36 @@ +import React from 'react'; +import {Actions} from 'nylas-exports'; +import {RetinaImg} from 'nylas-component-kit'; + +import EmojiButtonPopover from './emoji-button-popover'; + + +class EmojiButton extends React.Component { + static displayName = 'EmojiButton'; + + constructor() { + super(); + } + + onClick = ()=> { + const buttonRect = React.findDOMNode(this).getBoundingClientRect(); + Actions.openPopover( + , + {originRect: buttonRect, direction: 'up'} + ) + } + + render() { + return ( + + ); + } +} + +EmojiButton.containerStyles = { + order: 2, +}; + +export default EmojiButton; diff --git a/internal_packages/composer-emojis/lib/emojis-composer-extension.jsx b/internal_packages/composer-emoji/lib/emoji-composer-extension.jsx similarity index 56% rename from internal_packages/composer-emojis/lib/emojis-composer-extension.jsx rename to internal_packages/composer-emoji/lib/emoji-composer-extension.jsx index e67c40e82c..febf61c02b 100644 --- a/internal_packages/composer-emojis/lib/emojis-composer-extension.jsx +++ b/internal_packages/composer-emoji/lib/emoji-composer-extension.jsx @@ -1,22 +1,27 @@ -import {DOMUtils, ContenteditableExtension} from 'nylas-exports' -import EmojiActions from './emoji-actions' -import EmojiPicker from './emoji-picker' -const emoji = require('node-emoji'); -const emojis = Object.keys(emoji.emoji).sort(); +import {DOMUtils, ComposerExtension} from 'nylas-exports'; +import EmojiActions from './emoji-actions'; +import EmojiPicker from './emoji-picker'; +import emoji from 'node-emoji'; +import missingEmojiList from './missing-emoji'; -class EmojisComposerExtension extends ContenteditableExtension { + +class EmojiComposerExtension extends ComposerExtension { + + static selState = null; static onContentChanged = ({editor}) => { const sel = editor.currentSelection() - const {emojiOptions, triggerWord} = EmojisComposerExtension._findEmojiOptions(sel); + const {emojiOptions, triggerWord} = EmojiComposerExtension._findEmojiOptions(sel); if (sel.anchorNode && sel.isCollapsed) { if (emojiOptions.length > 0) { const offset = sel.anchorOffset; if (!DOMUtils.closest(sel.anchorNode, "n1-emoji-autocomplete")) { + const anchorOffset = Math.max(sel.anchorOffset - triggerWord.length - 1, 0); editor.select(sel.anchorNode, - sel.anchorOffset - triggerWord.length - 1, + anchorOffset, sel.focusNode, - sel.focusOffset).wrapSelection("n1-emoji-autocomplete"); + sel.focusOffset) + editor.wrapSelection("n1-emoji-autocomplete"); editor.select(sel.anchorNode, offset, sel.anchorNode, @@ -42,10 +47,21 @@ class EmojisComposerExtension extends ContenteditableExtension { } }; + static onBlur = ({editor}) => { + EmojiComposerExtension.selState = editor.currentSelection().exportSelection(); + }; + + static onFocus = ({editor}) => { + if (EmojiComposerExtension.selState) { + editor.select(EmojiComposerExtension.selState); + EmojiComposerExtension.selState = null; + } + }; + static toolbarComponentConfig = ({toolbarState}) => { const sel = toolbarState.selectionSnapshot; if (sel) { - const {emojiOptions} = EmojisComposerExtension._findEmojiOptions(sel); + const {emojiOptions} = EmojiComposerExtension._findEmojiOptions(sel); if (emojiOptions.length > 0 && !toolbarState.dragging && !toolbarState.doubleDown) { const locationRefNode = DOMUtils.closest(sel.anchorNode, "n1-emoji-autocomplete"); @@ -56,8 +72,8 @@ class EmojisComposerExtension extends ContenteditableExtension { props: {emojiOptions, selectedEmoji}, locationRefNode: locationRefNode, - width: EmojisComposerExtension._emojiPickerWidth(emojiOptions), - height: EmojisComposerExtension._emojiPickerHeight(emojiOptions), + width: EmojiComposerExtension._emojiPickerWidth(emojiOptions), + height: EmojiComposerExtension._emojiPickerHeight(emojiOptions), hidePointer: true, } } @@ -68,19 +84,20 @@ class EmojisComposerExtension extends ContenteditableExtension { static editingActions = () => { return [{ action: EmojiActions.selectEmoji, - callback: EmojisComposerExtension._onSelectEmoji, + callback: EmojiComposerExtension._onSelectEmoji, }] }; static onKeyDown = ({editor, event}) => { const sel = editor.currentSelection() - const {emojiOptions} = EmojisComposerExtension._findEmojiOptions(sel); + const {emojiOptions} = EmojiComposerExtension._findEmojiOptions(sel); if (emojiOptions.length > 0) { if (event.key === "ArrowDown" || event.key === "ArrowRight" || event.key === "ArrowUp" || event.key === "ArrowLeft") { event.preventDefault(); - const moveToNext = (event.key === "ArrowDown" || event.key === "ArrowRight") + const moveToNext = (event.key === "ArrowDown" || event.key === "ArrowRight"); const emojiNameNode = DOMUtils.closest(sel.anchorNode, "n1-emoji-autocomplete"); + if (!emojiNameNode) return null; const selectedEmoji = emojiNameNode.getAttribute("selectedEmoji"); if (selectedEmoji) { const emojiIndex = emojiOptions.indexOf(selectedEmoji); @@ -99,14 +116,37 @@ class EmojisComposerExtension extends ContenteditableExtension { } else if (event.key === "Enter" || event.key === "Tab") { event.preventDefault(); const emojiNameNode = DOMUtils.closest(sel.anchorNode, "n1-emoji-autocomplete"); + if (!emojiNameNode) return null; let selectedEmoji = emojiNameNode.getAttribute("selectedEmoji"); if (!selectedEmoji) selectedEmoji = emojiOptions[0]; - EmojisComposerExtension._onSelectEmoji({editor: editor, - actionArg: {emojiChar: emoji.get(selectedEmoji)}}); + const args = { + editor: editor, + actionArg: { + emojiName: selectedEmoji, + replaceSelection: true, + }, + }; + EmojiComposerExtension._onSelectEmoji(args); } } }; + static applyTransformsToDraft = ({draft}) => { + const nextDraft = draft.clone(); + nextDraft.body = nextDraft.body.replace(/.*<\/span>/g, (match, emojiName) => + `${emoji.get(emojiName)}` + ); + return nextDraft; + } + + static unapplyTransformsToDraft = ({draft}) => { + const nextDraft = draft.clone(); + nextDraft.body = nextDraft.body.replace(/.*<\/span>/g, (match, emojiName) => + `` + ); + return nextDraft; + } + static _findEmojiOptions(sel) { if (sel.anchorNode && sel.anchorNode.nodeValue && @@ -118,7 +158,7 @@ class EmojisComposerExtension extends ContenteditableExtension { if (index !== -1 && words.lastIndexOf(" ") < index) { lastWord = words.substring(index + 1, sel.anchorOffset); } else { - const {text} = EmojisComposerExtension._getTextUntilSpace(sel.anchorNode, sel.anchorOffset); + const {text} = EmojiComposerExtension._getTextUntilSpace(sel.anchorNode, sel.anchorOffset); index = text.lastIndexOf(":"); if (index !== -1 && text.lastIndexOf(" ") < index) { lastWord = text.substring(index + 1); @@ -127,7 +167,7 @@ class EmojisComposerExtension extends ContenteditableExtension { } } if (lastWord.length > 0) { - return {triggerWord: lastWord, emojiOptions: EmojisComposerExtension._findMatches(lastWord)}; + return {triggerWord: lastWord, emojiOptions: EmojiComposerExtension._findMatches(lastWord)}; } return {triggerWord: lastWord, emojiOptions: []}; } @@ -135,33 +175,47 @@ class EmojisComposerExtension extends ContenteditableExtension { } static _onSelectEmoji = ({editor, actionArg}) => { - const emojiChar = actionArg.emojiChar; - if (!emojiChar) return null; - const sel = editor.currentSelection() - if (sel.anchorNode && - sel.anchorNode.nodeValue && - sel.anchorNode.nodeValue.length > 0 && - sel.isCollapsed) { - const words = sel.anchorNode.nodeValue.substring(0, sel.anchorOffset); - let index = words.lastIndexOf(":"); - let lastWord = words.substring(index + 1, sel.anchorOffset); - if (index !== -1 && words.lastIndexOf(" ") < index) { - editor.select(sel.anchorNode, - sel.anchorOffset - lastWord.length - 1, - sel.focusNode, - sel.focusOffset); - } else { - const {text, textNode} = EmojisComposerExtension._getTextUntilSpace(sel.anchorNode, sel.anchorOffset); - index = text.lastIndexOf(":"); - lastWord = text.substring(index + 1); - const offset = textNode.nodeValue.lastIndexOf(":"); - editor.select(textNode, - offset, - sel.focusNode, - sel.focusOffset); + const {emojiName, replaceSelection} = actionArg; + if (!emojiName) return null; + if (replaceSelection) { + const sel = editor.currentSelection(); + if (sel.anchorNode && + sel.anchorNode.nodeValue && + sel.anchorNode.nodeValue.length > 0 && + sel.isCollapsed) { + const words = sel.anchorNode.nodeValue.substring(0, sel.anchorOffset); + let index = words.lastIndexOf(":"); + let lastWord = words.substring(index + 1, sel.anchorOffset); + if (index !== -1 && words.lastIndexOf(" ") < index) { + editor.select(sel.anchorNode, + sel.anchorOffset - lastWord.length - 1, + sel.focusNode, + sel.focusOffset); + } else { + const {text, textNode} = EmojiComposerExtension._getTextUntilSpace(sel.anchorNode, sel.anchorOffset); + index = text.lastIndexOf(":"); + lastWord = text.substring(index + 1); + const offset = textNode.nodeValue.lastIndexOf(":"); + editor.select(textNode, + offset, + sel.focusNode, + sel.focusOffset); + editor.delete(); + } } + } + const emojiChar = emoji.get(emojiName); + if (process.platform === "darwin" && missingEmojiList.indexOf(emojiName) !== -1) { + const html = ``; + editor.insertHTML(html, {selectInsertion: false}); + } else { editor.insertText(emojiChar); } + EmojiActions.useEmoji({emojiName: emojiName, emojiChar: emojiChar}); }; static _emojiPickerWidth(emojiOptions) { @@ -203,9 +257,10 @@ class EmojisComposerExtension extends ContenteditableExtension { static _findMatches(word) { const emojiOptions = [] - for (const curEmoji of emojis) { - if (word === curEmoji.substring(0, word.length)) { - emojiOptions.push(curEmoji); + const emojiNames = Object.keys(emoji.emoji).sort(); + for (const emojiName of emojiNames) { + if (word === emojiName.substring(0, word.length)) { + emojiOptions.push(emojiName); } } return emojiOptions; @@ -213,4 +268,4 @@ class EmojisComposerExtension extends ContenteditableExtension { } -export default EmojisComposerExtension; +export default EmojiComposerExtension; diff --git a/internal_packages/composer-emoji/lib/emoji-message-extension.jsx b/internal_packages/composer-emoji/lib/emoji-message-extension.jsx new file mode 100644 index 0000000000..1a765a1a2d --- /dev/null +++ b/internal_packages/composer-emoji/lib/emoji-message-extension.jsx @@ -0,0 +1,12 @@ +import {MessageViewExtension} from 'nylas-exports'; + + +class EmojiMessageExtension extends MessageViewExtension { + static formatMessageBody({message}) { + message.body = message.body.replace(/.*<\/span>/g, (match, emojiName) => + `` + ); + } +} + +export default EmojiMessageExtension; diff --git a/internal_packages/composer-emoji/lib/emoji-picker.jsx b/internal_packages/composer-emoji/lib/emoji-picker.jsx new file mode 100644 index 0000000000..66cbc0574f --- /dev/null +++ b/internal_packages/composer-emoji/lib/emoji-picker.jsx @@ -0,0 +1,64 @@ +import {React} from 'nylas-exports'; +import EmojiActions from './emoji-actions'; +import emoji from 'node-emoji'; +import missingEmojiList from './missing-emoji'; + + +class EmojiPicker extends React.Component { + static displayName = "EmojiPicker"; + static propTypes = { + emojiOptions: React.PropTypes.array, + selectedEmoji: React.PropTypes.string, + }; + + constructor(props) { + super(props); + this.state = {}; + } + + componentDidUpdate() { + const selectedButton = React.findDOMNode(this).querySelector(".emoji-option"); + if (selectedButton) { + selectedButton.scrollIntoViewIfNeeded(); + } + } + + onMouseDown(emojiName) { + EmojiActions.selectEmoji({emojiName, replaceSelection: true}); + } + + render() { + const emojiButtons = []; + let emojiIndex = this.props.emojiOptions.indexOf(this.props.selectedEmoji); + if (emojiIndex === -1) emojiIndex = 0; + if (this.props.emojiOptions) { + this.props.emojiOptions.forEach((emojiOption, i) => { + const emojiClass = emojiIndex === i ? "btn btn-icon emoji-option" : "btn btn-icon"; + let emojiChar = emoji.get(emojiOption); + if (process.platform === "darwin" && missingEmojiList.indexOf(emojiOption) !== -1) { + emojiChar = (); + } + emojiButtons.push( + + ); + emojiButtons.push(
); + }); + } + return ( +
+ {emojiButtons} +
+ ); + } +} + +export default EmojiPicker; diff --git a/internal_packages/composer-emoji/lib/emoji-store.jsx b/internal_packages/composer-emoji/lib/emoji-store.jsx new file mode 100644 index 0000000000..5a1ae99d36 --- /dev/null +++ b/internal_packages/composer-emoji/lib/emoji-store.jsx @@ -0,0 +1,69 @@ +import NylasStore from 'nylas-store'; +import Rx from 'rx-lite'; +import _ from 'underscore'; + +import {DatabaseStore} from 'nylas-exports'; +import EmojiActions from './emoji-actions'; + +const EmojiJSONBlobKey = 'emoji'; + + +class EmojiStore extends NylasStore { + constructor(props) { + super(props); + this._emoji = []; + } + + activate = () => { + const query = DatabaseStore.findJSONBlob(EmojiJSONBlobKey); + this._subscription = Rx.Observable.fromQuery(query).subscribe((emoji) => { + this._emoji = emoji ? emoji : []; + this.trigger(); + }); + this.listenTo(EmojiActions.useEmoji, this._onUseEmoji); + } + + frequentlyUsedEmoji = () => { + const sortedEmoji = this._emoji; + sortedEmoji.sort((a, b) => { + if (a.frequency < b.frequency) return 1; + return (b.frequency < a.frequency) ? -1 : 0; + }); + const sortedEmojiNames = []; + for (const emoji of sortedEmoji) { + sortedEmojiNames.push(emoji.emojiName); + } + if (sortedEmojiNames.length > 32) { + return sortedEmojiNames.slice(0, 32); + } + return sortedEmojiNames; + } + + _onUseEmoji = (emoji) => { + const savedEmoji = _.find(this._emoji, (curEmoji) => { + return curEmoji.emojiChar === emoji.emojiChar; + }); + if (savedEmoji) { + for (const key in emoji) { + if (emoji.hasOwnProperty(key)) { + savedEmoji[key] = emoji[key]; + } + } + savedEmoji.frequency++; + } else { + _.extend(emoji, {frequency: 1}); + this._emoji.push(emoji); + } + this._saveEmoji(); + this.trigger(); + } + + _saveEmoji = () => { + DatabaseStore.inTransaction((t) => { + return t.persistJSONBlob(EmojiJSONBlobKey, this._emoji); + }); + } + +} + +export default new EmojiStore(); diff --git a/internal_packages/composer-emoji/lib/main.js b/internal_packages/composer-emoji/lib/main.js new file mode 100644 index 0000000000..e2367973c7 --- /dev/null +++ b/internal_packages/composer-emoji/lib/main.js @@ -0,0 +1,19 @@ +/** @babel */ +import {ExtensionRegistry, ComponentRegistry} from 'nylas-exports'; +import EmojiStore from './emoji-store'; +import EmojiComposerExtension from './emoji-composer-extension'; +import EmojiMessageExtension from './emoji-message-extension'; +import EmojiButton from './emoji-button'; + +export function activate() { + ExtensionRegistry.Composer.register(EmojiComposerExtension); + ExtensionRegistry.MessageView.register(EmojiMessageExtension); + ComponentRegistry.register(EmojiButton, {role: 'Composer:ActionButton'}); + EmojiStore.activate(); +} + +export function deactivate() { + ExtensionRegistry.Composer.unregister(EmojiComposerExtension); + ExtensionRegistry.MessageView.unregister(EmojiMessageExtension); + ComponentRegistry.unregister(EmojiButton); +} diff --git a/internal_packages/composer-emoji/lib/missing-emoji.js b/internal_packages/composer-emoji/lib/missing-emoji.js new file mode 100644 index 0000000000..82a6156e7a --- /dev/null +++ b/internal_packages/composer-emoji/lib/missing-emoji.js @@ -0,0 +1,101 @@ +/** @babel */ +export default missingEmojiList = [ + 'relaxed', + 'v', + 'point_up', + 'writing_hand', + 'woman-heart-woman', + 'man-heart-man', + 'woman-kiss-woman', + 'man-kiss-man', + 'sunny', + 'cloud', + 'snowflake', + 'showman', + 'baseball', + 'airplane', + 'envelope', + 'email', + 'scissors', + 'black_nib', + 'pencil2', + 'heart', + 'heavy_heart_exclamation_mark_ornament', + 'latin_cross', + 'star_of_david', + 'yin_yang', + 'u7a7a', + 'u5272', + 'u6709', + 'u7121', + 'u7533', + 'u55b6', + 'u6708', + 'eight_pointed_black_star', + 'accept', + 'ideograph_advantage', + 'secret', + 'congratulations', + 'u5408', + 'u6e80', + 'u7981', + 'a', + 'b', + 'o2', + 'hotsprings', + 'bangbang', + 'interrobang', + 'part_alternation_mark', + 'warning', + 'recycle', + 'u6307', + 'sparkle', + 'eight_spoked_asterisk', + 'm', + 'sa', + 'parking', + 'zero', + 'one', + 'two', + 'three', + 'four', + 'five', + 'six', + 'seven', + 'eight', + 'nine', + 'keycap_star', + 'arrow_forward', + 'arrow_backward', + 'arrow_right', + 'arrow_left', + 'arrow_up', + 'arrow_down', + 'arrow_upper_right', + 'arrow_lower_right', + 'arrow_lower_left', + 'arrow_upper_left', + 'arrow_up_down', + 'left_right_arrow', + 'arrow_right_hook', + 'leftwards_arrow_with_hook', + 'arrow_heading_up', + 'arrow_heading_down', + 'hash', + 'information_source', + 'wavy_dash', + 'heavy_check_mark', + 'heavy_multiplication_x', + 'copyright', + 'registered', + 'tm', + 'ballot_box_with_check', + 'black_small_square', + 'white_small_square', + 'black_medium_square', + 'white_medium_square', + 'spades', + 'clubs', + 'hearts', + 'diamonds' +]; diff --git a/internal_packages/composer-emojis/package.json b/internal_packages/composer-emoji/package.json similarity index 89% rename from internal_packages/composer-emojis/package.json rename to internal_packages/composer-emoji/package.json index 712aca80b6..423f614c60 100644 --- a/internal_packages/composer-emojis/package.json +++ b/internal_packages/composer-emoji/package.json @@ -1,5 +1,5 @@ { - "name": "composer-emojis", + "name": "composer-emoji", "main": "./lib/main", "version": "0.1.0", "repository": { @@ -17,7 +17,7 @@ "description": "Type a colon (:) followed by the name of an emoji symbol to insert it into your message!", "dependencies": { - "node-emoji": "^1.1.0" + "node-emoji": "^1.2.1" }, "windowTypes": { "default": true, diff --git a/internal_packages/composer-emojis/spec/emojis-composer-extension-spec.jsx b/internal_packages/composer-emoji/spec/emoji-composer-extension-spec.jsx similarity index 85% rename from internal_packages/composer-emojis/spec/emojis-composer-extension-spec.jsx rename to internal_packages/composer-emoji/spec/emoji-composer-extension-spec.jsx index 50be9bce0a..d10f6e0316 100644 --- a/internal_packages/composer-emojis/spec/emojis-composer-extension-spec.jsx +++ b/internal_packages/composer-emoji/spec/emoji-composer-extension-spec.jsx @@ -1,18 +1,18 @@ import React, {addons} from 'react/addons'; import {renderIntoDocument} from '../../../spec/nylas-test-utils'; import Contenteditable from '../../../src/components/contenteditable/contenteditable'; -import EmojisComposerExtension from '../lib/emojis-composer-extension'; +import EmojiComposerExtension from '../lib/emoji-composer-extension'; const ReactTestUtils = addons.TestUtils; -describe('EmojisComposerExtension', ()=> { +describe('EmojiComposerExtension', ()=> { beforeEach(()=> { - spyOn(EmojisComposerExtension, 'onContentChanged').andCallThrough() - spyOn(EmojisComposerExtension, '_onSelectEmoji').andCallThrough() + spyOn(EmojiComposerExtension, 'onContentChanged').andCallThrough() + spyOn(EmojiComposerExtension, '_onSelectEmoji').andCallThrough() const html = 'Testing!' const onChange = jasmine.createSpy('onChange') this.component = renderIntoDocument( - + ) this.editableNode = React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithAttr(this.component, 'contentEditable')); }) @@ -59,7 +59,7 @@ describe('EmojisComposerExtension', ()=> { ReactTestUtils.Simulate.keyDown(this.editableNode, {key: "Enter", keyCode: 13, which: 13}); }); waitsFor(()=> { - return EmojisComposerExtension._onSelectEmoji.calls.length > 0 + return EmojiComposerExtension._onSelectEmoji.calls.length > 0 }) runs(()=> { expect(this.editableNode.textContent === "Testing! 💇").toBe(true); @@ -76,10 +76,10 @@ describe('EmojisComposerExtension', ()=> { runs(()=> { const button = React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithClass(this.component, 'emoji-option')) ReactTestUtils.Simulate.mouseDown(button); - expect(EmojisComposerExtension._onSelectEmoji).toHaveBeenCalled() + expect(EmojiComposerExtension._onSelectEmoji).toHaveBeenCalled() }); waitsFor(()=> { - return EmojisComposerExtension._onSelectEmoji.calls.length > 0 + return EmojiComposerExtension._onSelectEmoji.calls.length > 0 }) runs(()=> { expect(this.editableNode.textContent).toEqual("Testing! 💇"); @@ -97,14 +97,14 @@ describe('EmojisComposerExtension', ()=> { ReactTestUtils.Simulate.keyDown(this.editableNode, {key: "ArrowDown", keyCode: 40, which: 40}); }); waitsFor(()=> { - return EmojisComposerExtension.onContentChanged.calls.length > 1 + return EmojiComposerExtension.onContentChanged.calls.length > 1 }); runs(()=> { expect(React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithClass(this.component, 'emoji-option')).textContent).toEqual("🍔 :hamburger:"); }); }) - it('should be able to insert two emojis next to each other', ()=> { + it('should be able to insert two emoji next to each other', ()=> { runs(()=> { this._performEdit('Testing! 🍔 :h'); }); diff --git a/internal_packages/composer-emoji/stylesheets/composer-emoji.less b/internal_packages/composer-emoji/stylesheets/composer-emoji.less new file mode 100644 index 0000000000..b284a16f38 --- /dev/null +++ b/internal_packages/composer-emoji/stylesheets/composer-emoji.less @@ -0,0 +1,57 @@ +@import "ui-variables"; + +.emoji-picker { + max-height: 130px !important; + margin: 10px; + overflow: auto; + .btn.btn-icon { + font-size: 14px !important; + padding: 0 0.5em; + &:first-child { + padding-left: 0.5em !important; + } + &.emoji-option, &:hover { + background-color: @btn-emphasis-bg-color; + color: #FFFFFF; + border-radius: 5px; + } + } +} + +.emoji-button-popover { + width: 210px; + height: 290px; + overflow: hidden; + .emoji-tabs { + display: flex; + flex-direction: row; + padding: 5px 5px 5px 10px; + border-bottom: 1px solid @border-color-primary; + transition: box-shadow 0.5s; + &.shadow { + box-shadow: @standard-shadow; + } + .emoji-tab { + background-color: @gray-light; + &.active { + background-color: @component-active-color; + } + } + } + .emoji-finder-container { + height: 232px; + overflow: auto; + .emoji-search-container { + padding: @padding-base-vertical * 1.5 @padding-base-horizontal 0; + } + } + .emoji-name { + height: 25px; + width: 192px; + margin-top: 2px; + margin-left: 10px; + overflow: hidden; + text-overflow: ellipsis; + color: @text-color-very-subtle; + } +} \ No newline at end of file diff --git a/internal_packages/composer-emojis/lib/emoji-picker.jsx b/internal_packages/composer-emojis/lib/emoji-picker.jsx deleted file mode 100644 index 13c1ded127..0000000000 --- a/internal_packages/composer-emojis/lib/emoji-picker.jsx +++ /dev/null @@ -1,48 +0,0 @@ -import {React} from 'nylas-exports' -import EmojiActions from './emoji-actions' -const emoji = require('node-emoji'); - -class EmojiPicker extends React.Component { - static displayName = "EmojiPicker"; - static propTypes = { - emojiOptions: React.PropTypes.array, - selectedEmoji: React.PropTypes.string, - }; - - constructor(props) { - super(props); - this.state = {}; - } - - componentDidUpdate() { - const selectedButton = React.findDOMNode(this).querySelector(".emoji-option"); - if (selectedButton) { - selectedButton.scrollIntoViewIfNeeded(); - } - } - - onMouseDown(emojiChar) { - EmojiActions.selectEmoji({emojiChar}); - } - - render() { - const emojis = []; - let emojiIndex = this.props.emojiOptions.indexOf(this.props.selectedEmoji); - if (emojiIndex === -1) emojiIndex = 0; - if (this.props.emojiOptions) { - this.props.emojiOptions.forEach((emojiOption, i) => { - const emojiChar = emoji.get(emojiOption); - const emojiClass = emojiIndex === i ? "btn btn-icon emoji-option" : "btn btn-icon"; - emojis.push(); - emojis.push(
); - }) - } - return ( -
- {emojis} -
- ); - } -} - -export default EmojiPicker; diff --git a/internal_packages/composer-emojis/lib/main.js b/internal_packages/composer-emojis/lib/main.js deleted file mode 100644 index 672eac8e57..0000000000 --- a/internal_packages/composer-emojis/lib/main.js +++ /dev/null @@ -1,11 +0,0 @@ -/** @babel */ -import {ExtensionRegistry} from 'nylas-exports'; -import EmojisComposerExtension from './emojis-composer-extension'; - -export function activate() { - ExtensionRegistry.Composer.register(EmojisComposerExtension); -} - -export function deactivate() { - ExtensionRegistry.Composer.unregister(EmojisComposerExtension); -} diff --git a/internal_packages/composer-emojis/stylesheets/composer-emojis.less b/internal_packages/composer-emojis/stylesheets/composer-emojis.less deleted file mode 100644 index 124d967a21..0000000000 --- a/internal_packages/composer-emojis/stylesheets/composer-emojis.less +++ /dev/null @@ -1,19 +0,0 @@ -@import "ui-variables"; - -.emoji-picker { - max-height: 130px !important; - margin: 10px; - overflow: auto; - .btn.btn-icon { - font-size: 14px !important; - padding: 0 0.5em; - &:first-child { - padding-left: 0.5em !important; - } - &.emoji-option, &:hover { - background-color: @btn-emphasis-bg-color; - color: #FFFFFF; - border-radius: 5px; - } - } -} diff --git a/internal_packages/composer-signature/lib/main.es6 b/internal_packages/composer-signature/lib/main.es6 index f8b9b876f7..5aaa35a7e5 100644 --- a/internal_packages/composer-signature/lib/main.es6 +++ b/internal_packages/composer-signature/lib/main.es6 @@ -13,15 +13,13 @@ export function activate() { ExtensionRegistry.Composer.register(SignatureComposerExtension); PreferencesUIStore.registerPreferencesTab(this.preferencesTab); - - this.signatureStore = new SignatureStore(); - this.signatureStore.activate(); + SignatureStore.activate(); } export function deactivate() { ExtensionRegistry.Composer.unregister(SignatureComposerExtension); PreferencesUIStore.unregisterPreferencesTab(this.preferencesTab.sectionId); - this.signatureStore.deactivate(); + SignatureStore.deactivate(); } export function serialize() { diff --git a/internal_packages/composer-signature/lib/preferences-signatures.cjsx b/internal_packages/composer-signature/lib/preferences-signatures.cjsx deleted file mode 100644 index 78f84cda12..0000000000 --- a/internal_packages/composer-signature/lib/preferences-signatures.cjsx +++ /dev/null @@ -1,118 +0,0 @@ -React = require 'react' -_ = require 'underscore' -{Contenteditable, RetinaImg, Flexbox} = require 'nylas-component-kit' -{AccountStore, Utils} = require 'nylas-exports' - -class PreferencesSignatures extends React.Component - @displayName: 'PreferencesSignatures' - - constructor: (@props) -> - @_signatureSaveQueue = {} - - # TODO check initally selected account - selectedAccountId = AccountStore.accounts()[0].id - if selectedAccountId - key = @_configKey(selectedAccountId) - initialSig = @props.config.get(key) - else - initialSig = "" - - @state = - editAsHTML: false - accounts: AccountStore.accounts() - currentSignature: initialSig - selectedAccountId: selectedAccountId - - componentDidMount: -> - @usub = AccountStore.listen @_onChange - - componentWillUnmount: -> - @usub() - @_saveSignatureNow(@state.selectedAccountId, @state.currentSignature) - - _saveSignatureNow: (accountId, value) => - key = @_configKey(accountId) - @props.config.set(key, value) - - _saveSignatureSoon: (accountId, value) => - @_signatureSaveQueue[accountId] = value - @_saveSignaturesFromCache() - - __saveSignaturesFromCache: => - for accountId, value of @_signatureSaveQueue - @_saveSignatureNow(accountId, value) - - @_signatureSaveQueue = {} - - _saveSignaturesFromCache: _.debounce(PreferencesSignatures::__saveSignaturesFromCache, 500) - - _onChange: => - @setState @_getStateFromStores() - - _getStateFromStores: -> - accounts = AccountStore.accounts() - selectedAccountId = @state.selectedAccountId - currentSignature = @state.currentSignature - if not @state.selectedAccountId in _.pluck(accounts, "id") - selectedAccountId = null - currentSignature = "" - return {accounts, selectedAccountId, currentSignature} - - _renderAccountPicker: -> - options = @state.accounts.map (account) -> - - - - - _renderEditableSignature: -> - - - _renderHTMLSignature: -> -