├── .eslintrc.js ├── .github └── workflows │ └── build.yml ├── .gitignore ├── .prettierrc ├── .vscode ├── extensions.json └── settings.json ├── .yarn └── releases │ └── yarn-3.2.1.cjs ├── .yarnrc.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── package.json ├── packages ├── plugin │ ├── README.md │ ├── assets │ │ ├── header.png │ │ └── logo.png │ ├── package.json │ ├── src │ │ ├── Ui.tsx │ │ ├── components │ │ │ └── Resizer.tsx │ │ ├── main │ │ │ ├── index.ts │ │ │ └── store.ts │ │ ├── shared │ │ │ └── EventEmitter.ts │ │ ├── store │ │ │ └── index.tsx │ │ ├── style.css │ │ └── views │ │ │ ├── Chat │ │ │ ├── components │ │ │ │ └── Chatbar.tsx │ │ │ └── index.tsx │ │ │ └── Settings │ │ │ ├── components │ │ │ └── AvatarColorPicker.tsx │ │ │ └── index.tsx │ ├── tsconfig.json │ └── webpack.config.js ├── server │ ├── .dockerignore │ ├── Dockerfile │ ├── README.md │ ├── docker-compose.yml │ ├── package.json │ ├── src │ │ └── server.ts │ └── tsconfig.json ├── shared │ ├── package.json │ ├── src │ │ ├── assets │ │ │ ├── GiphyLogo.tsx │ │ │ ├── icons │ │ │ │ ├── BackIcon.tsx │ │ │ │ ├── BellIcon.tsx │ │ │ │ ├── ChainIcon.tsx │ │ │ │ ├── ChatIcon.tsx │ │ │ │ ├── EmojiIcon.tsx │ │ │ │ ├── GearIcon.tsx │ │ │ │ ├── GiphyCloseIcon.tsx │ │ │ │ ├── HashIcon.tsx │ │ │ │ ├── MessageIcon.tsx │ │ │ │ ├── SendArrowIcon.tsx │ │ │ │ ├── SettingsIcon.tsx │ │ │ │ ├── ThemeIcon.tsx │ │ │ │ └── TrashIcon.tsx │ │ │ └── sound.mp3 │ │ ├── components │ │ │ ├── Checkbox.tsx │ │ │ ├── CustomLink.tsx │ │ │ ├── GiphyGrid.tsx │ │ │ ├── Message.tsx │ │ │ ├── Messages.tsx │ │ │ ├── Notification.tsx │ │ │ ├── Notifications.tsx │ │ │ ├── Tooltip.tsx │ │ │ └── UserList.tsx │ │ └── utils │ │ │ ├── SocketProvider.tsx │ │ │ ├── constants.ts │ │ │ ├── helpers.ts │ │ │ ├── hooks │ │ │ └── use-on-outside-click.ts │ │ │ ├── interfaces.ts │ │ │ └── theme.ts │ ├── tsconfig.json │ └── types │ │ ├── files.d.ts │ │ └── styled.d.ts └── web │ ├── .env │ ├── README.md │ ├── config │ ├── env.js │ ├── getHttpsConfig.js │ ├── modules.js │ ├── paths.js │ ├── webpack.config.js │ ├── webpack │ │ └── persistentCache │ │ │ └── createEnvironmentHash.js │ └── webpackDevServer.config.js │ ├── package.json │ ├── public │ ├── favicon.ico │ ├── index.html │ ├── manifest.json │ └── robots.txt │ ├── scripts │ ├── build.js │ ├── start.js │ └── test.js │ ├── src │ ├── App.tsx │ ├── assets │ │ └── logo.png │ ├── index.tsx │ ├── react-app-env.d.ts │ ├── store │ │ └── RootStore.tsx │ ├── style.css │ └── views │ │ ├── Chat │ │ ├── components │ │ │ └── ChatBar.tsx │ │ └── index.tsx │ │ ├── Login │ │ └── index.tsx │ │ └── Settings │ │ ├── components │ │ └── AvatarColorPicker.tsx │ │ └── index.tsx │ └── tsconfig.json ├── tsconfig.json └── yarn.lock /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es6: true, 5 | }, 6 | extends: ['prettier', 'plugin:@figma/figma-plugins/recommended'], 7 | parser: '@typescript-eslint/parser', 8 | parserOptions: { 9 | project: ['tsconfig.json', './packages/*/tsconfig.json'], 10 | sourceType: 'module', 11 | }, 12 | plugins: [ 13 | 'prefer-arrow', 14 | 'react', 15 | '@typescript-eslint', 16 | 'import', 17 | 'unused-imports', 18 | ], 19 | rules: { 20 | 'unused-imports/no-unused-imports': 'error', 21 | 'react-hooks/exhaustive-deps': 0, 22 | 'import/order': [ 23 | 'error', 24 | { 25 | pathGroups: [ 26 | { 27 | pattern: '^[a-zA-Z]', 28 | group: 'builtin', 29 | position: 'after', 30 | }, 31 | { 32 | pattern: '@fc/shared/**', 33 | group: 'external', 34 | position: 'after', 35 | }, 36 | { 37 | pattern: '@/**', 38 | group: 'internal', 39 | position: 'after', 40 | }, 41 | ], 42 | pathGroupsExcludedImportTypes: ['builtin'], 43 | groups: [['builtin', 'external'], 'internal', 'parent', 'sibling'], 44 | 'newlines-between': 'always', 45 | alphabetize: { 46 | order: 'asc', 47 | }, 48 | }, 49 | ], 50 | '@typescript-eslint/adjacent-overload-signatures': 'error', 51 | '@typescript-eslint/array-type': [ 52 | 'error', 53 | { 54 | default: 'array', 55 | }, 56 | ], 57 | '@typescript-eslint/ban-types': [ 58 | 'error', 59 | { 60 | types: { 61 | Object: { 62 | message: 'Avoid using the `Object` type. Did you mean `object`?', 63 | }, 64 | Function: { 65 | message: 66 | 'Avoid using the `Function` type. Prefer a specific function type, like `() => void`.', 67 | }, 68 | Boolean: { 69 | message: 'Avoid using the `Boolean` type. Did you mean `boolean`?', 70 | }, 71 | Number: { 72 | message: 'Avoid using the `Number` type. Did you mean `number`?', 73 | }, 74 | String: { 75 | message: 'Avoid using the `String` type. Did you mean `string`?', 76 | }, 77 | Symbol: { 78 | message: 'Avoid using the `Symbol` type. Did you mean `symbol`?', 79 | }, 80 | }, 81 | }, 82 | ], 83 | '@typescript-eslint/consistent-type-assertions': 'error', 84 | '@typescript-eslint/dot-notation': 'error', 85 | '@typescript-eslint/indent': 'off', 86 | '@typescript-eslint/member-delimiter-style': [ 87 | 'off', 88 | { 89 | multiline: { 90 | delimiter: 'none', 91 | requireLast: true, 92 | }, 93 | singleline: { 94 | delimiter: 'semi', 95 | requireLast: false, 96 | }, 97 | }, 98 | ], 99 | '@typescript-eslint/no-empty-function': 'off', 100 | '@typescript-eslint/no-empty-interface': 'error', 101 | '@typescript-eslint/no-explicit-any': 'off', 102 | '@typescript-eslint/no-misused-new': 'error', 103 | '@typescript-eslint/no-namespace': 'error', 104 | '@typescript-eslint/no-parameter-properties': 'off', 105 | '@typescript-eslint/no-this-alias': 'error', 106 | '@typescript-eslint/no-unused-expressions': 'error', 107 | '@typescript-eslint/no-use-before-define': 'off', 108 | '@typescript-eslint/no-var-requires': 'error', 109 | '@typescript-eslint/prefer-for-of': 'error', 110 | '@typescript-eslint/prefer-function-type': 'error', 111 | '@typescript-eslint/prefer-namespace-keyword': 'error', 112 | '@typescript-eslint/quotes': 'off', 113 | '@typescript-eslint/semi': ['off', null], 114 | '@typescript-eslint/triple-slash-reference': [ 115 | 'error', 116 | { 117 | path: 'always', 118 | types: 'prefer-import', 119 | lib: 'always', 120 | }, 121 | ], 122 | '@typescript-eslint/type-annotation-spacing': 'off', 123 | '@typescript-eslint/unified-signatures': 'error', 124 | 'arrow-parens': ['off', 'always'], 125 | 'brace-style': ['off', 'off'], 126 | 'comma-dangle': 'off', 127 | complexity: 'off', 128 | 'constructor-super': 'error', 129 | 'eol-last': 'off', 130 | eqeqeq: ['error', 'smart'], 131 | 'guard-for-in': 'error', 132 | 'id-blacklist': [ 133 | 'error', 134 | 'any', 135 | 'Number', 136 | 'number', 137 | 'String', 138 | 'string', 139 | 'Boolean', 140 | 'boolean', 141 | 'Undefined', 142 | 'undefined', 143 | ], 144 | 'id-match': 'error', 145 | 'linebreak-style': 'off', 146 | 'max-classes-per-file': ['error', 1], 147 | 'max-len': 'off', 148 | 'new-parens': 'off', 149 | 'newline-per-chained-call': 'off', 150 | 'no-bitwise': 'error', 151 | 'no-caller': 'error', 152 | 'no-cond-assign': 'error', 153 | 'no-console': 'off', 154 | 'no-debugger': 'error', 155 | 'no-duplicate-case': 'error', 156 | 'no-duplicate-imports': 'error', 157 | 'no-empty': 'off', 158 | 'no-eval': 'error', 159 | 'no-extra-bind': 'error', 160 | 'no-extra-semi': 'off', 161 | 'no-fallthrough': 'off', 162 | 'no-invalid-this': 'off', 163 | 'no-irregular-whitespace': 'off', 164 | 'no-multiple-empty-lines': 'off', 165 | 'no-new-func': 'error', 166 | 'no-new-wrappers': 'error', 167 | 'no-redeclare': 'error', 168 | 'no-return-await': 'error', 169 | 'no-sequences': 'error', 170 | 'no-sparse-arrays': 'error', 171 | 'no-template-curly-in-string': 'error', 172 | 'no-throw-literal': 'error', 173 | 'no-trailing-spaces': 'off', 174 | 'no-undef-init': 'error', 175 | 'no-underscore-dangle': 'error', 176 | 'no-unsafe-finally': 'error', 177 | 'no-unused-labels': 'error', 178 | 'no-var': 'error', 179 | 'object-shorthand': 'error', 180 | 'one-var': ['error', 'never'], 181 | 'prefer-arrow/prefer-arrow-functions': 'error', 182 | 'prefer-const': 'error', 183 | 'prefer-object-spread': 'error', 184 | 'quote-props': 'off', 185 | radix: 'error', 186 | 'react/jsx-curly-spacing': 'off', 187 | 'react/jsx-equals-spacing': 'off', 188 | 'react/jsx-tag-spacing': [ 189 | 'off', 190 | { 191 | afterOpening: 'allow', 192 | closingSlash: 'allow', 193 | }, 194 | ], 195 | 'react/jsx-wrap-multilines': 'off', 196 | 'space-before-function-paren': 'off', 197 | 'space-in-parens': ['off', 'never'], 198 | 'spaced-comment': [ 199 | 'error', 200 | 'always', 201 | { 202 | markers: ['/'], 203 | }, 204 | ], 205 | 'use-isnan': 'error', 206 | 'valid-typeof': 'off', 207 | }, 208 | }; 209 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build Figma-Chat 2 | 3 | on: [push] 4 | 5 | env: 6 | YARN_ENABLE_IMMUTABLE_INSTALLS: false 7 | YARN_CHECKSUM_BEHAVIOR: update 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v3 15 | - uses: borales/actions-yarn@v3.0.0 16 | with: 17 | cmd: install 18 | 19 | - uses: borales/actions-yarn@v3.0.0 20 | with: 21 | cmd: lint 22 | 23 | - uses: borales/actions-yarn@v3.0.0 24 | with: 25 | cmd: build:plugin 26 | 27 | - uses: borales/actions-yarn@v3.0.0 28 | with: 29 | cmd: build:server 30 | 31 | - uses: borales/actions-yarn@v3.0.0 32 | with: 33 | cmd: build:web 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | Figma Chat/ 2 | node_modules 3 | *.zip 4 | dist 5 | build 6 | release 7 | .DS_Store 8 | 9 | .pnp.* 10 | .yarn/* 11 | !.yarn/patches 12 | !.yarn/plugins 13 | !.yarn/releases 14 | !.yarn/sdks 15 | !.yarn/versions -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true 3 | } -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "arcanis.vscode-zipfs", 4 | "esbenp.prettier-vscode", 5 | "dbaeumer.vscode-eslint" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "search.exclude": { 3 | "**/.yarn": true, 4 | "**/.pnp.*": true 5 | }, 6 | "eslint.nodePath": ".yarn/sdks", 7 | "typescript.tsdk": ".yarn/sdks/typescript/lib", 8 | "prettier.prettierPath": ".yarn/sdks/prettier/index.js", 9 | "typescript.enablePromptUseWorkspaceTsdk": true 10 | } 11 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nmHoistingLimits: none 2 | 3 | nodeLinker: node-modules 4 | 5 | yarnPath: .yarn/releases/yarn-3.2.1.cjs 6 | 7 | checksumBehavior: 'update' 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Philip Stapelfeldt 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Figma Chat  2 | 3 | 4 | 5 | ## Monorepo 6 | 7 | - [Plugin](./packages/plugin/README.md) 8 | - [Web-Client](./packages/web/README.md) 9 | - [Server](./packages/server/README.md) 10 | 11 | ## Development 12 | 13 | You can simple start or build all packages 14 | 15 | ```bash 16 | # start 17 | yarn start:server && yarn start:plugin && yarn start:web 18 | # build 19 | yarn build:server && yarn build:plugin && yarn build:web 20 | ``` 21 | 22 | ## Description 23 | 24 | This is the monorepo of Figma-Chat. Inside the `packages` folder you will find all projects. 25 | 26 | ### Sound 27 | 28 | Thanks to [https://notificationsounds.com/notification-sounds/when-604](https://notificationsounds.com/notification-sounds/when-604) 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "figma-chat", 3 | "version": "5.0.1", 4 | "description": "", 5 | "private": true, 6 | "scripts": { 7 | "build": "yarn build:plugin && yarn build:web && yarn build:server", 8 | "start:plugin": "yarn workspace plugin start", 9 | "start:web": "yarn workspace web start", 10 | "start:server": "yarn workspace server dev", 11 | "build:plugin": "yarn workspace plugin build", 12 | "build:web": "yarn workspace web build", 13 | "build:server": "yarn workspace server build", 14 | "lint": "yarn workspace plugin lint && yarn workspace web lint && yarn workspace shared lint && yarn workspace server lint", 15 | "version": "npx conventional-changelog-cli -p karma -i CHANGELOG.md -s -r 0 && git add ." 16 | }, 17 | "author": "Philip Stapelfeldt ", 18 | "repository": { 19 | "type": "git", 20 | "url": "https://github.com/ph1p/figma-chat.git" 21 | }, 22 | "bugs": { 23 | "url": "https://github.com/ph1p/figma-chat/issues" 24 | }, 25 | "prettier": { 26 | "singleQuote": true 27 | }, 28 | "license": "ISC", 29 | "workspaces": [ 30 | "packages/*" 31 | ], 32 | "packageManager": "yarn@3.2.1" 33 | } 34 | -------------------------------------------------------------------------------- /packages/plugin/README.md: -------------------------------------------------------------------------------- 1 | # Figma Chat Plugin 2 | 3 | A plugin to chat in figma files. Fully **encrypted**! (https://github.com/sehrope/node-simple-encryptor) 4 | 5 | ### Installation 6 | 7 | There is no special installation process. Just install the plugin in figma. 8 | You can find it [**here**](https://www.figma.com/c/plugin/742073255743594050/Figma-Chat) 9 | 10 | ### What does it look like? 11 | 12 |  13 | (And yes, I have chatted with myself) 14 | 15 | ### Encrypted? No login? 16 | 17 | Yes. When opening the plugin a **room** name and a **secret key** are randomly generated once 18 | and stored inside the `figma.root`. All editors within the file can access this attribute. 19 | 20 | ```javascript 21 | figma.root.setPluginData('roomName', ''); 22 | ``` 23 | 24 | All messages are en- and decrypted with the stored secret key and send to the server. 25 | 26 | ### Development 27 | 28 | ```bash 29 | git clone git@github.com:ph1p/figma-chat.git 30 | cd figma-chat 31 | yarn install 32 | ``` 33 | 34 | ```bash 35 | yarn build:plugin 36 | ``` 37 | 38 | or 39 | 40 | ```bash 41 | yarn start:plugin 42 | ``` 43 | 44 | - Open figma 45 | - Go to **Plugins** 46 | - Click the "+" next to **Development** 47 | - Choose the manifest.json inside `packages/plugin/Figma Chat` 48 | - Ready to develop 49 | 50 | ### Bugs / Features 51 | 52 | Feel free to open a feature request or a bug report: https://github.com/ph1p/figma-chat/issues 53 | 54 | ### Sound 55 | 56 | Thanks to [https://notificationsounds.com/notification-sounds/when-604](https://notificationsounds.com/notification-sounds/when-604) 57 | -------------------------------------------------------------------------------- /packages/plugin/assets/header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ph1p/figma-chat/a47166e40d558d80df68da0a536584debfffc134/packages/plugin/assets/header.png -------------------------------------------------------------------------------- /packages/plugin/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ph1p/figma-chat/a47166e40d558d80df68da0a536584debfffc134/packages/plugin/assets/logo.png -------------------------------------------------------------------------------- /packages/plugin/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "plugin", 3 | "version": "5.0.1", 4 | "description": "", 5 | "main": "code.js", 6 | "scripts": { 7 | "build": "NODE_ENV=production webpack --mode=production && rm ../../release/Figma\\ Chat/ui.js ../../release/Figma\\ Chat/ui.js.LICENSE.txt", 8 | "lint": "eslint --ext .tsx,.ts,.json,.js src/ --fix", 9 | "start": "DEBUG=* webpack --watch" 10 | }, 11 | "author": "Philip Stapelfeldt ", 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/ph1p/figma-chat.git" 15 | }, 16 | "bugs": { 17 | "url": "https://github.com/ph1p/figma-chat/issues" 18 | }, 19 | "prettier": { 20 | "singleQuote": true 21 | }, 22 | "license": "ISC", 23 | "dependencies": { 24 | "@fc/shared": "link:../shared/src", 25 | "@giphy/js-fetch-api": "^5.4.0", 26 | "@giphy/react-components": "^9.3.0", 27 | "buffer": "^6.0.3", 28 | "crypto-browserify": "^3.12.0", 29 | "mobx": "^6.12.0", 30 | "mobx-react-lite": "^4.0.5", 31 | "mobx-sync": "^3.0.0", 32 | "polished": "^4.3.1", 33 | "react": "^18.2.0", 34 | "react-dom": "^18.2.0", 35 | "react-router-dom": "^6.22.2", 36 | "simple-encryptor": "^4.0.0", 37 | "socket.io-client": "^4.7.4", 38 | "stream-browserify": "^3.0.0", 39 | "styled-components": "^6.1.8", 40 | "tsconfig-paths-webpack-plugin": "^4.1.0", 41 | "uniqid": "^5.4.0" 42 | }, 43 | "devDependencies": { 44 | "@figma/eslint-plugin-figma-plugins": "^0.14.0", 45 | "@figma/plugin-typings": "^1.88.0", 46 | "@types/node": "^20.11.24", 47 | "@types/react": "^18.2.61", 48 | "@types/react-dom": "^18.2.19", 49 | "@types/styled-components": "^5.1.34", 50 | "@typescript-eslint/eslint-plugin": "^7.1.0", 51 | "@typescript-eslint/parser": "^7.1.0", 52 | "create-file-webpack": "^1.0.2", 53 | "css-loader": "^6.10.0", 54 | "esbuild-loader": "^4.0.3", 55 | "eslint": "^8.57.0", 56 | "eslint-config-prettier": "^9.1.0", 57 | "eslint-import-resolver-node": "^0.3.9", 58 | "eslint-plugin-import": "^2.29.1", 59 | "eslint-plugin-prefer-arrow": "^1.2.3", 60 | "eslint-plugin-prettier": "^5.1.3", 61 | "eslint-plugin-react": "^7.34.0", 62 | "eslint-plugin-unused-imports": "^3.1.0", 63 | "html-webpack-plugin": "^5.6.0", 64 | "prettier": "^3.2.5", 65 | "style-loader": "^3.3.4", 66 | "terser-webpack-plugin": "v5.3.10", 67 | "tsconfig-paths-webpack-plugin": "^4.1.0", 68 | "typescript": "^5.3.3", 69 | "url-loader": "^4.1.1", 70 | "webpack": "^5.90.3", 71 | "webpack-bundle-analyzer": "^4.10.1", 72 | "webpack-cli": "^5.1.4" 73 | }, 74 | "figmaPlugin": { 75 | "documentAccess": "dynamic-page", 76 | "name": "Figma Chat", 77 | "id": "742073255743594050", 78 | "api": "1.0.0", 79 | "main": "code.js", 80 | "ui": "ui.html", 81 | "enableProposedApi": false, 82 | "editorType": [ 83 | "figma", 84 | "figjam", 85 | "dev" 86 | ], 87 | "capabilities": [ 88 | "inspect" 89 | ], 90 | "networkAccess": { 91 | "allowedDomains": [ 92 | "*" 93 | ], 94 | "reasoning": "Any domain is allowed, because you can specify own Chat-Servers.", 95 | "devAllowedDomains": [] 96 | }, 97 | "permissions": [ 98 | "currentuser" 99 | ], 100 | "menu": [ 101 | { 102 | "name": "Open Chat", 103 | "command": "open" 104 | }, 105 | { 106 | "separator": true 107 | }, 108 | { 109 | "name": "Reset Chat", 110 | "command": "reset" 111 | } 112 | ], 113 | "relaunchButtons": [ 114 | { 115 | "command": "open", 116 | "name": "Open Figma-Chat" 117 | }, 118 | { 119 | "command": "relaunch", 120 | "name": "Send selection to chat", 121 | "multipleSelection": true 122 | } 123 | ] 124 | }, 125 | "resolutions": { 126 | "bn.js": "5.2.0" 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /packages/plugin/src/Ui.tsx: -------------------------------------------------------------------------------- 1 | import { toJS } from 'mobx'; 2 | import { observer } from 'mobx-react-lite'; 3 | import React, { useEffect, useState } from 'react'; 4 | import { createRoot } from 'react-dom/client'; 5 | import { MemoryRouter as Router, Route, Routes } from 'react-router-dom'; 6 | import io, { Socket } from 'socket.io-client'; 7 | import styled, { createGlobalStyle, ThemeProvider } from 'styled-components'; 8 | 9 | import Notifications from '@fc/shared/components/Notifications'; 10 | import UserListView from '@fc/shared/components/UserList'; 11 | import { SocketProvider } from '@fc/shared/utils/SocketProvider'; 12 | import { ConnectionEnum } from '@fc/shared/utils/interfaces'; 13 | 14 | import { Resizer } from './components/Resizer'; 15 | import EventEmitter from './shared/EventEmitter'; 16 | import { getStoreFromMain, StoreProvider, trunk, useStore } from './store'; 17 | import ChatView from './views/Chat'; 18 | import './style.css'; 19 | import SettingsView from './views/Settings'; 20 | 21 | const GlobalStyle = createGlobalStyle` 22 | body { 23 | background-color: ${(p) => p.theme.backgroundColor}; 24 | } 25 | 26 | ::-webkit-scrollbar-thumb { 27 | background-color: ${(p) => p.theme.scrollbarColor}; 28 | } 29 | `; 30 | 31 | const AppWrapper = styled.div` 32 | overflow: hidden; 33 | `; 34 | 35 | const App = observer(() => { 36 | const store = useStore(); 37 | const [socket, setSocket] = useState(null); 38 | 39 | const onFocus = () => { 40 | EventEmitter.emit('focus', false); 41 | store.setIsFocused(false); 42 | }; 43 | 44 | const onFocusOut = () => { 45 | EventEmitter.emit('focus', false); 46 | store.setIsFocused(false); 47 | }; 48 | 49 | const initSocketConnection = (url: string) => { 50 | store.setStatus(ConnectionEnum.NONE); 51 | 52 | if (socket) { 53 | socket.io.off('error'); 54 | socket.io.off('reconnect_error'); 55 | socket.off('chat message'); 56 | socket.off('join leave message'); 57 | socket.off('online'); 58 | socket.disconnect(); 59 | } 60 | 61 | setSocket( 62 | io(url, { 63 | reconnectionAttempts: 5, 64 | forceNew: true, 65 | transports: ['websocket'], 66 | }) 67 | ); 68 | }; 69 | 70 | useEffect(() => { 71 | EventEmitter.ask('current-user').then((user) => store.setCurrentUser(user)); 72 | EventEmitter.ask('figma-editor-type').then((figmaEditorType) => store.setFigmaEditorType(figmaEditorType)); 73 | 74 | if (socket && store.status === ConnectionEnum.NONE) { 75 | socket.on('connect', () => { 76 | EventEmitter.ask('root-data').then((rootData: any) => { 77 | socket.io.off('error'); 78 | socket.io.off('reconnect_error'); 79 | socket.off('chat message'); 80 | socket.off('join leave message'); 81 | socket.off('online'); 82 | 83 | const { 84 | roomName: dataRoomName = '', 85 | secret: dataSecret = '', 86 | history: messages = [], 87 | selection = { 88 | page: '', 89 | nodes: [], 90 | }, 91 | currentUser, 92 | } = rootData; 93 | 94 | store.setCurrentUser(currentUser); 95 | store.setSecret(dataSecret); 96 | store.setRoomName(dataRoomName); 97 | store.setMessages(messages); 98 | store.setSelection(selection); 99 | 100 | // socket listener 101 | socket.io.on('error', () => store.setStatus(ConnectionEnum.ERROR)); 102 | 103 | socket.io.on('reconnect_error', () => 104 | store.setStatus(ConnectionEnum.ERROR) 105 | ); 106 | 107 | socket.on('chat message', (data) => 108 | store.addMessage(data, socket, false) 109 | ); 110 | 111 | socket.on('join leave message', (data) => { 112 | const username = data.user.name || 'Anon'; 113 | let message = 'joins the conversation'; 114 | 115 | if (data.type === 'LEAVE') { 116 | message = 'leaves the conversation'; 117 | } 118 | store.addNotification(`${username} ${message}`); 119 | }); 120 | 121 | socket.on('online', (data) => { 122 | store.setOnline(data); 123 | }); 124 | 125 | socket.on('remove message', (messageId) => 126 | store.removeMessage(messageId) 127 | ); 128 | 129 | store.setStatus(ConnectionEnum.CONNECTED); 130 | 131 | socket.emit('set user', toJS(store.currentUser)); 132 | socket.emit('join room', { 133 | settings: toJS(store.currentUser), 134 | room: dataRoomName, 135 | }); 136 | 137 | EventEmitter.emit('ask-for-relaunch-message'); 138 | }); 139 | }); 140 | } 141 | 142 | return () => { 143 | if (socket) { 144 | socket.off('connect'); 145 | socket.io.off('error'); 146 | socket.io.off('reconnect_error'); 147 | socket.off('chat message'); 148 | socket.off('remove message'); 149 | socket.off('join leave message'); 150 | socket.off('online'); 151 | socket.disconnect(); 152 | } 153 | }; 154 | }, [socket]); 155 | 156 | useEffect(() => { 157 | if (store.settings.url) { 158 | initSocketConnection(store.settings.url); 159 | } 160 | // check focus 161 | window.addEventListener('focus', onFocus); 162 | window.addEventListener('blur', onFocusOut); 163 | 164 | return () => { 165 | window.removeEventListener('focus', onFocus); 166 | window.removeEventListener('blur', onFocusOut); 167 | }; 168 | }, [store.settings.url]); 169 | 170 | return ( 171 | 172 | 173 | {store.figmaEditorType !== 'dev' && } 174 | 175 | 176 | 177 | 178 | 182 | 183 | 187 | } 188 | /> 189 | } /> 190 | } /> 191 | 192 | 193 | 194 | 195 | 196 | ); 197 | }); 198 | 199 | const root = createRoot(document.getElementById('app')); 200 | 201 | getStoreFromMain().then((store) => 202 | trunk.init(store).then(() => 203 | root.render( 204 | 205 | 206 | 207 | ) 208 | ) 209 | ); 210 | -------------------------------------------------------------------------------- /packages/plugin/src/components/Resizer.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | import EventEmitter from '../shared/EventEmitter'; 5 | 6 | export const Resizer = () => { 7 | const [dragStart, _setDragStart] = useState(false); 8 | const dragStartRef = useRef(dragStart); 9 | const setDragStart = (x) => { 10 | dragStartRef.current = x; 11 | _setDragStart(x); 12 | }; 13 | 14 | const [sizeAndPosition, _setSizeAndPosition] = useState({ 15 | x: 0, 16 | y: 0, 17 | width: 0, 18 | height: 0, 19 | }); 20 | const sizeAndPositionRef = useRef(sizeAndPosition); 21 | const setSizeAndPosition = (x) => { 22 | sizeAndPositionRef.current = x; 23 | _setSizeAndPosition(x); 24 | }; 25 | 26 | const onMouseDown = (e) => { 27 | e.preventDefault(); 28 | e.stopPropagation(); 29 | setDragStart(true); 30 | 31 | setSizeAndPosition({ 32 | x: e.clientX, 33 | y: e.clientY, 34 | width: document.body.clientWidth, 35 | height: document.body.clientHeight, 36 | }); 37 | }; 38 | 39 | const onMouseUp = (e) => { 40 | e.preventDefault(); 41 | e.stopPropagation(); 42 | setDragStart(false); 43 | }; 44 | 45 | const onMouseMove = (e) => { 46 | if (dragStartRef.current) { 47 | EventEmitter.emit('resize', { 48 | height: 49 | sizeAndPositionRef.current.height + 50 | (e.clientY - sizeAndPositionRef.current.y), 51 | width: 52 | sizeAndPositionRef.current.width + 53 | (e.clientX - sizeAndPositionRef.current.x), 54 | }); 55 | } 56 | }; 57 | 58 | useEffect(() => { 59 | window.addEventListener('mousemove', onMouseMove); 60 | window.addEventListener('mouseup', onMouseUp); 61 | return () => { 62 | window.removeEventListener('mousemove', onMouseMove); 63 | window.removeEventListener('mouseup', onMouseUp); 64 | }; 65 | }, []); 66 | 67 | return ; 68 | }; 69 | 70 | const ResizerElement = styled.div` 71 | position: fixed; 72 | right: 0; 73 | bottom: 1px; 74 | width: 15px; 75 | height: 15px; 76 | z-index: 100; 77 | cursor: nwse-resize; 78 | &::after { 79 | content: ''; 80 | transform: rotate(-45deg); 81 | transform-origin: 50%; 82 | position: absolute; 83 | top: 6px; 84 | left: 2px; 85 | width: 10px; 86 | height: 1px; 87 | background-color: ${(p) => p.theme.secondaryFontColor}; 88 | box-shadow: 0px 3px 0px 0px ${(p) => p.theme.secondaryFontColor}; 89 | } 90 | `; 91 | -------------------------------------------------------------------------------- /packages/plugin/src/main/index.ts: -------------------------------------------------------------------------------- 1 | import './store'; 2 | import { generateString } from '@fc/shared/utils/helpers'; 3 | 4 | import EventEmitter from '../shared/EventEmitter'; 5 | 6 | let isFocused = true; 7 | let sendNotifications = false; 8 | let triggerSelectionEvent = true; 9 | 10 | const isRelaunch = figma.command === 'relaunch'; 11 | const isReset = figma.command === 'reset'; 12 | 13 | const currentUser = { 14 | ...figma.currentUser, 15 | }; 16 | 17 | delete currentUser.sessionId; 18 | 19 | if (isReset) { 20 | figma.root.setPluginData('history', '[]'); 21 | figma.clientStorage.setAsync('figma-chat', '{}'); 22 | figma.root.setPluginData('roomName', generateString(20)); 23 | figma.root.setPluginData('secret', generateString(20)); 24 | 25 | figma.notify('Figma Chat successfully reset!'); 26 | figma.closePlugin(); 27 | } else { 28 | figma.showUI(__html__, { 29 | width: 333, 30 | height: 490, 31 | // visible: !isRelaunch 32 | }); 33 | 34 | figma.root.setRelaunchData({ 35 | open: '', 36 | }); 37 | 38 | const main = async () => { 39 | // random user id for current user 40 | let history = figma.root.getPluginData('history'); 41 | let roomName = figma.root.getPluginData('roomName'); 42 | let secret = figma.root.getPluginData('secret'); 43 | 44 | if (!history) { 45 | history = '[]'; 46 | figma.root.setPluginData('history', history); 47 | } 48 | 49 | // Parse History 50 | try { 51 | history = typeof history === 'string' ? JSON.parse(history) : []; 52 | } catch { 53 | history = JSON.parse('[]'); 54 | } 55 | 56 | if (!roomName) { 57 | const randomRoomName = generateString(20); 58 | figma.root.setPluginData('roomName', randomRoomName); 59 | roomName = randomRoomName; 60 | } 61 | 62 | if (!secret) { 63 | secret = generateString(20); 64 | figma.root.setPluginData('secret', secret); 65 | } 66 | 67 | return { 68 | roomName, 69 | secret, 70 | history, 71 | }; 72 | }; 73 | 74 | const getSelectionIds = () => figma.currentPage.selection.map((n) => n.id); 75 | 76 | const sendSelection = () => { 77 | EventEmitter.emit('selection', { 78 | page: { 79 | id: figma.currentPage.id, 80 | name: figma.currentPage.name, 81 | }, 82 | nodes: getSelectionIds(), 83 | }); 84 | }; 85 | 86 | let alreadyAskedForRelaunchMessage = false; 87 | 88 | const isValidShape = (node) => 89 | node.type === 'RECTANGLE' || 90 | node.type === 'ELLIPSE' || 91 | node.type === 'GROUP' || 92 | node.type === 'TEXT' || 93 | node.type === 'VECTOR' || 94 | node.type === 'FRAME' || 95 | node.type === 'COMPONENT' || 96 | node.type === 'INSTANCE' || 97 | node.type === 'POLYGON'; 98 | 99 | const goToPage = async (id) => { 100 | if (await figma.getNodeByIdAsync(id)) { 101 | await figma.setCurrentPageAsync(await figma.getNodeByIdAsync(id) as PageNode); 102 | } 103 | }; 104 | 105 | let previousSelection = figma.currentPage.selection || []; 106 | 107 | EventEmitter.on('resize', ({ width, height }) => { 108 | if (width <= 333 && height >= 490) { 109 | figma.ui.resize(width, 490); 110 | } else if (width >= 333 && height <= 490) { 111 | figma.ui.resize(333, height); 112 | } else if (width <= 333 && height <= 490) { 113 | figma.ui.resize(width, height); 114 | } 115 | }); 116 | 117 | EventEmitter.on('remove message', (messageId: string) => { 118 | const messageHistory = JSON.parse( 119 | figma.root.getPluginData('history') || '[]' 120 | ); 121 | 122 | figma.root.setPluginData( 123 | 'history', 124 | JSON.stringify( 125 | messageHistory.filter((message) => 126 | message?.id ? message?.id !== messageId : true 127 | ) 128 | ) 129 | ); 130 | }); 131 | 132 | EventEmitter.on('clear-chat-history', (_, send) => { 133 | figma.root.setPluginData('history', '[]'); 134 | 135 | send('history', JSON.parse('[]')); 136 | }); 137 | 138 | EventEmitter.on('add-message-to-history', (payload) => { 139 | const messageHistory = JSON.parse( 140 | figma.root.getPluginData('history') || '[]' 141 | ); 142 | 143 | figma.root.setPluginData( 144 | 'history', 145 | JSON.stringify(messageHistory.concat(payload)) 146 | ); 147 | }); 148 | 149 | EventEmitter.answer( 150 | 'get-history', 151 | JSON.parse(figma.root.getPluginData('history') || '[]') 152 | ); 153 | 154 | EventEmitter.on('notify', (payload) => { 155 | figma.notify(payload); 156 | }); 157 | 158 | EventEmitter.on('notification', (payload) => { 159 | if (sendNotifications) { 160 | figma.notify(payload); 161 | } 162 | }); 163 | 164 | EventEmitter.answer('current-user', async () => currentUser); 165 | EventEmitter.answer('figma-editor-type', async () => figma.editorType.toString()); 166 | 167 | EventEmitter.answer('root-data', async () => { 168 | const { roomName, secret, history } = await main(); 169 | 170 | return { 171 | roomName, 172 | secret, 173 | history, 174 | currentUser, 175 | selection: getSelectionIds(), 176 | }; 177 | }); 178 | 179 | EventEmitter.on('focus', (payload) => { 180 | isFocused = payload; 181 | 182 | if (!isFocused) { 183 | sendNotifications = true; 184 | } 185 | }); 186 | 187 | EventEmitter.on('focus-nodes', (payload) => { 188 | let selectedNodes = []; 189 | triggerSelectionEvent = false; 190 | 191 | // fallback for ids 192 | if (payload.ids) { 193 | selectedNodes = payload.ids; 194 | } else { 195 | goToPage(payload?.page?.id); 196 | selectedNodes = payload.nodes; 197 | } 198 | 199 | const nodes = figma.currentPage.findAll( 200 | (n) => selectedNodes.indexOf(n.id) !== -1 201 | ); 202 | 203 | figma.currentPage.selection = nodes; 204 | figma.viewport.scrollAndZoomIntoView(nodes); 205 | 206 | setTimeout(() => (triggerSelectionEvent = true)); 207 | }); 208 | 209 | EventEmitter.on('ask-for-relaunch-message', (_, emit) => { 210 | if (isRelaunch && !alreadyAskedForRelaunchMessage) { 211 | alreadyAskedForRelaunchMessage = true; 212 | emit('relaunch-message', { 213 | selection: { 214 | page: { 215 | id: figma.currentPage.id, 216 | name: figma.currentPage.name, 217 | }, 218 | nodes: getSelectionIds(), 219 | }, 220 | }); 221 | } 222 | }); 223 | 224 | EventEmitter.on('cancel', () => {}); 225 | 226 | // events 227 | figma.on('selectionchange', async () => { 228 | if (figma.currentPage.selection.length > 0) { 229 | for (const node of figma.currentPage.selection) { 230 | if (node.setRelaunchData && isValidShape(node)) { 231 | node.setRelaunchData({ 232 | relaunch: '', 233 | }); 234 | } 235 | } 236 | previousSelection = figma.currentPage.selection; 237 | } else { 238 | if (previousSelection.length > 0) { 239 | // tidy up 🧹 240 | for (const node of previousSelection) { 241 | if ( 242 | node.setRelaunchData && 243 | isValidShape(node) && 244 | await figma.getNodeByIdAsync(node.id) 245 | ) { 246 | node.setRelaunchData({}); 247 | } 248 | } 249 | } 250 | } 251 | if (triggerSelectionEvent) { 252 | sendSelection(); 253 | } 254 | }); 255 | } 256 | -------------------------------------------------------------------------------- /packages/plugin/src/main/store.ts: -------------------------------------------------------------------------------- 1 | import EventEmitter from '../shared/EventEmitter'; 2 | 3 | export const getState = async () => 4 | JSON.parse(await figma.clientStorage.getAsync('figma-chat')); 5 | 6 | EventEmitter.on('storage', async (key, send) => { 7 | try { 8 | send('storage', await figma.clientStorage.getAsync(key)); 9 | } catch { 10 | send('storage', '{}'); 11 | } 12 | }); 13 | 14 | EventEmitter.on('storage set item', ({ key, value }, send) => { 15 | figma.clientStorage.setAsync(key, value); 16 | 17 | send('storage set item', true); 18 | }); 19 | 20 | EventEmitter.on('storage get item', async (key, send) => { 21 | try { 22 | const store = await figma.clientStorage.getAsync(key); 23 | 24 | send('storage get item', store[key]); 25 | } catch { 26 | send('storage get item', false); 27 | } 28 | }); 29 | 30 | EventEmitter.on('storage remove item', async (key, send) => { 31 | try { 32 | await figma.clientStorage.setAsync(key, undefined); 33 | 34 | send('storage remove item', true); 35 | } catch { 36 | send('storage remove item', false); 37 | } 38 | }); 39 | -------------------------------------------------------------------------------- /packages/plugin/src/shared/EventEmitter.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * An structured way to handle renderer and main messages 3 | */ 4 | class EventEmitter { 5 | messageEvent = new Map(); 6 | emit: ( 7 | name: string, 8 | data?: 9 | | Record 10 | | number 11 | | string 12 | | Uint8Array 13 | | unknown[] 14 | | boolean 15 | ) => void; 16 | 17 | constructor() { 18 | // MAIN PROCESS 19 | try { 20 | this.emit = (name, data) => { 21 | figma.ui.postMessage({ 22 | name, 23 | data: data || null, 24 | }); 25 | }; 26 | 27 | figma.ui.onmessage = (event) => { 28 | if (this.messageEvent.has(event.name)) { 29 | this.messageEvent.get(event.name)(event.data, this.emit); 30 | } 31 | }; 32 | } catch { 33 | // we ignore the error, because it only says, that "figma" is undefined 34 | // RENDERER PROCESS 35 | onmessage = (event) => { 36 | if (this.messageEvent.has(event.data.pluginMessage.name)) { 37 | this.messageEvent.get(event.data.pluginMessage.name)( 38 | event.data.pluginMessage.data, 39 | this.emit 40 | ); 41 | } 42 | }; 43 | 44 | this.emit = (name = '', data = {}) => { 45 | parent.postMessage( 46 | { 47 | pluginMessage: { 48 | name, 49 | data: data || null, 50 | }, 51 | }, 52 | '*' 53 | ); 54 | }; 55 | } 56 | } 57 | 58 | /** 59 | * This method emits a message to main or renderer 60 | * @param name string 61 | * @param callback function 62 | */ 63 | on(name, callback) { 64 | this.messageEvent.set(name, callback); 65 | 66 | return () => this.remove(name); 67 | } 68 | 69 | /** 70 | * Listen to a message once 71 | * @param name 72 | * @param callback 73 | */ 74 | once(name, callback) { 75 | const remove = this.on(name, (data, emit) => { 76 | callback(data, emit); 77 | remove(); 78 | }); 79 | } 80 | 81 | /** 82 | * Ask for data 83 | * @param name 84 | */ 85 | ask(name, data = undefined) { 86 | this.emit(name, data); 87 | 88 | return new Promise((resolve) => this.once(name, resolve)); 89 | } 90 | 91 | /** 92 | * Answer data from "ask" 93 | * @param name 94 | * @param functionOrValue 95 | */ 96 | answer(name, functionOrValue) { 97 | this.on(name, (incomingData, emit) => { 98 | if (this.isAsyncFunction(functionOrValue)) { 99 | functionOrValue(incomingData).then((data) => emit(name, data)); 100 | } else if (typeof functionOrValue === 'function') { 101 | emit(name, functionOrValue(incomingData)); 102 | } else { 103 | emit(name, functionOrValue); 104 | } 105 | }); 106 | } 107 | 108 | /** 109 | * Remove and active listener 110 | * @param name 111 | */ 112 | remove(name) { 113 | if (this.messageEvent.has(name)) { 114 | this.messageEvent.delete(name); 115 | } 116 | } 117 | 118 | /** 119 | * This function checks if it is asynchronous or not 120 | * @param func 121 | */ 122 | isAsyncFunction(func) { 123 | func = func.toString().trim(); 124 | 125 | return ( 126 | func.match('__awaiter') || func.match('function*') || func.match('async') 127 | ); 128 | } 129 | } 130 | 131 | export default new EventEmitter(); 132 | -------------------------------------------------------------------------------- /packages/plugin/src/store/index.tsx: -------------------------------------------------------------------------------- 1 | import { makeAutoObservable, observable, toJS } from 'mobx'; 2 | import { AsyncTrunk, ignore } from 'mobx-sync'; 3 | import React, { createRef } from 'react'; 4 | import { createEncryptor } from 'simple-encryptor'; 5 | import { DefaultTheme } from 'styled-components'; 6 | 7 | import MessageSound from '@fc/shared/assets/sound.mp3'; 8 | import { DEFAULT_SERVER_URL } from '@fc/shared/utils/constants'; 9 | import { 10 | ConnectionEnum, 11 | CurrentUser, 12 | MessageData, 13 | StoreSettings, 14 | } from '@fc/shared/utils/interfaces'; 15 | import { darkTheme, lightTheme } from '@fc/shared/utils/theme'; 16 | 17 | import EventEmitter from '../shared/EventEmitter'; 18 | 19 | export class RootStore { 20 | constructor() { 21 | makeAutoObservable(this); 22 | } 23 | 24 | @ignore 25 | get encryptor() { 26 | return createEncryptor(this.secret); 27 | } 28 | 29 | @ignore 30 | status = ConnectionEnum.NONE; 31 | 32 | @ignore 33 | online = []; 34 | 35 | messages: MessageData[] = []; 36 | 37 | @ignore 38 | messagesRef = createRef(); 39 | 40 | @ignore 41 | secret = ''; 42 | 43 | @ignore 44 | roomName = ''; 45 | 46 | @ignore 47 | autoScrollDisabled = false; 48 | 49 | @ignore 50 | selection = undefined; 51 | 52 | @ignore 53 | figmaEditorType = ''; 54 | 55 | @ignore 56 | currentUser: CurrentUser = { 57 | id: '', 58 | name: '', 59 | sessionId: '', 60 | avatar: '', 61 | photoUrl: '', 62 | color: '#4F4F4F', 63 | }; 64 | 65 | setStatus(status) { 66 | this.status = status; 67 | } 68 | 69 | setCurrentUser(currentUser) { 70 | this.currentUser = { 71 | ...this.currentUser, 72 | ...currentUser, 73 | }; 74 | } 75 | 76 | setSecret(secret) { 77 | this.secret = secret; 78 | } 79 | setRoomName(roomName) { 80 | this.roomName = roomName; 81 | } 82 | setOnline(online) { 83 | this.online = online; 84 | } 85 | setMessages(messages) { 86 | this.messages = messages; 87 | } 88 | setMessagesRef(messagesRef) { 89 | this.messagesRef = messagesRef; 90 | } 91 | setAutoScrollDisabled(autoScrollDisabled) { 92 | this.autoScrollDisabled = autoScrollDisabled; 93 | } 94 | setSelection(selection) { 95 | this.selection = selection; 96 | } 97 | 98 | disableAutoScroll(disable) { 99 | this.autoScrollDisabled = disable; 100 | } 101 | 102 | @ignore 103 | get theme(): DefaultTheme { 104 | return this.settings.isDarkTheme ? darkTheme : lightTheme; 105 | } 106 | 107 | @ignore 108 | get selectionCount() { 109 | // fallback 110 | if (this.selection?.length) { 111 | return this.selection.length; 112 | } 113 | 114 | return this.selection?.nodes?.length || 0; 115 | } 116 | 117 | scrollToBottom() { 118 | if (!this.autoScrollDisabled) { 119 | const ref = toJS(this.messagesRef); 120 | // scroll to bottom 121 | if (ref?.current) { 122 | ref.current.scrollTop = ref.current.scrollHeight; 123 | } 124 | } 125 | } 126 | 127 | // --- 128 | @ignore 129 | isFocused = true; 130 | 131 | @observable.deep 132 | settings: Omit = { 133 | url: DEFAULT_SERVER_URL, 134 | enableNotificationTooltip: true, 135 | enableNotificationSound: true, 136 | isDarkTheme: false, 137 | }; 138 | 139 | @ignore 140 | notifications = []; 141 | 142 | setSetting(key: keyof StoreSettings, value: string | boolean) { 143 | this.settings = { 144 | ...this.settings, 145 | [key]: value, 146 | }; 147 | } 148 | 149 | setIsFocused(isFocused: boolean) { 150 | this.isFocused = isFocused; 151 | } 152 | 153 | setDarkTheme(isDarkTheme) { 154 | this.settings.isDarkTheme = isDarkTheme; 155 | } 156 | 157 | setFigmaEditorType(figmaEditorType) { 158 | this.figmaEditorType = figmaEditorType; 159 | } 160 | 161 | addNotification(text: string, type?: string) { 162 | this.notifications.push({ 163 | id: Math.random(), 164 | text, 165 | type, 166 | }); 167 | } 168 | 169 | deleteNotification(id: number) { 170 | this.notifications.splice( 171 | this.notifications.findIndex((n) => n.id === id), 172 | 1 173 | ); 174 | } 175 | 176 | clearChatHistory(cb: () => void) { 177 | if ( 178 | (window as any).confirm( 179 | 'Do you really want to delete the complete chat history? (This cannot be undone)' 180 | ) 181 | ) { 182 | this.messages = []; 183 | EventEmitter.emit('clear-chat-history'); 184 | cb(); 185 | this.addNotification('Chat history successfully deleted'); 186 | } 187 | } 188 | 189 | persistSettings(settings, isInit = false) { 190 | const oldUrl = this.settings.url; 191 | 192 | this.settings = { 193 | ...this.settings, 194 | ...settings, 195 | }; 196 | 197 | // set server URL 198 | if (!isInit && settings.url && settings.url !== oldUrl) { 199 | this.addNotification('Updated server-URL'); 200 | } 201 | } 202 | 203 | persistCurrentUser(currentUser, socket?) { 204 | this.setCurrentUser(currentUser); 205 | 206 | if (socket && socket.connected) { 207 | // set user data on server 208 | socket.emit('set user', toJS(this.currentUser)); 209 | } 210 | } 211 | 212 | removeMessage(messageId) { 213 | this.messages = this.messages.filter((message) => 214 | message?.id ? message?.id !== messageId : true 215 | ); 216 | EventEmitter.emit('remove message', messageId); 217 | } 218 | 219 | addMessage(messageData: Partial, socket, isLocal = true) { 220 | // silent on error 221 | try { 222 | let newMessage: Partial; 223 | 224 | // is local sender 225 | if (isLocal) { 226 | // generate messageId 227 | const messageId = ( 228 | new Date().getTime() * 229 | Math.random() * 230 | 10000 231 | ).toString(32); 232 | 233 | messageData.message.date = new Date().toString(); 234 | 235 | newMessage = { 236 | ...messageData, 237 | id: messageId, 238 | user: toJS(this.currentUser), 239 | }; 240 | 241 | socket.emit('chat message', { 242 | roomName: this.roomName, 243 | message: this.encryptor.encrypt(JSON.stringify(newMessage)), 244 | }); 245 | 246 | EventEmitter.emit('add-message-to-history', newMessage as any); 247 | } else { 248 | const decryptedMessage = this.encryptor.decrypt(messageData as string); 249 | 250 | newMessage = JSON.parse(decryptedMessage); 251 | 252 | if (newMessage.message?.external) { 253 | EventEmitter.emit('add-message-to-history', newMessage as any); 254 | } 255 | 256 | if (this.settings.enableNotificationSound) { 257 | const audio = new Audio(MessageSound); 258 | audio.play(); 259 | } 260 | 261 | if (this.settings.enableNotificationTooltip) { 262 | let text = ''; 263 | if (newMessage.message.text) { 264 | text = 265 | newMessage.message.text.length > 25 266 | ? newMessage.message.text.substr(0, 25 - 1) + '...' 267 | : newMessage.message.text; 268 | } 269 | 270 | EventEmitter.emit( 271 | 'notification', 272 | messageData?.user?.name 273 | ? `${text} · ${messageData.user.avatar} ${messageData.user.name}` 274 | : `New chat message` 275 | ); 276 | } 277 | } 278 | 279 | this.messages.push(newMessage as MessageData); 280 | 281 | setTimeout(() => this.scrollToBottom(), 0); 282 | } catch (e) { 283 | console.log(e); 284 | } 285 | } 286 | } 287 | 288 | export const rootStore = new RootStore(); 289 | 290 | const StoreContext = React.createContext(null); 291 | 292 | export const StoreProvider = ({ children }) => ( 293 | {children} 294 | ); 295 | 296 | export const useStore = () => { 297 | const store = React.useContext(StoreContext); 298 | if (!store) { 299 | throw new Error('useStore must be used within a StoreProvider.'); 300 | } 301 | return store; 302 | }; 303 | 304 | export const trunk = new AsyncTrunk(rootStore, { 305 | storageKey: 'figma-chat', 306 | storage: { 307 | getItem: (key: string) => { 308 | EventEmitter.emit('storage get item', key); 309 | return new Promise((resolve) => 310 | EventEmitter.once('storage get item', resolve) 311 | ); 312 | }, 313 | setItem: (key: string, value: string) => { 314 | EventEmitter.emit('storage set item', { 315 | key, 316 | value, 317 | }); 318 | return new Promise((resolve) => 319 | EventEmitter.once('storage set item', resolve) 320 | ); 321 | }, 322 | removeItem: (key: string) => { 323 | EventEmitter.emit('storage remove item', key); 324 | return new Promise((resolve) => 325 | EventEmitter.once('storage remove item', resolve) 326 | ); 327 | }, 328 | }, 329 | }); 330 | 331 | export const getStoreFromMain = (): Promise => 332 | new Promise((resolve) => { 333 | EventEmitter.emit('storage', 'figma-chat'); 334 | EventEmitter.once('storage', (store) => { 335 | resolve(JSON.parse(store || '{}')); 336 | }); 337 | }); 338 | -------------------------------------------------------------------------------- /packages/plugin/src/style.css: -------------------------------------------------------------------------------- 1 | * { 2 | -webkit-box-sizing: border-box; 3 | box-sizing: border-box; 4 | } 5 | 6 | body { 7 | overflow: hidden; 8 | font-family: 'Inter', sans-serif; 9 | -webkit-font-smoothing: antialiased; 10 | -moz-osx-font-smoothing: grayscale; 11 | font-size: 12px; 12 | margin: 0; 13 | } 14 | 15 | input { 16 | font-family: 'Inter', sans-serif; 17 | } 18 | 19 | .main { 20 | position: relative; 21 | height: 100%; 22 | } 23 | 24 | ::-webkit-scrollbar { 25 | width: 4px; 26 | } 27 | 28 | ::-webkit-scrollbar:horizontal { 29 | height: 4px; 30 | } 31 | 32 | ::-webkit-scrollbar-track { 33 | background-color: transparent; 34 | } 35 | 36 | ::-webkit-scrollbar-thumb { 37 | border-radius: 6px; 38 | } 39 | -------------------------------------------------------------------------------- /packages/plugin/src/views/Chat/index.tsx: -------------------------------------------------------------------------------- 1 | import { toJS } from 'mobx'; 2 | import { observer, useLocalObservable } from 'mobx-react-lite'; 3 | import React, { useEffect, FunctionComponent } from 'react'; 4 | import styled from 'styled-components'; 5 | 6 | import { Messages } from '@fc/shared/components/Messages'; 7 | import { useSocket } from '@fc/shared/utils/SocketProvider'; 8 | import { MAX_MESSAGES } from '@fc/shared/utils/constants'; 9 | 10 | import EventEmitter from '../../shared/EventEmitter'; 11 | import { useStore } from '../../store'; 12 | 13 | import Chatbar from './components/Chatbar'; 14 | 15 | const Chat: FunctionComponent = observer(() => { 16 | const store = useStore(); 17 | const socket = useSocket(); 18 | 19 | const chatState = useLocalObservable(() => ({ 20 | textMessage: '', 21 | selectionIsChecked: false, 22 | messagesToShow: MAX_MESSAGES, 23 | setMessagesToShow(num: number) { 24 | this.messagesToShow = num; 25 | }, 26 | setTextMessage(msg: string) { 27 | this.textMessage = msg; 28 | }, 29 | setSelectionIsChecked(checked: boolean) { 30 | this.selectionIsChecked = checked; 31 | }, 32 | get filteredMessages() { 33 | return [...store.messages].slice(-chatState.messagesToShow); 34 | }, 35 | })); 36 | 37 | const sendMessage = (e = null) => { 38 | if (e) { 39 | e.preventDefault(); 40 | } 41 | 42 | if (store.roomName) { 43 | let message = { 44 | text: chatState.textMessage, 45 | }; 46 | 47 | if (store.selectionCount > 0 && chatState.selectionIsChecked) { 48 | message = { 49 | ...message, 50 | ...{ 51 | selection: toJS(store.selection), 52 | }, 53 | }; 54 | } 55 | 56 | if (!chatState.textMessage && !chatState.selectionIsChecked) { 57 | store.addNotification( 58 | 'Please enter a text or select something', 59 | 'error' 60 | ); 61 | } else { 62 | store.addMessage( 63 | { 64 | message, 65 | }, 66 | socket 67 | ); 68 | 69 | chatState.setTextMessage(''); 70 | chatState.setSelectionIsChecked(false); 71 | } 72 | } 73 | }; 74 | 75 | useEffect(() => { 76 | EventEmitter.on('selection', (selection) => { 77 | const hasSelection = 78 | selection?.length > 0 || selection?.nodes?.length > 0; 79 | 80 | store.setSelection(hasSelection ? selection : {}); 81 | 82 | if (!hasSelection) { 83 | chatState.selectionIsChecked = false; 84 | } 85 | }); 86 | 87 | EventEmitter.on('relaunch-message', (data) => { 88 | chatState.selectionIsChecked = true; 89 | 90 | store.setSelection(data.selection || {}); 91 | 92 | if (store.selectionCount) { 93 | sendMessage(); 94 | EventEmitter.emit('notify', 'Selection sent successfully'); 95 | } 96 | }); 97 | 98 | return () => { 99 | EventEmitter.remove('selection'); 100 | EventEmitter.remove('root-data'); 101 | EventEmitter.remove('relaunch-message'); 102 | }; 103 | }, []); 104 | 105 | useEffect(() => { 106 | store.scrollToBottom(); 107 | }, [store.messages]); 108 | 109 | const onClickSelection = (selection) => { 110 | let selectionData = null; 111 | 112 | // fallback without page 113 | if (selection.length) { 114 | selectionData = { 115 | ids: selection, 116 | }; 117 | } else { 118 | selectionData = { 119 | ...selection, 120 | }; 121 | } 122 | 123 | EventEmitter.emit('focus-nodes', selectionData); 124 | }; 125 | 126 | const removeMessage = (messageId) => { 127 | if (socket && messageId) { 128 | store.removeMessage(messageId); 129 | socket.emit('remove message', { 130 | roomName: store.roomName, 131 | messageId, 132 | }); 133 | } 134 | }; 135 | 136 | return ( 137 | 0} $isDevMode={store.figmaEditorType === 'dev'}> 138 | 144 | chatState.setTextMessage(text)} 147 | textMessage={chatState.textMessage} 148 | setSelectionIsChecked={(isChecked) => 149 | (chatState.selectionIsChecked = isChecked) 150 | } 151 | selectionIsChecked={chatState.selectionIsChecked} 152 | /> 153 | 154 | ); 155 | }); 156 | 157 | const Wrapper = styled.div<{ $hasSelection: boolean, $isDevMode: boolean }>` 158 | display: grid; 159 | grid-template-rows: 1fr ${(p) => p.$isDevMode ? '78px' : '44px'}; 160 | height: 100%; 161 | `; 162 | 163 | export default Chat; 164 | -------------------------------------------------------------------------------- /packages/plugin/src/views/Settings/components/AvatarColorPicker.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from 'mobx-react-lite'; 2 | import { lighten, rgba } from 'polished'; 3 | import React, { useRef, FunctionComponent, useCallback } from 'react'; 4 | import styled from 'styled-components'; 5 | 6 | import Tooltip from '@fc/shared/components/Tooltip'; 7 | import { useSocket } from '@fc/shared/utils/SocketProvider'; 8 | import { EColors } from '@fc/shared/utils/constants'; 9 | 10 | import { useStore } from '../../../store'; 11 | 12 | const AvatarColorPicker: FunctionComponent = observer(() => { 13 | const store = useStore(); 14 | const socket = useSocket(); 15 | 16 | const pickerRef = useRef(null); 17 | 18 | const persistCurrentUser = useCallback( 19 | (data) => { 20 | pickerRef.current.hide(); 21 | store.persistCurrentUser(data, socket); 22 | }, 23 | [socket] 24 | ); 25 | 26 | return ( 27 | ( 34 | 39 | {store.currentUser.avatar || ( 40 | 41 | )} 42 | 43 | ), 44 | { 45 | forwardRef: true, 46 | } 47 | )} 48 | > 49 | 50 | 51 | {[ 52 | '', 53 | '🐵', 54 | '🐮', 55 | '🐷', 56 | '🐨', 57 | '🦊', 58 | '🐻', 59 | '🐶', 60 | '🐸', 61 | '🐹', 62 | '🦄', 63 | '🐔', 64 | '🐧', 65 | '🐦', 66 | '🐺', 67 | '🦋', 68 | '🐥', 69 | '🐝', 70 | ].map((emoji) => ( 71 | { 75 | pickerRef.current.hide(); 76 | persistCurrentUser({ 77 | avatar: emoji, 78 | }); 79 | }} 80 | > 81 | {emoji || } 82 | 83 | ))} 84 | 85 | 86 | 87 | {Object.keys(EColors).map((color) => ( 88 | { 91 | pickerRef.current.hide(); 92 | persistCurrentUser({ 93 | color, 94 | }); 95 | }} 96 | className={`color ${ 97 | store.currentUser.color === color && ' active' 98 | }`} 99 | style={{ backgroundColor: color }} 100 | /> 101 | ))} 102 | 103 | 104 | 105 | ); 106 | }); 107 | 108 | const AvatarColorPickerAction = styled.div<{ color: string }>` 109 | width: 72px; 110 | height: 72px; 111 | background-color: ${(p) => p.color}; 112 | border-radius: 100%; 113 | text-align: center; 114 | cursor: pointer; 115 | overflow: hidden; 116 | align-self: center; 117 | font-size: 39px; 118 | line-height: 75px; 119 | box-shadow: 0 0 0 12px ${(p) => rgba(p.color, 0.2)}, 120 | 0 0 0 30px ${(p) => rgba(p.color, 0.1)}; 121 | `; 122 | 123 | const ItemWrapper = styled.div` 124 | display: grid; 125 | grid-gap: 5px; 126 | grid-template-columns: repeat(6, 1fr); 127 | &.colors { 128 | grid-template-columns: repeat(10, 1fr); 129 | background-color: ${(p) => lighten(0.1, p.theme.tooltipBackgroundColor)}; 130 | border-top: 1px solid ${(p) => p.theme.backgroundColorInverse}; 131 | padding: 10px; 132 | margin: 15px -15px -15px; 133 | border-radius: 0 0 20px 20px; 134 | } 135 | `; 136 | 137 | const Item = styled.div` 138 | position: relative; 139 | width: 41px; 140 | height: 41px; 141 | border: 1px solid ${(p) => p.theme.backgroundColorInverse}; 142 | border-radius: 100%; 143 | text-align: center; 144 | font-size: 18px; 145 | line-height: 40px; 146 | overflow: hidden; 147 | cursor: pointer; 148 | &.active { 149 | background-color: ${(p) => p.theme.backgroundColorInverse}; 150 | } 151 | &.color { 152 | width: 24px; 153 | height: 24px; 154 | border: 0; 155 | position: relative; 156 | &.active { 157 | &::after { 158 | content: ''; 159 | position: absolute; 160 | left: 8px; 161 | top: 8px; 162 | width: 8px; 163 | height: 8px; 164 | background-color: #fff; 165 | border-radius: 100%; 166 | } 167 | } 168 | } 169 | &.empty { 170 | &::after { 171 | content: ''; 172 | position: absolute; 173 | left: 19px; 174 | top: 13px; 175 | height: 14px; 176 | width: 1px; 177 | background-color: ${(p) => p.theme.backgroundColorInverse}; 178 | transform: rotate(45deg); 179 | } 180 | } 181 | `; 182 | 183 | const Wrapper = styled.div` 184 | width: 280px; 185 | `; 186 | 187 | export default AvatarColorPicker; 188 | -------------------------------------------------------------------------------- /packages/plugin/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "target": "es6", 5 | "jsx": "react", 6 | "experimentalDecorators": true, 7 | "moduleResolution": "node", 8 | "allowSyntheticDefaultImports": true, 9 | "resolveJsonModule": true, 10 | "noUnusedLocals": true, 11 | "noUnusedParameters": true, 12 | "typeRoots": ["./node_modules/@types", "../../node_modules/@figma"] 13 | }, 14 | "include": ["src"] 15 | } 16 | -------------------------------------------------------------------------------- /packages/plugin/webpack.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const BundleAnalyzerPlugin = 3 | require('webpack-bundle-analyzer').BundleAnalyzerPlugin; 4 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 5 | const CreateFileWebpack = require('create-file-webpack'); 6 | const TerserPlugin = require('terser-webpack-plugin'); 7 | const { ESBuildPlugin } = require('esbuild-loader'); 8 | const path = require('path'); 9 | const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin'); 10 | 11 | const { figmaPlugin } = require('./package.json'); 12 | 13 | module.exports = (env, argv) => ({ 14 | mode: argv.mode === 'production' ? 'production' : 'development', 15 | devtool: argv.mode === 'production' ? false : 'inline-source-map', 16 | optimization: { 17 | minimizer: [ 18 | new TerserPlugin({ 19 | terserOptions: { 20 | ecma: 2016, 21 | compress: { 22 | arguments: true, 23 | drop_console: true, 24 | }, 25 | }, 26 | }), 27 | ], 28 | }, 29 | entry: { 30 | ui: './src/Ui.tsx', 31 | code: './src/main/index.ts', 32 | }, 33 | watchOptions: { 34 | ignored: ['node_modules/**'], 35 | }, 36 | module: { 37 | rules: [ 38 | { 39 | test: /\.tsx?$/, 40 | loader: 'esbuild-loader', 41 | options: { 42 | loader: 'tsx', // Or 'ts' if you don't need tsx 43 | target: 'es2015', 44 | }, 45 | }, 46 | { 47 | test: /\.css$/, 48 | use: [ 49 | { 50 | loader: 'style-loader', 51 | }, 52 | { 53 | loader: 'css-loader', 54 | }, 55 | ], 56 | }, 57 | { 58 | test: /\.(png|jpg|gif|webp|svg|zip|mp3)$/, 59 | loader: 'url-loader', 60 | }, 61 | ], 62 | }, 63 | resolve: { 64 | extensions: ['.tsx', '.ts', '.jsx', '.js', '.mp3'], 65 | fallback: { 66 | stream: require.resolve('stream-browserify'), 67 | crypto: require.resolve('crypto-browserify'), 68 | buffer: require.resolve('buffer/'), 69 | string_decoder: false, 70 | events: false, 71 | }, 72 | }, 73 | output: { 74 | filename: '[name].js', 75 | path: path.resolve(__dirname, '..', '..', 'release', figmaPlugin.name), 76 | }, 77 | plugins: [ 78 | // argv.mode !== 'production' ? new BundleAnalyzerPlugin() : null, 79 | new webpack.ProvidePlugin({ 80 | Buffer: ['buffer', 'Buffer'], 81 | }), 82 | new webpack.DefinePlugin({ 83 | process: { 84 | env: { 85 | REACT_APP_SC_ATTR: JSON.stringify('data-styled-figma-chat'), 86 | SC_ATTR: JSON.stringify('data-styled-figma-chat'), 87 | REACT_APP_SC_DISABLE_SPEEDY: JSON.stringify('false'), 88 | }, 89 | }, 90 | }), 91 | new HtmlWebpackPlugin({ 92 | filename: 'ui.html', 93 | inlineSource: '.(js)$', 94 | chunks: ['ui'], 95 | inject: false, 96 | templateContent: ({ compilation, htmlWebpackPlugin }) => ` 97 | 98 | 99 | 100 | 101 | ${htmlWebpackPlugin.files.js.map( 102 | (jsFile) => 103 | `` 106 | )} 107 | 108 | 109 | `, 110 | }), 111 | new CreateFileWebpack({ 112 | path: path.resolve(__dirname, '..', '..', 'release', figmaPlugin.name), 113 | fileName: 'manifest.json', 114 | content: JSON.stringify(figmaPlugin), 115 | }), 116 | ].filter(Boolean), 117 | }); 118 | -------------------------------------------------------------------------------- /packages/server/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .git -------------------------------------------------------------------------------- /packages/server/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:16.5.0-alpine 2 | 3 | ARG VERSION 4 | ENV VERSION=$VERSION 5 | 6 | WORKDIR /usr/src/app 7 | 8 | COPY package*.json ./ 9 | 10 | RUN yarn install --production 11 | 12 | COPY ./dist/server.js ./dist/ 13 | 14 | CMD [ "npm", "run", "start:docker" ] -------------------------------------------------------------------------------- /packages/server/README.md: -------------------------------------------------------------------------------- 1 | # Figma Chat Server 2 | 3 | This plugins needs a server. 4 | This is a simple websocket server. **Messages are only forwarded and not stored!** 5 | 6 | ## How to start your own server? 7 | 8 | Clone the repository: 9 | 10 | ```bash 11 | git clone https://github.com/ph1p/figma-chat.git 12 | ``` 13 | 14 | and install all the dependencies. 15 | 16 | ```bash 17 | yarn install 18 | ``` 19 | 20 | run the server: 21 | 22 | ```bash 23 | yarn build && yarn start 24 | ``` 25 | 26 | If you want to set another port, you can set the `PORT` environment variable. 27 | 28 | ### Docker (run inside this folder) 29 | 30 | ```bash 31 | tsc --outDir dist && docker build . --tag figma-chat-server --build-arg VERSION=$(node -p -e "require('./package.json').version") && docker run -p 127.0.0.1:80:3000/tcp figma-chat-server 32 | ``` 33 | 34 | ### Traefik 35 | 36 | The simplest way to start your server, is to run it with [traefik](https://traefik.io/). 37 | You can find a `docker-compose.yml` inside this repository. 38 | The only thing you have to change is the URL and run: 39 | 40 | ```bash 41 | docker-compose up -d 42 | ``` 43 | 44 | or if you want to rebuild it: 45 | 46 | ```bash 47 | docker-compose build 48 | ``` 49 | 50 | ## Development 51 | 52 | ```bash 53 | yarn start:server # starts a server on port 3000 54 | ``` 55 | 56 | Set the server URL to `http://127.0.0.1:3000/` inside your plugin. 57 | -------------------------------------------------------------------------------- /packages/server/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | figmachat: 5 | build: . 6 | labels: 7 | - 'traefik.enable=true' 8 | - 'traefik.http.routers.figmachat.tls' 9 | - 'traefik.http.routers.figmachat.tls.certresolver=letsencryptresolver' 10 | - 'traefik.http.services.figmachat.loadbalancer.server.port=80' 11 | - 'traefik.http.routers.figmachat.rule=Host(`figma-chat.ph1p.dev`)' 12 | environment: 13 | - PORT=80 14 | networks: 15 | - default 16 | - proxy 17 | 18 | networks: 19 | proxy: 20 | external: true 21 | -------------------------------------------------------------------------------- /packages/server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server", 3 | "version": "5.0.1", 4 | "main": "../../release/server/server.js", 5 | "scripts": { 6 | "start": "NODE_ENV=production node ../../release/server/server.js", 7 | "start:docker": "NODE_ENV=production node ./dist/server.js", 8 | "build": "tsc", 9 | "dev": "NODE_ENV=development nodemon -e ts --exec \"tsc && node ../../release/server/server.js\"", 10 | "lint": "echo \"not implemented\"" 11 | }, 12 | "license": "MIT", 13 | "dependencies": { 14 | "express": "^4.18.3", 15 | "socket.io": "^4.7.4" 16 | }, 17 | "devDependencies": { 18 | "@types/express": "^4.17.21", 19 | "@typescript-eslint/eslint-plugin": "^7.1.0", 20 | "@typescript-eslint/parser": "^7.1.0", 21 | "eslint": "^8.57.0", 22 | "eslint-config-prettier": "^9.1.0", 23 | "eslint-import-resolver-node": "^0.3.9", 24 | "eslint-plugin-import": "^2.29.1", 25 | "eslint-plugin-prefer-arrow": "^1.2.3", 26 | "eslint-plugin-prettier": "^5.1.3", 27 | "eslint-plugin-react": "^7.34.0", 28 | "nodemon": "^3.1.0", 29 | "prettier": "^3.2.5", 30 | "typescript": "^5.3.3" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /packages/server/src/server.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import http from 'http'; 3 | import { Server, Socket } from 'socket.io'; 4 | 5 | const app = express(); 6 | const httpServer = http.createServer(app); 7 | const io = new Server(httpServer, { 8 | cors: { 9 | origin: '*', 10 | methods: ['GET', 'POST'], 11 | }, 12 | }); 13 | 14 | const VERSION = process.env.VERSION || process.env.NODE_ENV; 15 | const PORT = process.env.PORT || 3000; 16 | 17 | app.get('/', (_, res) => { 18 | res.send({ 19 | name: 'figma-chat', 20 | version: VERSION, 21 | }); 22 | }); 23 | 24 | const createUser = (id = '', name = '', color = '', room = '') => ({ 25 | id, 26 | name, 27 | color, 28 | room, 29 | }); 30 | 31 | declare class UserSocket extends Socket { 32 | user: any; 33 | } 34 | 35 | io.on('connection', (socket) => { 36 | const sock = socket as unknown as UserSocket; 37 | if (!sock.user) { 38 | sock.user = createUser(socket.id); 39 | } 40 | 41 | const sendOnline = async (room = '') => { 42 | try { 43 | let userRoom = room; 44 | 45 | if (sock?.user?.room) { 46 | userRoom = sock.user.room; 47 | } 48 | 49 | if (userRoom) { 50 | const sockets = await io.of('/').in(userRoom).allSockets(); 51 | const users = Array.from(sockets) 52 | .map((id) => io.of('/').sockets.get(id)) 53 | .filter(Boolean) 54 | .map((s: any) => s.user); 55 | 56 | io.in(userRoom).emit('online', users); 57 | } 58 | } catch (e) { 59 | console.log(e); 60 | } 61 | }; 62 | 63 | const joinLeave = async (currentSocket: any, type = 'JOIN') => { 64 | currentSocket.broadcast 65 | .to(currentSocket.user.room) 66 | .emit('join leave message', { 67 | id: currentSocket.id, 68 | user: currentSocket.user, 69 | type, 70 | }); 71 | }; 72 | 73 | socket.on('remove message', ({ roomName, messageId }) => { 74 | sock.broadcast.to(roomName).emit('remove message', messageId); 75 | }); 76 | 77 | socket.on('chat message', ({ roomName, message }) => { 78 | if (roomName) { 79 | if (!sock.user.room) { 80 | sock.user.room = roomName; 81 | sock.join(roomName); 82 | sendOnline(roomName); 83 | } 84 | 85 | // send to all in room except sender 86 | sock.broadcast.to(roomName).emit('chat message', message); 87 | } 88 | }); 89 | 90 | const joinRoom = ({ room, user }: any) => { 91 | sock.join(room); 92 | 93 | sock.user = { 94 | ...sock.user, 95 | ...user, 96 | room, 97 | }; 98 | 99 | joinLeave(sock); 100 | sendOnline(room); 101 | }; 102 | 103 | socket.on('login', ({ room, user }) => { 104 | if (io.sockets.adapter.rooms.has(room)) { 105 | joinRoom({ 106 | room, 107 | user, 108 | }); 109 | 110 | sock.emit('login succeeded'); 111 | } else { 112 | sock.emit('login failed'); 113 | } 114 | }); 115 | 116 | sock.on('set user', (userOptions) => { 117 | sock.user = { 118 | ...sock.user, 119 | ...userOptions, 120 | }; 121 | 122 | sendOnline(); 123 | }); 124 | 125 | sock.on('reconnect', () => { 126 | sendOnline(); 127 | 128 | sock.emit('user reconnected'); 129 | }); 130 | 131 | sock.on('join room', joinRoom); 132 | 133 | sock.on('disconnect', () => { 134 | joinLeave(sock, 'LEAVE'); 135 | sock.leave(sock.user.room); 136 | 137 | sendOnline(sock.user.room); 138 | }); 139 | }); 140 | 141 | httpServer.listen(PORT, () => { 142 | console.log(`The Figma-Chat Server (${VERSION}) is running`); 143 | }); 144 | 145 | process.on('SIGINT', () => { 146 | process.exit(); 147 | }); 148 | -------------------------------------------------------------------------------- /packages/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "commonjs", 5 | "strict": true, 6 | "moduleResolution": "node", 7 | "esModuleInterop": true, 8 | "skipLibCheck": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "resolveJsonModule": true, 11 | "outDir": "../../release/server" 12 | }, 13 | "include": ["src"] 14 | } 15 | -------------------------------------------------------------------------------- /packages/shared/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "shared", 3 | "version": "5.0.1", 4 | "private": true, 5 | "dependencies": { 6 | "@popperjs/core": "^2.11.8", 7 | "linkify-react": "^4.1.3", 8 | "linkifyjs": "^4.1.3", 9 | "mobx": "^6.12.0", 10 | "mobx-react-lite": "^4.0.5", 11 | "react": "^18.2.0", 12 | "react-dom": "^18.2.0", 13 | "react-popper": "^2.3.0", 14 | "react-spring": "^9.7.3", 15 | "react-timeago": "^7.2.0", 16 | "socket.io-client": "^4.7.4", 17 | "styled-components": "^6.1.8" 18 | }, 19 | "devDependencies": { 20 | "@types/node": "^20.11.24", 21 | "@types/react": "^18.2.61", 22 | "@types/react-dom": "^18.2.19", 23 | "@types/react-router-dom": "^5.3.3", 24 | "@types/react-timeago": "^4.1.7", 25 | "@types/styled-components": "^5.1.34", 26 | "@typescript-eslint/eslint-plugin": "^7.1.0", 27 | "@typescript-eslint/parser": "^7.1.0", 28 | "eslint": "^8.57.0", 29 | "eslint-config-prettier": "^9.1.0", 30 | "eslint-import-resolver-node": "^0.3.9", 31 | "eslint-plugin-import": "^2.29.1", 32 | "eslint-plugin-prefer-arrow": "^1.2.3", 33 | "eslint-plugin-prettier": "^5.1.3", 34 | "eslint-plugin-react": "^7.34.0", 35 | "eslint-plugin-unused-imports": "^3.1.0", 36 | "prettier": "^3.2.5", 37 | "typescript": "^5.3.3" 38 | }, 39 | "scripts": { 40 | "lint": "eslint --ext .tsx,.ts,.json,.js src/ --fix" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /packages/shared/src/assets/GiphyLogo.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react'; 2 | 3 | export const GiphyLogo: FunctionComponent = () => ( 4 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 26 | 27 | 28 | ); 29 | -------------------------------------------------------------------------------- /packages/shared/src/assets/icons/BackIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react'; 2 | 3 | const BackIcon: FunctionComponent = () => ( 4 | 11 | 15 | 16 | ); 17 | 18 | export default BackIcon; 19 | -------------------------------------------------------------------------------- /packages/shared/src/assets/icons/BellIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react'; 2 | 3 | interface Props { 4 | active?: boolean; 5 | } 6 | 7 | const BellIcon: FunctionComponent = (props) => { 8 | if (props.active) { 9 | return ( 10 | 16 | 22 | 23 | ); 24 | } 25 | 26 | return ( 27 | 28 | 34 | 39 | 40 | ); 41 | }; 42 | 43 | export default BellIcon; 44 | -------------------------------------------------------------------------------- /packages/shared/src/assets/icons/ChainIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react'; 2 | 3 | const ChainIcon: FunctionComponent = () => ( 4 | 11 | 15 | 19 | 20 | ); 21 | 22 | export default ChainIcon; 23 | -------------------------------------------------------------------------------- /packages/shared/src/assets/icons/ChatIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react'; 2 | 3 | const ChatIcon: FunctionComponent = () => ( 4 | 5 | 6 | 11 | 12 | ); 13 | 14 | export default ChatIcon; 15 | -------------------------------------------------------------------------------- /packages/shared/src/assets/icons/EmojiIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react'; 2 | 3 | const EmojiIcon: FunctionComponent = () => ( 4 | 5 | 9 | 13 | 14 | 15 | 16 | ); 17 | 18 | export default EmojiIcon; 19 | -------------------------------------------------------------------------------- /packages/shared/src/assets/icons/GearIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react'; 2 | 3 | const GearIcon: FunctionComponent = () => ( 4 | 11 | 15 | 19 | 20 | ); 21 | 22 | export default GearIcon; 23 | -------------------------------------------------------------------------------- /packages/shared/src/assets/icons/GiphyCloseIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react'; 2 | 3 | export const GiphyCloseIcon: FunctionComponent = () => ( 4 | 11 | 15 | 19 | 20 | ); 21 | -------------------------------------------------------------------------------- /packages/shared/src/assets/icons/HashIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react'; 2 | 3 | const HashIcon: FunctionComponent = () => ( 4 | 11 | 17 | 18 | ); 19 | 20 | export default HashIcon; 21 | -------------------------------------------------------------------------------- /packages/shared/src/assets/icons/MessageIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react'; 2 | 3 | interface Props { 4 | active?: boolean; 5 | } 6 | 7 | const MessageIcon: FunctionComponent = (props) => { 8 | if (props.active) { 9 | return ( 10 | 16 | 20 | 21 | 22 | ); 23 | } 24 | 25 | return ( 26 | 27 | 31 | 36 | 37 | ); 38 | }; 39 | 40 | export default MessageIcon; 41 | -------------------------------------------------------------------------------- /packages/shared/src/assets/icons/SendArrowIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react'; 2 | 3 | const SendArrowIcon: FunctionComponent = () => ( 4 | 11 | 15 | 16 | ); 17 | 18 | export default SendArrowIcon; 19 | -------------------------------------------------------------------------------- /packages/shared/src/assets/icons/SettingsIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react'; 2 | 3 | const SettingsIcon: FunctionComponent = () => ( 4 | 5 | 6 | 7 | 8 | ); 9 | 10 | export default SettingsIcon; 11 | -------------------------------------------------------------------------------- /packages/shared/src/assets/icons/ThemeIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react'; 2 | 3 | interface Props { 4 | active?: boolean; 5 | } 6 | 7 | const ThemeIcon: FunctionComponent = (props) => { 8 | if (props.active) { 9 | return ( 10 | 16 | 22 | 23 | ); 24 | } 25 | 26 | return ( 27 | 28 | 29 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | ); 41 | }; 42 | 43 | export default ThemeIcon; 44 | -------------------------------------------------------------------------------- /packages/shared/src/assets/icons/TrashIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react'; 2 | 3 | interface Props { 4 | active?: boolean; 5 | } 6 | 7 | const TrashIcon: FunctionComponent = (props) => { 8 | if (props.active) { 9 | return ( 10 | 16 | 20 | 25 | 30 | 31 | ); 32 | } 33 | 34 | return ( 35 | 36 | 40 | 45 | 50 | 51 | ); 52 | }; 53 | 54 | export default TrashIcon; 55 | -------------------------------------------------------------------------------- /packages/shared/src/assets/sound.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ph1p/figma-chat/a47166e40d558d80df68da0a536584debfffc134/packages/shared/src/assets/sound.mp3 -------------------------------------------------------------------------------- /packages/shared/src/components/Checkbox.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | const Checkbox = (props: React.InputHTMLAttributes) => { 5 | return ( 6 | 7 | 14 | Enable tooltips 15 | 16 | 17 | ); 18 | }; 19 | 20 | const CheckboxWrapper = styled.div<{ checked?: boolean }>` 21 | display: flex; 22 | justify-content: space-between; 23 | position: relative; 24 | height: 21px; 25 | input { 26 | display: none; 27 | } 28 | label { 29 | cursor: pointer; 30 | font-size: 12px; 31 | font-weight: bold; 32 | width: 100%; 33 | height: 17px; 34 | padding: 4px 0 0 0; 35 | & + div { 36 | pointer-events: none; 37 | position: relative; 38 | transition: opacity 0.3s; 39 | opacity: ${({ checked }) => (checked ? 1 : 0.4)}; 40 | &:after { 41 | content: ''; 42 | position: absolute; 43 | right: 0; 44 | width: 35px; 45 | height: 21px; 46 | background-color: rgba(255, 255, 255, 0.46); 47 | border-radius: 25px; 48 | } 49 | &:before { 50 | content: ''; 51 | position: absolute; 52 | right: 0; 53 | background-color: #fff; 54 | width: 13px; 55 | height: 13px; 56 | transition: transform 0.3s; 57 | transform: translate( 58 | ${({ checked }) => (checked ? '-4px, 4px' : '-18px, 4px')} 59 | ); 60 | border-radius: 100%; 61 | } 62 | } 63 | } 64 | `; 65 | 66 | export default Checkbox; 67 | -------------------------------------------------------------------------------- /packages/shared/src/components/CustomLink.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useNavigate, useMatch } from 'react-router-dom'; 3 | 4 | export const CustomLink = ({ 5 | children, 6 | to, 7 | style = {}, 8 | className = '', 9 | }: any) => { 10 | const navigate = useNavigate(); 11 | const match = useMatch({ 12 | path: to, 13 | }); 14 | 15 | return ( 16 | navigate(to)} 24 | className={match ? `${className} active` : className} 25 | > 26 | {children} 27 | 28 | ); 29 | }; 30 | -------------------------------------------------------------------------------- /packages/shared/src/components/GiphyGrid.tsx: -------------------------------------------------------------------------------- 1 | import { GiphyFetch } from '@giphy/js-fetch-api'; 2 | import { Grid } from '@giphy/react-components'; 3 | import { observer } from 'mobx-react-lite'; 4 | import React, { FunctionComponent, useMemo, useRef } from 'react'; 5 | import styled from 'styled-components'; 6 | 7 | import { GiphyLogo } from '@fc/shared/assets/GiphyLogo'; 8 | 9 | import { GiphyCloseIcon } from '../assets/icons/GiphyCloseIcon'; 10 | import { useSocket } from '../utils/SocketProvider'; 11 | import { useOnClickOutside } from '../utils/hooks/use-on-outside-click'; 12 | 13 | const gf = new GiphyFetch('omj1iPoq5H5GTi2Xjz2E9NFCcVqGLuPZ'); 14 | 15 | interface GiphyGridProps { 16 | store: any; 17 | setTextMessage: (text: string) => void; 18 | textMessage: string; 19 | } 20 | 21 | export const GiphyGrid: FunctionComponent = observer( 22 | (props) => { 23 | const socket = useSocket(); 24 | const ref = useRef(null); 25 | useOnClickOutside(ref, () => props.setTextMessage('')); 26 | 27 | const searchTerm = useMemo(() => { 28 | if (props.textMessage.startsWith('/giphy')) { 29 | return props.textMessage.replace('/giphy', ''); 30 | } 31 | return ''; 32 | }, [props.textMessage]); 33 | 34 | const isGiphy = useMemo( 35 | () => props.textMessage.startsWith('/giphy'), 36 | [props.textMessage] 37 | ); 38 | 39 | return isGiphy ? ( 40 | 41 | 42 | 43 | 44 | 45 | {searchTerm} 46 | props.setTextMessage('')}> 47 | 48 | 49 | 50 | 51 | Nothing found 🙈} 53 | width={290} 54 | columns={2} 55 | gutter={9} 56 | noLink={true} 57 | fetchGifs={(offset) => 58 | searchTerm 59 | ? gf.search(searchTerm, { offset, limit: 10 }) 60 | : gf.trending({ offset, limit: 10 }) 61 | } 62 | key={searchTerm} 63 | overlay={({ gif }) => ( 64 | { 67 | const message = { 68 | giphy: gif.id, 69 | external: !props.store.addMessage, 70 | }; 71 | 72 | if (socket) { 73 | if (props.store.addMessage) { 74 | props.store.addMessage({ message }, socket); 75 | } else { 76 | props.store.addLocalMessage({ message }, socket); 77 | } 78 | } 79 | 80 | props.setTextMessage(''); 81 | }} 82 | /> 83 | )} 84 | /> 85 | 86 | 87 | ) : null; 88 | } 89 | ); 90 | 91 | const GridWrapper = styled.div` 92 | overflow: auto; 93 | padding-bottom: 9px; 94 | `; 95 | 96 | const Empty = styled.div` 97 | overflow: auto; 98 | position: absolute; 99 | left: 50%; 100 | top: 50%; 101 | transform: translate(-50%, -50%); 102 | font-size: 15px; 103 | `; 104 | 105 | const GiphyHeader = styled.div` 106 | display: flex; 107 | padding: 0 0 12px; 108 | align-items: center; 109 | .logo { 110 | margin-left: 5px; 111 | svg { 112 | width: 70px; 113 | height: 15px; 114 | } 115 | } 116 | .searchterm { 117 | color: #4c4c4c; 118 | margin-left: 6px; 119 | } 120 | .close { 121 | margin-left: auto; 122 | cursor: pointer; 123 | } 124 | `; 125 | 126 | const Giphy = styled.div` 127 | position: absolute; 128 | z-index: 15; 129 | bottom: 54px; 130 | transform: translateX(-50%); 131 | left: 50%; 132 | color: #fff; 133 | display: grid; 134 | grid-template-rows: 36px 1fr; 135 | width: 315px; 136 | height: 250px; 137 | background-color: #000; 138 | border-radius: 14px; 139 | padding: 9px 9px 0; 140 | .overlay { 141 | position: absolute; 142 | left: 0; 143 | top: 0; 144 | right: 0; 145 | bottom: 0; 146 | cursor: pointer; 147 | transition: all 0.3s; 148 | &:hover { 149 | background-color: rgba(0, 0, 0, 0.4); 150 | } 151 | } 152 | `; 153 | -------------------------------------------------------------------------------- /packages/shared/src/components/Messages.tsx: -------------------------------------------------------------------------------- 1 | import { toJS } from 'mobx'; 2 | import { observer } from 'mobx-react-lite'; 3 | import React, { FunctionComponent, useEffect } from 'react'; 4 | import styled, { css } from 'styled-components'; 5 | 6 | import Message from '../components/Message'; 7 | import { MAX_MESSAGES } from '../utils/constants'; 8 | import { MessageData } from '../utils/interfaces'; 9 | 10 | interface Props { 11 | chatState: any; 12 | onClickSelection?: (selection: any) => void; 13 | isWeb?: boolean; 14 | store: any; 15 | removeMessage: (messageId: string) => void; 16 | } 17 | 18 | export const Messages: FunctionComponent = observer((props) => { 19 | const showMessageSeperator = (messageIndex = 0) => { 20 | return ( 21 | (messageIndex + 1) % MAX_MESSAGES === 0 && 22 | messageIndex + 1 !== props.chatState.filteredMessages.length 23 | ); 24 | }; 25 | 26 | useEffect(() => { 27 | if (props.store.messagesRef.current) { 28 | const onWheel = () => { 29 | let isLoadingMore = false; 30 | const { current } = toJS(props.store.messagesRef); 31 | let prevScroll = 0; 32 | 33 | if ( 34 | current && 35 | current.scrollTop < 40 && 36 | !isLoadingMore && 37 | props.chatState.filteredMessages.length !== 38 | props.store.messages.length 39 | ) { 40 | prevScroll = current.scrollHeight; 41 | isLoadingMore = true; 42 | 43 | if ( 44 | props.chatState.filteredMessages.length + MAX_MESSAGES >= 45 | props.store.messages.length 46 | ) { 47 | props.chatState.setMessagesToShow(props.store.messages.length); 48 | } else { 49 | props.chatState.setMessagesToShow( 50 | props.chatState.messagesToShow + MAX_MESSAGES 51 | ); 52 | } 53 | 54 | current.scrollTop = current.scrollHeight - prevScroll; 55 | isLoadingMore = false; 56 | } 57 | 58 | if (current) { 59 | props.store.disableAutoScroll( 60 | current.scrollHeight - (current.scrollTop + current.clientHeight) > 61 | 0 62 | ); 63 | } 64 | }; 65 | 66 | props.store.messagesRef.current.addEventListener('wheel', onWheel); 67 | } 68 | }, [props.store.messagesRef]); 69 | 70 | return ( 71 | 72 | 73 | {props.chatState.filteredMessages.map( 74 | (data: MessageData, i: number) => ( 75 | 76 | {showMessageSeperator(i) && ( 77 | 78 | older messages 79 | 80 | )} 81 | 87 | 88 | ) 89 | )} 90 | 91 | 92 | ); 93 | }); 94 | 95 | const Wrapper = styled.div<{ isWeb?: boolean }>` 96 | position: relative; 97 | width: 100%; 98 | overflow-x: hidden; 99 | overflow-y: auto; 100 | display: grid; 101 | ${(p) => 102 | p.isWeb 103 | ? css` 104 | height: calc(500px - 56px); 105 | max-height: 100%; 106 | @media (min-width: 450px) { 107 | height: calc(500px - 56px); 108 | } 109 | @media (max-width: 450px) { 110 | height: calc(100vh - 106px); 111 | width: 100%; 112 | width: 100%; 113 | } 114 | ` 115 | : ''} 116 | `; 117 | 118 | const ScrollWrapper = styled.div<{$isDevMode: boolean}>` 119 | padding: 9px 9px 0; 120 | width: 100%; 121 | align-self: end; 122 | &::after { 123 | content: ''; 124 | transition: opacity 0.3s; 125 | opacity: 1; 126 | position: fixed; 127 | bottom: ${(p) => p.$isDevMode ? 60 : 30}; 128 | left: 14px; 129 | right: 14px; 130 | background: transparent; 131 | height: 14px; 132 | pointer-events: none; 133 | box-shadow: 0 3px 10px 14px ${(p) => p.theme.backgroundColor}; 134 | border-radius: 0; 135 | } 136 | `; 137 | 138 | const MessageSeperator = styled.div` 139 | border-width: 1px 0 0 0; 140 | border-color: ${(p) => p.theme.secondaryBackgroundColor}; 141 | border-style: solid; 142 | margin: 5px 0 17px; 143 | text-align: center; 144 | position: relative; 145 | span { 146 | position: absolute; 147 | top: -8px; 148 | left: 50%; 149 | color: ${(p) => p.theme.secondaryBackgroundColor}; 150 | background-color: ${(p) => p.theme.backgroundColor}; 151 | padding: 0 14px; 152 | transform: translateX(-50%); 153 | font-size: 10px; 154 | text-transform: uppercase; 155 | } 156 | `; 157 | -------------------------------------------------------------------------------- /packages/shared/src/components/Notification.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from 'mobx-react-lite'; 2 | import React, { useEffect, useState } from 'react'; 3 | import styled from 'styled-components'; 4 | 5 | import { NotificationParams } from '../utils/interfaces'; 6 | 7 | const Notification = ( 8 | props: NotificationParams & { 9 | deleteNotification: (id: number) => any; 10 | } 11 | ) => { 12 | const [tm, setTm] = useState(); 13 | const hideNotification = () => props.deleteNotification(props.id); 14 | 15 | useEffect(() => { 16 | setTm(setTimeout(hideNotification, 3000)); 17 | 18 | return () => clearTimeout(tm); 19 | }, []); 20 | 21 | let typeClass = ''; 22 | 23 | if (props.type === 'success') { 24 | typeClass = 'success'; 25 | } else if (props.type === 'error') { 26 | typeClass = 'error'; 27 | } 28 | 29 | return ( 30 | 31 | {props.text} 32 | 33 | ); 34 | }; 35 | 36 | const NotificationContainer = styled.div` 37 | cursor: pointer; 38 | padding: 5px 10px; 39 | border-radius: 5px; 40 | height: auto; 41 | margin-bottom: 5px; 42 | background-color: ${(p) => p.theme.tooltipBackgroundColor}; 43 | color: ${(p) => p.theme.fontColorInverse}; 44 | span { 45 | color: ${(p) => p.theme.fontColorInverse}; 46 | font-size: 12px; 47 | } 48 | `; 49 | 50 | export default observer(Notification); 51 | -------------------------------------------------------------------------------- /packages/shared/src/components/Notifications.tsx: -------------------------------------------------------------------------------- 1 | // store 2 | import { observer } from 'mobx-react-lite'; 3 | import React, { FunctionComponent, useEffect, useState } from 'react'; 4 | import { useLocation } from 'react-router-dom'; 5 | import styled from 'styled-components'; 6 | 7 | import { NotificationParams } from '@fc/shared/utils/interfaces'; 8 | 9 | import Notification from './Notification'; 10 | 11 | interface Props { 12 | notifications: NotificationParams[]; 13 | deleteNotification: (id: number) => void; 14 | } 15 | 16 | const Notifications: FunctionComponent = (props: Props) => { 17 | const location = useLocation(); 18 | const [isRoot, setIsRoot] = useState(true); 19 | 20 | useEffect(() => { 21 | setIsRoot(location.pathname === '/'); 22 | }, [location]); 23 | 24 | if (props.notifications.length === 0) return null; 25 | 26 | return ( 27 | 28 | {props.notifications.map((data: NotificationParams, key) => ( 29 | props.deleteNotification(data.id)} 33 | /> 34 | ))} 35 | 36 | ); 37 | }; 38 | 39 | const NotificationsContainer = styled.div<{ $isRoot: boolean }>` 40 | display: flex; 41 | flex-direction: column-reverse; 42 | position: absolute; 43 | top: 0; 44 | z-index: 11; 45 | padding: 11px; 46 | width: 100%; 47 | align-items: center; 48 | `; 49 | 50 | export default observer(Notifications); 51 | -------------------------------------------------------------------------------- /packages/shared/src/components/Tooltip.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useImperativeHandle, useRef, useState } from 'react'; 2 | import { usePopper } from 'react-popper'; 3 | import styled, { css } from 'styled-components'; 4 | 5 | interface Props { 6 | handler?: any; 7 | hover?: boolean; 8 | shadow?: boolean; 9 | children: any; 10 | style?: any; 11 | offsetHorizontal?: number; 12 | placement?: 'top' | 'bottom'; 13 | } 14 | 15 | export const RefTooltip = React.forwardRef((props, ref: any) => { 16 | const [isOpen, setIsOpen] = useState(false); 17 | const [popperElement, setPopperElement] = useState(); 18 | const [arrowElement, setArrowElement] = useState(); 19 | 20 | const { styles, attributes } = usePopper( 21 | props.handler.current, 22 | popperElement, 23 | { 24 | placement: props.placement || 'top', 25 | strategy: 'fixed', 26 | modifiers: [ 27 | { 28 | name: 'arrow', 29 | options: { 30 | element: arrowElement, 31 | }, 32 | }, 33 | { 34 | name: 'offset', 35 | options: { 36 | offset: [0, props.hover ? 7 : 14], 37 | }, 38 | }, 39 | { 40 | name: 'preventOverflow', 41 | options: { 42 | padding: props.offsetHorizontal || 14, 43 | }, 44 | }, 45 | ], 46 | } 47 | ); 48 | 49 | const wrapperRef = useRef(null); 50 | 51 | useImperativeHandle(ref, () => ({ 52 | show: () => setIsOpen(true), 53 | hide: () => setIsOpen(false), 54 | })); 55 | 56 | return ( 57 | 58 | {isOpen && ( 59 | 67 | 68 | {props.children} 69 | 70 | 71 | 72 | )} 73 | 74 | ); 75 | }); 76 | 77 | export default React.forwardRef((props, ref) => { 78 | const [isOpen, setIsOpen] = useState(false); 79 | const { handler: HandlerComp } = props; 80 | 81 | const wrapperRef = useRef(null); 82 | const handlerRef = useRef(null); 83 | 84 | const [popperElement, setPopperElement] = useState(); 85 | const [arrowElement, setArrowElement] = useState(); 86 | 87 | const { styles, attributes } = usePopper(handlerRef.current, popperElement, { 88 | placement: props.placement || 'top', 89 | strategy: 'fixed', 90 | modifiers: [ 91 | { 92 | name: 'arrow', 93 | options: { 94 | element: arrowElement, 95 | }, 96 | }, 97 | { 98 | name: 'offset', 99 | options: { 100 | offset: [0, props.hover ? 7 : 14], 101 | }, 102 | }, 103 | { 104 | name: 'preventOverflow', 105 | options: { 106 | padding: props.offsetHorizontal || 14, 107 | }, 108 | }, 109 | ], 110 | }); 111 | 112 | useImperativeHandle(ref, () => ({ 113 | hide: () => setIsOpen(false), 114 | })); 115 | 116 | useEffect(() => { 117 | if (!props.hover) { 118 | const handleClick = (event: any) => { 119 | if (wrapperRef.current && !wrapperRef.current.contains(event.target)) { 120 | setIsOpen(false); 121 | } 122 | }; 123 | 124 | document.addEventListener('mousedown', handleClick); 125 | return () => document.removeEventListener('mousedown', handleClick); 126 | } 127 | }, [wrapperRef]); 128 | 129 | return ( 130 | 131 | !props.hover && setIsOpen(!isOpen)} 133 | onMouseEnter={() => props.hover && setIsOpen(!isOpen)} 134 | onMouseLeave={() => props.hover && setIsOpen(!isOpen)} 135 | > 136 | 137 | 138 | 139 | {isOpen && ( 140 | 148 | 149 | {props.children} 150 | 151 | 152 | 153 | )} 154 | 155 | ); 156 | }); 157 | 158 | const TooltipContent = styled.div<{ hover?: boolean }>` 159 | padding: ${(p) => (p.hover ? '5px 10px' : '15px')}; 160 | position: relative; 161 | z-index: 1; 162 | font-weight: normal; 163 | `; 164 | 165 | const Arrow = styled.div` 166 | position: absolute; 167 | width: 21px; 168 | height: 21px; 169 | pointer-events: none; 170 | &::before { 171 | content: ''; 172 | position: absolute; 173 | width: 21px; 174 | height: 21px; 175 | background-color: ${(p) => p.theme.tooltipBackgroundColor}; 176 | transform: rotate(45deg); 177 | top: 0px; 178 | left: 0px; 179 | border-radius: 4px; 180 | z-index: -1; 181 | } 182 | `; 183 | 184 | const Tooltip = styled.div<{ 185 | hover?: boolean; 186 | isOpen?: boolean; 187 | shadow?: boolean; 188 | }>` 189 | position: fixed; 190 | background-color: ${(p) => p.theme.tooltipBackgroundColor}; 191 | border-radius: ${(p) => (p.hover ? 6 : 20)}px; 192 | visibility: ${(p) => (p.isOpen ? 'visible' : 'hidden')}; 193 | pointer-events: ${(p) => (p.isOpen ? 'all' : 'none')}; 194 | z-index: 4; 195 | color: ${(p) => p.theme.fontColorInverse}; 196 | 197 | ${(p) => 198 | p.shadow 199 | ? css` 200 | box-shadow: 0px 24px 34px ${({ theme }) => theme.tooltipShadow}; 201 | ` 202 | : ''} 203 | 204 | &-enter { 205 | opacity: 0; 206 | } 207 | &-enter-active { 208 | opacity: 1; 209 | transition: opacity 200ms ease-in, transform 200ms ease-in; 210 | } 211 | &-exit { 212 | opacity: 1; 213 | } 214 | &-exit-active { 215 | opacity: 0; 216 | transition: opacity 200ms ease-in, transform 200ms ease-in; 217 | } 218 | 219 | &[data-popper-placement^='top'] { 220 | ${Arrow} { 221 | bottom: ${(p) => (p.hover ? -1 : -4)}px; 222 | } 223 | } 224 | &[data-popper-placement^='bottom'] { 225 | ${Arrow} { 226 | top: ${(p) => (p.hover ? -1 : -4)}px; 227 | } 228 | } 229 | &.place-left { 230 | &::after { 231 | margin-top: -10px; 232 | } 233 | } 234 | `; 235 | -------------------------------------------------------------------------------- /packages/shared/src/components/UserList.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react'; 2 | import { useNavigate } from 'react-router'; 3 | import styled from 'styled-components'; 4 | 5 | import BackIcon from '@fc/shared/assets/icons/BackIcon'; 6 | 7 | import { CurrentUser } from '../utils/interfaces'; 8 | 9 | interface Props { 10 | users: { 11 | id: string; 12 | name: string; 13 | avatar: string; 14 | color: string; 15 | photoUrl: string; 16 | }[]; 17 | user: CurrentUser; 18 | } 19 | 20 | const UserList: FunctionComponent = (props) => { 21 | const navigate = useNavigate(); 22 | 23 | return ( 24 | 25 | Active Users 26 | 27 | {props.users.map((user, i) => { 28 | return ( 29 | 30 | 39 | {user.avatar} 40 | 41 | 42 | {user.name || 'Anon'} 43 | {user.id === props.user.id && you} 44 | 45 | 46 | ); 47 | })} 48 | 49 | 50 | navigate('/')}> 51 | 52 | 53 | 54 | 55 | ); 56 | }; 57 | 58 | const Wrapper = styled.div` 59 | display: grid; 60 | grid-template-rows: auto 1fr 35px; 61 | width: 100vw; 62 | padding: 9px; 63 | height: 100%; 64 | 65 | h5 { 66 | color: #a2adc0; 67 | font-weight: normal; 68 | margin: 0 0 10px; 69 | font-size: 10px; 70 | } 71 | .users { 72 | overflow-y: auto; 73 | .user { 74 | padding: 4px 0; 75 | font-size: 14px; 76 | font-weight: bold; 77 | display: flex; 78 | align-items: center; 79 | .name { 80 | color: ${(p) => p.theme.fontColor}; 81 | &.empty { 82 | font-weight: normal; 83 | font-style: italic; 84 | } 85 | p { 86 | color: #999; 87 | font-size: 10px; 88 | margin: 2px 0 0 0; 89 | font-weight: 400; 90 | } 91 | } 92 | .color { 93 | background-size: cover; 94 | border-radius: 100%; 95 | width: 41px; 96 | height: 41px; 97 | margin-right: 17px; 98 | font-size: 22px; 99 | text-align: center; 100 | line-height: 43px; 101 | } 102 | } 103 | } 104 | `; 105 | 106 | const Tile = styled.div<{ name: string }>` 107 | width: 24px; 108 | height: 24px; 109 | display: flex; 110 | justify-content: center; 111 | align-items: center; 112 | background-color: ${(p) => p.theme.chatbarSecondaryBackground}; 113 | border-radius: 100%; 114 | cursor: pointer; 115 | svg { 116 | transform: scale(0.8); 117 | 118 | path { 119 | fill: ${({ theme }) => theme.thirdFontColor}; 120 | } 121 | } 122 | `; 123 | 124 | const ShortcutTiles = styled.div` 125 | background-color: ${(p) => p.theme.secondaryBackgroundColor}; 126 | padding: 6px; 127 | border-radius: 94px; 128 | justify-self: start; 129 | `; 130 | 131 | export default UserList; 132 | -------------------------------------------------------------------------------- /packages/shared/src/utils/SocketProvider.tsx: -------------------------------------------------------------------------------- 1 | import React, { FunctionComponent } from 'react'; 2 | import { Socket } from 'socket.io-client'; 3 | 4 | interface Props { 5 | socket: Socket | null; 6 | children: any; 7 | } 8 | 9 | const SocketContext = React.createContext(null); 10 | 11 | export const SocketProvider: FunctionComponent = (props) => ( 12 | 13 | {props.children} 14 | 15 | ); 16 | 17 | export const useSocket = (): Socket | null => { 18 | const socket = React.useContext(SocketContext); 19 | 20 | if (socket === undefined) { 21 | throw new Error('useSocket must be used within a SocketProvider.'); 22 | } 23 | 24 | return socket; 25 | }; 26 | 27 | export const withSocketContext = (Component: any) => (props: any) => 28 | ( 29 | 30 | {(socket) => } 31 | 32 | ); 33 | -------------------------------------------------------------------------------- /packages/shared/src/utils/constants.ts: -------------------------------------------------------------------------------- 1 | export const MAX_MESSAGES = 25; 2 | export const DEFAULT_SERVER_URL = 'https://figma-chat.ph1p.dev/'; 3 | export enum EColors { 4 | '#4F4F4F' = 'gray', 5 | '#493AC6' = 'blue', 6 | '#00B5CE' = 'lightblue', 7 | '#907CFF' = 'purple', 8 | '#1BC47D' = 'green', 9 | '#FFC8B9' = 'skintone', 10 | '#EF596F' = 'red', 11 | '#F0C75F' = 'yellow', 12 | '#F3846B' = 'orange', 13 | '#87BBB7' = 'forest', 14 | } 15 | -------------------------------------------------------------------------------- /packages/shared/src/utils/helpers.ts: -------------------------------------------------------------------------------- 1 | export const isOnlyEmoji = (str: string) => 2 | str 3 | ? str.replace( 4 | /(?:[\u2700-\u27bf]|(?:\ud83c[\udde6-\uddff]){2}|[\ud800-\udbff][\udc00-\udfff]|[\u0023-\u0039]\ufe0f?\u20e3|\u3299|\u3297|\u303d|\u3030|\u24c2|\ud83c[\udd70-\udd71]|\ud83c[\udd7e-\udd7f]|\ud83c\udd8e|\ud83c[\udd91-\udd9a]|\ud83c[\udde6-\uddff]|\ud83c[\ude01-\ude02]|\ud83c\ude1a|\ud83c\ude2f|\ud83c[\ude32-\ude3a]|\ud83c[\ude50-\ude51]|\u203c|\u2049|[\u25aa-\u25ab]|\u25b6|\u25c0|[\u25fb-\u25fe]|\u00a9|\u00ae|\u2122|\u2139|\ud83c\udc04|[\u2600-\u26FF]|\u2b05|\u2b06|\u2b07|\u2b1b|\u2b1c|\u2b50|\u2b55|\u231a|\u231b|\u2328|\u23cf|[\u23e9-\u23f3]|[\u23f8-\u23fa]|\ud83c\udccf|\u2934|\u2935|[\u2190-\u21ff])/g, 5 | '' 6 | ) === '' 7 | : ''; 8 | 9 | export const generateString = (length: number = 40): string => { 10 | const chars = 11 | 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; 12 | 13 | let text = ''; 14 | for (let i = 0; i < length; i++) { 15 | text += chars.charAt(Math.floor(Math.random() * chars.length)); 16 | } 17 | 18 | return text; 19 | }; 20 | -------------------------------------------------------------------------------- /packages/shared/src/utils/hooks/use-on-outside-click.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | export const useOnClickOutside = (ref: any, handler: any) => { 4 | useEffect(() => { 5 | const listener = (event: any) => { 6 | if (!ref.current || ref.current.contains(event.target)) { 7 | return; 8 | } 9 | handler(event); 10 | }; 11 | document.addEventListener('mousedown', listener); 12 | document.addEventListener('touchstart', listener); 13 | return () => { 14 | document.removeEventListener('mousedown', listener); 15 | document.removeEventListener('touchstart', listener); 16 | }; 17 | }, [ref, handler]); 18 | }; 19 | -------------------------------------------------------------------------------- /packages/shared/src/utils/interfaces.ts: -------------------------------------------------------------------------------- 1 | import { EColors } from './constants'; 2 | 3 | export enum ConnectionEnum { 4 | NONE = 'NONE', 5 | CONNECTED = 'CONNECTED', 6 | ERROR = 'ERROR', 7 | CONNECTING = 'CONNECTING', 8 | } 9 | 10 | export interface CurrentUser { 11 | color: keyof typeof EColors; 12 | id: string; 13 | name?: string; 14 | photoUrl?: string; 15 | avatar?: string; 16 | sessionId?: string; 17 | } 18 | 19 | export interface NotificationParams { 20 | id: number; 21 | text: string; 22 | type: string; 23 | } 24 | 25 | export interface MessageData { 26 | id: string; 27 | message: { 28 | date?: string | Date; 29 | text: string; 30 | giphy?: string; 31 | external?: any; 32 | selection?: { 33 | nodes?: string[]; 34 | page?: { 35 | name?: string; 36 | }; 37 | }; 38 | }; 39 | user: CurrentUser; 40 | } 41 | 42 | export interface StoreSettings { 43 | url: string; 44 | enableNotificationTooltip: boolean; 45 | enableNotificationSound: boolean; 46 | isDarkTheme: boolean; 47 | } 48 | -------------------------------------------------------------------------------- /packages/shared/src/utils/theme.ts: -------------------------------------------------------------------------------- 1 | export const darkTheme = { 2 | fontColor: '#fff', 3 | secondaryFontColor: '#615e73', 4 | thirdFontColor: '#7989a0', 5 | fontColorInverse: '#000', 6 | backgroundColor: '#0D0B1C', 7 | secondaryBackgroundColor: '#1F2538', 8 | thirdBackgroundColor: '#171b29', 9 | backgroundColorInverse: '#E7E7E7', 10 | scrollbarColor: '#434e71', 11 | borderColor: '#272D36', 12 | tooltipBackgroundColor: '#fff', 13 | placeholder: '#626E81', 14 | brighterInputFont: '#6E6D77', 15 | tooltipShadow: 'rgba(0, 0, 0, 0.61)', 16 | chatbarSecondaryBackground: '#3a4567', 17 | inputColor: '#fff', 18 | }; 19 | 20 | export const lightTheme = { 21 | fontColor: '#000', 22 | secondaryFontColor: '#cecece', 23 | thirdFontColor: '#717D92', 24 | fontColorInverse: '#fff', 25 | backgroundColor: '#fff', 26 | secondaryBackgroundColor: '#eceff4', 27 | thirdBackgroundColor: '#e4e9f1', 28 | backgroundColorInverse: '#383168', 29 | scrollbarColor: '#cfcfd0', 30 | borderColor: '#e4e4e4', 31 | tooltipBackgroundColor: '#1e1940', 32 | placeholder: '#a2adc0', 33 | brighterInputFont: '#B4BFD0', 34 | tooltipShadow: 'rgba(30, 25, 64, 0.34)', 35 | chatbarSecondaryBackground: '#D5DAE0', 36 | inputColor: '#717D92', 37 | }; 38 | -------------------------------------------------------------------------------- /packages/shared/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "target": "es5", 5 | "lib": ["dom", "dom.iterable", "esnext"], 6 | "allowJs": true, 7 | "skipLibCheck": true, 8 | "esModuleInterop": true, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "noFallthroughCasesInSwitch": true, 13 | "module": "esnext", 14 | "moduleResolution": "node", 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "noEmit": true, 18 | "jsx": "react-jsx" 19 | }, 20 | "include": ["src"] 21 | } 22 | -------------------------------------------------------------------------------- /packages/shared/types/files.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.mp3' { 2 | const src: string; 3 | export default src; 4 | } 5 | -------------------------------------------------------------------------------- /packages/shared/types/styled.d.ts: -------------------------------------------------------------------------------- 1 | // styled.d.t.s 2 | 3 | import 'styled-components'; 4 | 5 | declare module 'styled-components' { 6 | export interface DefaultTheme { 7 | fontColor: string; 8 | secondaryFontColor: string; 9 | thirdFontColor: string; 10 | fontColorInverse: string; 11 | backgroundColor: string; 12 | secondaryBackgroundColor: string; 13 | thirdBackgroundColor: string; 14 | backgroundColorInverse: string; 15 | scrollbarColor: string; 16 | borderColor: string; 17 | tooltipBackgroundColor: string; 18 | placeholder: string; 19 | brighterInputFont: string; 20 | tooltipShadow: string; 21 | chatbarSecondaryBackground: string; 22 | inputColor: string; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /packages/web/.env: -------------------------------------------------------------------------------- 1 | SKIP_PREFLIGHT_CHECK=true -------------------------------------------------------------------------------- /packages/web/README.md: -------------------------------------------------------------------------------- 1 | # Figma Chat Web-Client 2 | 3 | This package includes the Figma Chat web client. You can find the main instance here https://figma-chat.vercel.app/. 4 | 5 | ## But Why? 6 | 7 | Quite simple. People who don't have rights to edit your Figma file can't run plugins. 8 | 9 | ## How to use? 10 | 11 | Open the plugin inside your file and go to settings. Send https://figma-chat.vercel.app/ to the person you want to chat with and copy the "Auth-String" for them. This serves as a password, so don't give it to anyone else. 12 | **Only people can enter a room if at least one person in the figma file is online.** 13 | 14 | 15 | -------------------------------------------------------------------------------- /packages/web/config/env.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | const paths = require('./paths'); 6 | 7 | // Make sure that including paths.js after env.js will read .env variables. 8 | delete require.cache[require.resolve('./paths')]; 9 | 10 | const NODE_ENV = process.env.NODE_ENV; 11 | if (!NODE_ENV) { 12 | throw new Error( 13 | 'The NODE_ENV environment variable is required but was not specified.' 14 | ); 15 | } 16 | 17 | // https://github.com/bkeepers/dotenv#what-other-env-files-can-i-use 18 | const dotenvFiles = [ 19 | `${paths.dotenv}.${NODE_ENV}.local`, 20 | // Don't include `.env.local` for `test` environment 21 | // since normally you expect tests to produce the same 22 | // results for everyone 23 | NODE_ENV !== 'test' && `${paths.dotenv}.local`, 24 | `${paths.dotenv}.${NODE_ENV}`, 25 | paths.dotenv, 26 | ].filter(Boolean); 27 | 28 | // Load environment variables from .env* files. Suppress warnings using silent 29 | // if this file is missing. dotenv will never modify any environment variables 30 | // that have already been set. Variable expansion is supported in .env files. 31 | // https://github.com/motdotla/dotenv 32 | // https://github.com/motdotla/dotenv-expand 33 | dotenvFiles.forEach(dotenvFile => { 34 | if (fs.existsSync(dotenvFile)) { 35 | require('dotenv-expand').expand( 36 | require('dotenv').config({ 37 | path: dotenvFile, 38 | }) 39 | ); 40 | } 41 | }); 42 | 43 | // We support resolving modules according to `NODE_PATH`. 44 | // This lets you use absolute paths in imports inside large monorepos: 45 | // https://github.com/facebook/create-react-app/issues/253. 46 | // It works similar to `NODE_PATH` in Node itself: 47 | // https://nodejs.org/api/modules.html#modules_loading_from_the_global_folders 48 | // Note that unlike in Node, only *relative* paths from `NODE_PATH` are honored. 49 | // Otherwise, we risk importing Node.js core modules into an app instead of webpack shims. 50 | // https://github.com/facebook/create-react-app/issues/1023#issuecomment-265344421 51 | // We also resolve them to make sure all tools using them work consistently. 52 | const appDirectory = fs.realpathSync(process.cwd()); 53 | process.env.NODE_PATH = (process.env.NODE_PATH || '') 54 | .split(path.delimiter) 55 | .filter(folder => folder && !path.isAbsolute(folder)) 56 | .map(folder => path.resolve(appDirectory, folder)) 57 | .join(path.delimiter); 58 | 59 | // Grab NODE_ENV and REACT_APP_* environment variables and prepare them to be 60 | // injected into the application via DefinePlugin in webpack configuration. 61 | const REACT_APP = /^REACT_APP_/i; 62 | 63 | function getClientEnvironment(publicUrl) { 64 | const raw = Object.keys(process.env) 65 | .filter(key => REACT_APP.test(key)) 66 | .reduce( 67 | (env, key) => { 68 | env[key] = process.env[key]; 69 | return env; 70 | }, 71 | { 72 | // Useful for determining whether we’re running in production mode. 73 | // Most importantly, it switches React into the correct mode. 74 | NODE_ENV: process.env.NODE_ENV || 'development', 75 | // Useful for resolving the correct path to static assets in `public`. 76 | // For example, . 77 | // This should only be used as an escape hatch. Normally you would put 78 | // images into the `src` and `import` them in code to get their paths. 79 | PUBLIC_URL: publicUrl, 80 | // We support configuring the sockjs pathname during development. 81 | // These settings let a developer run multiple simultaneous projects. 82 | // They are used as the connection `hostname`, `pathname` and `port` 83 | // in webpackHotDevClient. They are used as the `sockHost`, `sockPath` 84 | // and `sockPort` options in webpack-dev-server. 85 | WDS_SOCKET_HOST: process.env.WDS_SOCKET_HOST, 86 | WDS_SOCKET_PATH: process.env.WDS_SOCKET_PATH, 87 | WDS_SOCKET_PORT: process.env.WDS_SOCKET_PORT, 88 | // Whether or not react-refresh is enabled. 89 | // It is defined here so it is available in the webpackHotDevClient. 90 | FAST_REFRESH: process.env.FAST_REFRESH !== 'false', 91 | } 92 | ); 93 | // Stringify all values so we can feed into webpack DefinePlugin 94 | const stringified = { 95 | 'process.env': Object.keys(raw).reduce((env, key) => { 96 | env[key] = JSON.stringify(raw[key]); 97 | return env; 98 | }, {}), 99 | }; 100 | 101 | return { raw, stringified }; 102 | } 103 | 104 | module.exports = getClientEnvironment; 105 | -------------------------------------------------------------------------------- /packages/web/config/getHttpsConfig.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | const crypto = require('crypto'); 6 | const chalk = require('react-dev-utils/chalk'); 7 | const paths = require('./paths'); 8 | 9 | // Ensure the certificate and key provided are valid and if not 10 | // throw an easy to debug error 11 | function validateKeyAndCerts({ cert, key, keyFile, crtFile }) { 12 | let encrypted; 13 | try { 14 | // publicEncrypt will throw an error with an invalid cert 15 | encrypted = crypto.publicEncrypt(cert, Buffer.from('test')); 16 | } catch (err) { 17 | throw new Error( 18 | `The certificate "${chalk.yellow(crtFile)}" is invalid.\n${err.message}` 19 | ); 20 | } 21 | 22 | try { 23 | // privateDecrypt will throw an error with an invalid key 24 | crypto.privateDecrypt(key, encrypted); 25 | } catch (err) { 26 | throw new Error( 27 | `The certificate key "${chalk.yellow(keyFile)}" is invalid.\n${ 28 | err.message 29 | }` 30 | ); 31 | } 32 | } 33 | 34 | // Read file and throw an error if it doesn't exist 35 | function readEnvFile(file, type) { 36 | if (!fs.existsSync(file)) { 37 | throw new Error( 38 | `You specified ${chalk.cyan( 39 | type 40 | )} in your env, but the file "${chalk.yellow(file)}" can't be found.` 41 | ); 42 | } 43 | return fs.readFileSync(file); 44 | } 45 | 46 | // Get the https config 47 | // Return cert files if provided in env, otherwise just true or false 48 | function getHttpsConfig() { 49 | const { SSL_CRT_FILE, SSL_KEY_FILE, HTTPS } = process.env; 50 | const isHttps = HTTPS === 'true'; 51 | 52 | if (isHttps && SSL_CRT_FILE && SSL_KEY_FILE) { 53 | const crtFile = path.resolve(paths.appPath, SSL_CRT_FILE); 54 | const keyFile = path.resolve(paths.appPath, SSL_KEY_FILE); 55 | const config = { 56 | cert: readEnvFile(crtFile, 'SSL_CRT_FILE'), 57 | key: readEnvFile(keyFile, 'SSL_KEY_FILE'), 58 | }; 59 | 60 | validateKeyAndCerts({ ...config, keyFile, crtFile }); 61 | return config; 62 | } 63 | return isHttps; 64 | } 65 | 66 | module.exports = getHttpsConfig; 67 | -------------------------------------------------------------------------------- /packages/web/config/modules.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | const paths = require('./paths'); 6 | const chalk = require('react-dev-utils/chalk'); 7 | const resolve = require('resolve'); 8 | 9 | /** 10 | * Get additional module paths based on the baseUrl of a compilerOptions object. 11 | * 12 | * @param {Object} options 13 | */ 14 | function getAdditionalModulePaths(options = {}) { 15 | const baseUrl = options.baseUrl; 16 | 17 | if (!baseUrl) { 18 | return ''; 19 | } 20 | 21 | const baseUrlResolved = path.resolve(paths.appPath, baseUrl); 22 | 23 | // We don't need to do anything if `baseUrl` is set to `node_modules`. This is 24 | // the default behavior. 25 | if (path.relative(paths.appNodeModules, baseUrlResolved) === '') { 26 | return null; 27 | } 28 | 29 | // Allow the user set the `baseUrl` to `appSrc`. 30 | if (path.relative(paths.appSrc, baseUrlResolved) === '') { 31 | return [paths.appSrc]; 32 | } 33 | 34 | // If the path is equal to the root directory we ignore it here. 35 | // We don't want to allow importing from the root directly as source files are 36 | // not transpiled outside of `src`. We do allow importing them with the 37 | // absolute path (e.g. `src/Components/Button.js`) but we set that up with 38 | // an alias. 39 | if (path.relative(paths.appPath, baseUrlResolved) === '') { 40 | return null; 41 | } 42 | 43 | // Otherwise, throw an error. 44 | throw new Error( 45 | chalk.red.bold( 46 | "Your project's `baseUrl` can only be set to `src` or `node_modules`." + 47 | ' Create React App does not support other values at this time.' 48 | ) 49 | ); 50 | } 51 | 52 | /** 53 | * Get webpack aliases based on the baseUrl of a compilerOptions object. 54 | * 55 | * @param {*} options 56 | */ 57 | function getWebpackAliases(options = {}) { 58 | const baseUrl = options.baseUrl; 59 | 60 | if (!baseUrl) { 61 | return {}; 62 | } 63 | 64 | const baseUrlResolved = path.resolve(paths.appPath, baseUrl); 65 | 66 | if (path.relative(paths.appPath, baseUrlResolved) === '') { 67 | return { 68 | src: paths.appSrc, 69 | }; 70 | } 71 | } 72 | 73 | /** 74 | * Get jest aliases based on the baseUrl of a compilerOptions object. 75 | * 76 | * @param {*} options 77 | */ 78 | function getJestAliases(options = {}) { 79 | const baseUrl = options.baseUrl; 80 | 81 | if (!baseUrl) { 82 | return {}; 83 | } 84 | 85 | const baseUrlResolved = path.resolve(paths.appPath, baseUrl); 86 | 87 | if (path.relative(paths.appPath, baseUrlResolved) === '') { 88 | return { 89 | '^src/(.*)$': '/src/$1', 90 | }; 91 | } 92 | } 93 | 94 | function getModules() { 95 | // Check if TypeScript is setup 96 | const hasTsConfig = fs.existsSync(paths.appTsConfig); 97 | const hasJsConfig = fs.existsSync(paths.appJsConfig); 98 | 99 | if (hasTsConfig && hasJsConfig) { 100 | throw new Error( 101 | 'You have both a tsconfig.json and a jsconfig.json. If you are using TypeScript please remove your jsconfig.json file.' 102 | ); 103 | } 104 | 105 | let config; 106 | 107 | // If there's a tsconfig.json we assume it's a 108 | // TypeScript project and set up the config 109 | // based on tsconfig.json 110 | if (hasTsConfig) { 111 | const ts = require(resolve.sync('typescript', { 112 | basedir: paths.appNodeModules, 113 | })); 114 | config = ts.readConfigFile(paths.appTsConfig, ts.sys.readFile).config; 115 | // Otherwise we'll check if there is jsconfig.json 116 | // for non TS projects. 117 | } else if (hasJsConfig) { 118 | config = require(paths.appJsConfig); 119 | } 120 | 121 | config = config || {}; 122 | const options = config.compilerOptions || {}; 123 | 124 | const additionalModulePaths = getAdditionalModulePaths(options); 125 | 126 | return { 127 | additionalModulePaths: additionalModulePaths, 128 | webpackAliases: getWebpackAliases(options), 129 | jestAliases: getJestAliases(options), 130 | hasTsConfig, 131 | }; 132 | } 133 | 134 | module.exports = getModules(); 135 | -------------------------------------------------------------------------------- /packages/web/config/paths.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | const fs = require('fs'); 5 | const getPublicUrlOrPath = require('react-dev-utils/getPublicUrlOrPath'); 6 | 7 | // Make sure any symlinks in the project folder are resolved: 8 | // https://github.com/facebook/create-react-app/issues/637 9 | const appDirectory = fs.realpathSync(process.cwd()); 10 | const resolveApp = relativePath => path.resolve(appDirectory, relativePath); 11 | 12 | // We use `PUBLIC_URL` environment variable or "homepage" field to infer 13 | // "public path" at which the app is served. 14 | // webpack needs to know it to put the right
you