9 | Bookmarklet (drag it to your bookmarks bar) 10 |
11 |Look down!
12 | 13 | 14 | 30 | 31 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /dist/olm.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dreamside-digital/safesupport-chatbox/569a8e76c2c75bce5623e985238bc322d619bb47/dist/olm.wasm -------------------------------------------------------------------------------- /jest/cssTransform.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | process() { 3 | return 'module.exports = {};'; 4 | }, 5 | getCacheKey() { 6 | return 'cssTransform'; 7 | }, 8 | }; 9 | -------------------------------------------------------------------------------- /jest/fileTransform.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | process(src, filename) { 5 | return `module.exports = ${JSON.stringify(path.basename(filename))};`; 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /jest/setup.js: -------------------------------------------------------------------------------- 1 | import Enzyme from 'enzyme'; 2 | import Adapter from 'enzyme-adapter-react-16'; 3 | 4 | Enzyme.configure({ adapter: new Adapter() }); 5 | 6 | Object.defineProperty(document, 'readyState', { 7 | value: 'complete', 8 | writable: true, 9 | enumerable: true, 10 | configurable: true, 11 | }); 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "safesupport-chatbox", 3 | "version": "1.1.5", 4 | "description": "A secure and private embeddable chatbox that connects to Riot", 5 | "main": "dist/chatbox.js", 6 | "scripts": { 7 | "build": "NODE_ENV=production webpack-cli --mode production", 8 | "start": "webpack-dev-server", 9 | "test": "jest", 10 | "deploy": "yarn build && gh-pages -d dist", 11 | "lint": "./node_modules/.bin/eslint ." 12 | }, 13 | "babel": { 14 | "presets": [ 15 | "airbnb", 16 | [ 17 | "@babel/preset-env", 18 | { 19 | "targets": { 20 | "node": "12" 21 | } 22 | } 23 | ], 24 | "@babel/preset-react" 25 | ], 26 | "plugins": [ 27 | "@babel/plugin-syntax-dynamic-import", 28 | "@babel/plugin-syntax-import-meta", 29 | "@babel/plugin-proposal-class-properties", 30 | "@babel/plugin-proposal-json-strings", 31 | [ 32 | "@babel/plugin-proposal-decorators", 33 | { 34 | "legacy": true 35 | } 36 | ], 37 | "@babel/plugin-proposal-function-sent", 38 | "@babel/plugin-proposal-export-namespace-from", 39 | "@babel/plugin-proposal-numeric-separator", 40 | "@babel/plugin-proposal-throw-expressions", 41 | "@babel/plugin-transform-runtime" 42 | ] 43 | }, 44 | "browserslist": "> 0.25%, not dead", 45 | "jest": { 46 | "coverageDirectory": "./coverage/", 47 | "collectCoverage": true, 48 | "collectCoverageFrom": [ 49 | "9 | Bookmarklet (drag it to your bookmarks bar) 10 |
11 |Look down!
12 | 13 | 14 | 30 | 31 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /public/olm.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dreamside-digital/safesupport-chatbox/569a8e76c2c75bce5623e985238bc322d619bb47/public/olm.wasm -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base" 4 | ], 5 | "automerge": true, 6 | "major": { 7 | "automerge": false 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/components/_chat.scss: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | @keyframes slideInUp { 6 | from { 7 | transform: translate3d(0, 100%, 0); 8 | display: inherit; 9 | visibility: visible; 10 | } 11 | 12 | to { 13 | transform: translate3d(0, 0, 0); 14 | } 15 | } 16 | 17 | @keyframes slideOutDown { 18 | from { 19 | transform: translate3d(0, 0, 0); 20 | } 21 | 22 | to { 23 | display: none; 24 | visibility: hidden; 25 | transform: translate3d(0, 100%, 0); 26 | } 27 | } 28 | 29 | .docked-widget { 30 | position: fixed; 31 | bottom: 10px; 32 | right: 10px; 33 | z-index: 9999; 34 | width: 400px; 35 | max-width: 100vw; 36 | font-size: $base-font-size; 37 | } 38 | 39 | .dock { 40 | cursor: pointer; 41 | display: flex; 42 | align-items: center; 43 | justify-content: space-between; 44 | width: 400px; 45 | max-width: calc(100vw - 10px); 46 | color: $white; 47 | font-family: $theme-font; 48 | font-size: 1em; 49 | border: none; 50 | color: $white; 51 | font-size: 1em; 52 | line-height: 1; 53 | background-color: transparent; 54 | padding: 5px; 55 | 56 | #open-chatbox-label { 57 | background: $theme-color; 58 | padding: 0.75em; 59 | flex: 1 1 auto; 60 | text-align: left; 61 | margin-right: 0.25em; 62 | border: 1px solid $white; 63 | border-radius: 0.625em; 64 | transition: all 0.2s ease-in-out; 65 | } 66 | 67 | .label-icon { 68 | background: $theme-color; 69 | height: 2.625em; 70 | width: 2.625em; 71 | border-radius: 2.625em; 72 | display: flex; 73 | justify-content: center; 74 | align-items: center; 75 | border: 1px solid $white; 76 | transition: all 0.2s ease-in-out; 77 | } 78 | 79 | &:hover { 80 | #open-chatbox-label, .label-icon { 81 | border: 1px solid $dark-color; 82 | box-shadow: inset 0px 0px 0px 1px $dark-color; 83 | } 84 | } 85 | 86 | &:focus { 87 | outline: none; 88 | 89 | #open-chatbox-label, .label-icon { 90 | border: 1px solid $dark-color; 91 | box-shadow: inset 0px 0px 0px 1px $dark-color; 92 | background-color: $theme-highlight-color; 93 | } 94 | } 95 | } 96 | 97 | 98 | .widget { 99 | width: 400px; 100 | max-width: calc(100vw - 10px); 101 | border-bottom: none; 102 | margin: 0; 103 | animation-duration: 0.2s; 104 | animation-fill-mode: forwards; 105 | 106 | &-entering { 107 | animation-name: slideInUp; 108 | } 109 | &-entered { 110 | display: inherit; 111 | visibility: visible; 112 | } 113 | &-exiting { 114 | animation-name: slideOutDown; 115 | } 116 | &-exited { 117 | display: none; 118 | visibility: hidden; 119 | } 120 | 121 | &-header { 122 | display: flex; 123 | align-items: center; 124 | margin-bottom: 0.2em; 125 | justify-content: flex-end; 126 | flex: 0 0 auto; 127 | 128 | &-title { 129 | display: flex; 130 | flex-grow: 1; 131 | } 132 | 133 | &-minimize { 134 | cursor: pointer; 135 | display: flex; 136 | align-items: center; 137 | justify-content: flex-start; 138 | border: 1px solid $dark-color !important; 139 | background: $white; 140 | color: $dark-color; 141 | flex: 1 1 auto; 142 | font-family: $theme-font; 143 | font-size: 1em; 144 | padding: 0.5em; 145 | border-radius: 0.625em; 146 | transition: all 0.2s ease-in-out; 147 | 148 | &:hover { 149 | box-shadow: inset 0px 0px 0px 1px $dark-color; 150 | } 151 | 152 | &:focus { 153 | outline: none; 154 | box-shadow: inset 0px 0px 0px 1px $dark-color; 155 | background-color: $theme-light-color; 156 | } 157 | } 158 | 159 | &-close { 160 | font-size: inherit; 161 | cursor: pointer; 162 | display: flex; 163 | align-items: center; 164 | justify-content: center; 165 | border: 1px solid $dark-color !important; 166 | background: $white; 167 | border-radius: 2.625em; 168 | padding: 0.5em; 169 | margin-left: 0.2em; 170 | color: $dark-color; 171 | transition: all 0.2s ease-in-out; 172 | width: 2.625em; 173 | 174 | &:hover { 175 | box-shadow: inset 0px 0px 0px 1px $dark-color; 176 | } 177 | 178 | &:focus { 179 | outline: none; 180 | box-shadow: inset 0px 0px 0px 1px $dark-color; 181 | background-color: $theme-light-color; 182 | } 183 | } 184 | } 185 | &-body { 186 | background: white; 187 | padding: 10px; 188 | height: 150px; 189 | } 190 | &-footer { 191 | background: green; 192 | line-height: 30px; 193 | padding-left: 10px; 194 | } 195 | 196 | button { 197 | transition: all 0.2s ease-in-out; 198 | 199 | &:hover { 200 | box-shadow: inset 0px 0px 0px 1px $dark-color; 201 | } 202 | 203 | &:focus { 204 | background-color: $theme-light-color; 205 | outline: none; 206 | } 207 | } 208 | } 209 | 210 | .btn-icon { 211 | font-size: 1.5em; 212 | line-height: 1; 213 | transform: rotateX(0deg); 214 | transition: all 0.5s linear; 215 | display: flex; 216 | align-items: center; 217 | justify-content: center; 218 | } 219 | 220 | .arrow { 221 | margin-right: 0.5em; 222 | transform: translateY(0.15em); 223 | 224 | &.opened { 225 | color: $dark-color; 226 | transform: rotateX(180deg) translateY(0.15em); 227 | } 228 | 229 | } 230 | 231 | #safesupport-chatbox { 232 | font-family: $theme-font; 233 | display: flex; 234 | flex-direction: column; 235 | height: calc(40vh + 180px); 236 | max-height: 100vh; 237 | padding: 5px; 238 | 239 | a { 240 | color: inherit; 241 | transition: all 0.2s ease-in-out; 242 | 243 | &:hover, &:focus { 244 | color: $theme-color; 245 | } 246 | } 247 | 248 | .message-window { 249 | background-color: $white; 250 | border: 1px solid $dark-color; 251 | flex: 1 1 auto; 252 | padding: 0.5em; 253 | overflow: scroll; 254 | display: flex; 255 | flex-direction: column-reverse; 256 | justify-content: space-between; 257 | margin-bottom: 0.2em; 258 | border-radius: 0.625em; 259 | } 260 | 261 | .notices { 262 | color: $gray-color; 263 | font-size: 0.9em; 264 | 265 | > div { 266 | margin-top: 0.5em; 267 | margin-bottom: 0.5em; 268 | } 269 | } 270 | 271 | .message { 272 | margin-top: 0.5em; 273 | margin-bottom: 0.5em; 274 | 275 | .text { 276 | width: fit-content; 277 | line-height: 1.2; 278 | } 279 | 280 | .buttons { 281 | display: flex; 282 | align-items: center; 283 | 284 | button { 285 | background-color: transparent; 286 | padding: 0.25em 0.5em; 287 | font-size: 0.9em; 288 | color: inherit; 289 | font-weight: bold; 290 | font-family: $theme-font; 291 | cursor: pointer; 292 | display: flex; 293 | flex: 0 1 auto; 294 | border: 1px solid $theme-color; 295 | transition: all 0.2s ease-in-out; 296 | border-radius: 0.625em; 297 | margin-left: 0.25em; 298 | 299 | &:hover { 300 | border: 1px solid $dark-color; 301 | box-shadow: inset 0px 0px 0px 1px $dark-color; 302 | } 303 | 304 | &:focus { 305 | outline: none; 306 | color: $white; 307 | border: 1px solid $dark-color; 308 | box-shadow: inset 0px 0px 0px 1px $dark-color; 309 | background-color: $theme-highlight-color; 310 | } 311 | } 312 | } 313 | 314 | 315 | &.from-bot { 316 | color: $gray-color; 317 | font-size: 0.9em; 318 | } 319 | 320 | &.from-me { 321 | display: flex; 322 | justify-content: flex-end; 323 | 324 | &.placeholder { 325 | opacity: 0.5; 326 | } 327 | 328 | .text { 329 | border: 1px solid $theme-color; 330 | background-color: $theme-color; 331 | color: $white; 332 | border-radius: 1em 1em 0 1em; 333 | margin-left: 10%; 334 | padding: 0.3em 0.6em; 335 | } 336 | 337 | a { 338 | color: $white; 339 | 340 | &:hover, &:focus { 341 | color: $light-purple; 342 | } 343 | } 344 | } 345 | 346 | &.from-support { 347 | display: flex; 348 | justify-content: flex-start; 349 | 350 | .text { 351 | border: 1px solid $light-color; 352 | background-color: $light-color; 353 | color: $dark-color; 354 | border-radius: 1em 1em 1em 0; 355 | margin-right: 10%; 356 | padding: 0.5em 0.75em; 357 | } 358 | 359 | a { 360 | color: $dark-color; 361 | 362 | &:hover, &:focus { 363 | color: $medium-purple; 364 | } 365 | } 366 | } 367 | } 368 | 369 | .input-window { 370 | flex: 0 0 auto; 371 | 372 | form { 373 | display: flex; 374 | align-items: center; 375 | margin-bottom: 0; 376 | } 377 | 378 | input[type="submit"] { 379 | background-color: $theme-color; 380 | height: 100%; 381 | padding: 0.5em 1em; 382 | font-size: 1em; 383 | color: $white; 384 | font-weight: bold; 385 | font-family: $theme-font; 386 | cursor: pointer; 387 | display: flex; 388 | flex: 0 1 auto; 389 | border: 1px solid $theme-color; 390 | transition: all 0.2s ease-in-out; 391 | border-radius: 0.625em; 392 | 393 | &:hover { 394 | border: 1px solid $dark-color; 395 | box-shadow: inset 0px 0px 0px 1px $dark-color; 396 | } 397 | 398 | &:focus { 399 | outline: none; 400 | border: 1px solid $dark-color; 401 | box-shadow: inset 0px 0px 0px 1px $dark-color; 402 | background-color: $theme-highlight-color; 403 | } 404 | } 405 | 406 | .message-input-container { 407 | display: flex; 408 | flex: 1 1 auto; 409 | position: relative; 410 | 411 | input[type="text"] { 412 | font-size: 1em; 413 | padding: 0.5em; 414 | padding-right: 32px; 415 | border: none; 416 | display: flex; 417 | flex: 1 1 auto; 418 | background: $white; 419 | color: $dark-color; 420 | font-family: $theme-font; 421 | margin-right: 0.2em; 422 | transition: all 0.2s ease-in-out; 423 | border-radius: 0.625em; 424 | border: 1px solid $dark-color; 425 | 426 | &:hover { 427 | box-shadow: inset 0px 0px 0px 1px $dark-color; 428 | } 429 | 430 | &:focus { 431 | outline: none; 432 | box-shadow: inset 0px 0px 0px 1px $dark-color; 433 | background: $theme-light-color; 434 | } 435 | } 436 | 437 | .emoji-button-container { 438 | position: absolute; 439 | right: 6px; 440 | top: 0; 441 | bottom: 0; 442 | height: 100%; 443 | display: flex; 444 | align-items: center; 445 | justify-content: flex-start; 446 | 447 | 448 | button { 449 | transition: all 0.2s ease-in-out; 450 | 451 | &:hover { 452 | box-shadow: none; 453 | } 454 | 455 | &:focus { 456 | outline: none; 457 | } 458 | 459 | emoji-button { 460 | background: transparent; 461 | border: none; 462 | padding: 0; 463 | margin-right: 3px; 464 | transition: all 0.2s ease-in-out; 465 | 466 | &:hover { 467 | svg path#icon { 468 | fill: $theme-color; 469 | } 470 | } 471 | 472 | &:focus { 473 | svg path#icon { 474 | fill: $theme-highlight-color; 475 | } 476 | } 477 | } 478 | } 479 | } 480 | 481 | .emoji-picker { 482 | animation-duration: 0.2s; 483 | animation-fill-mode: forwards; 484 | position: absolute; 485 | bottom: 32px; 486 | right: -4px; 487 | 488 | &-entering { 489 | animation-name: slideInUp; 490 | opacity: 0.5; 491 | } 492 | &-entered { 493 | display: inherit; 494 | visibility: visible; 495 | opacity: 1; 496 | } 497 | &-exiting { 498 | animation-name: slideOutDown; 499 | opacity: 0.5; 500 | } 501 | &-exited { 502 | display: none; 503 | visibility: hidden; 504 | opacity: 0; 505 | } 506 | } 507 | } 508 | } 509 | 510 | .highlight-text { 511 | color: $theme-color; 512 | } 513 | 514 | .pos-relative { 515 | position: relative; 516 | } 517 | } 518 | 519 | .hidden { 520 | display: none; 521 | } 522 | 523 | @media screen and (max-width: 420px){ 524 | .docked-widget { 525 | right: 0; 526 | left: 0; 527 | bottom: 0; 528 | } 529 | 530 | .dock, .widget { 531 | width: 100vw; 532 | max-width: 100vw; 533 | padding: 5px; 534 | } 535 | 536 | #safesupport-chatbox { 537 | height: calc(180px + 60vh); 538 | } 539 | } 540 | 541 | @media screen and (max-width: 360px){ 542 | #safesupport-chatbox .input-window .message-input-container .emoji-picker { 543 | position: fixed; 544 | left: 5px; 545 | right: 5px; 546 | bottom: 42px; 547 | } 548 | } 549 | -------------------------------------------------------------------------------- /src/components/_dark_mode.scss: -------------------------------------------------------------------------------- 1 | @media (prefers-color-scheme: dark) { 2 | 3 | .loader { 4 | color: $dark-theme-color; 5 | } 6 | 7 | .dock { 8 | #open-chatbox-label, .label-icon { 9 | border-color: $white; 10 | } 11 | 12 | &:hover { 13 | #open-chatbox-label, .label-icon { 14 | border: 1px solid $dark-color; 15 | box-shadow: inset 0px 0px 0px 1px $dark-color; 16 | } 17 | } 18 | } 19 | 20 | .widget-header-minimize, .widget-header-close { 21 | background: $dark-background-color; 22 | color: $light-text-color; 23 | border: 1px solid $white; 24 | transition: all 0.2s ease-in-out; 25 | 26 | &:hover { 27 | border-color: $theme-color; 28 | box-shadow: inset 0px 0px 0px 1px $theme-color; 29 | } 30 | 31 | &:focus { 32 | border-color: $theme-color; 33 | background: $dark-theme-highlight-color; 34 | box-shadow: inset 0px 0px 0px 1px $theme-color; 35 | outline: none; 36 | } 37 | } 38 | 39 | .widget { 40 | button { 41 | transition: all 0.2s ease-in-out; 42 | 43 | &:hover { 44 | border-color: $theme-color; 45 | box-shadow: inset 0px 0px 0px 1px $theme-color; 46 | } 47 | 48 | &:focus { 49 | background: $dark-theme-highlight-color; 50 | box-shadow: inset 0px 0px 0px 1px $theme-color; 51 | outline: none; 52 | } 53 | } 54 | } 55 | #safesupport-chatbox { 56 | .btn-icon { 57 | color: $light-text-color; 58 | } 59 | .message-window { 60 | background-color: $dark-background-color; 61 | border: 1px solid $white; 62 | } 63 | 64 | .notices { 65 | color: transparentize($light-text-color, 0.3); 66 | } 67 | 68 | .message { 69 | &.from-bot { 70 | color: transparentize($light-text-color, 0.3); 71 | } 72 | 73 | &.from-me { 74 | .text { 75 | background-color: $theme-color; 76 | color: $light-text-color; 77 | border: 1px solid $theme-color; 78 | } 79 | } 80 | 81 | &.from-support { 82 | .text { 83 | background-color: $dark-theme-color; 84 | color: $light-text-color; 85 | border: 1px solid $dark-theme-color; 86 | } 87 | 88 | a { 89 | color: $light-text-color; 90 | 91 | &:hover, &:focus { 92 | color: $light-purple; 93 | } 94 | } 95 | } 96 | 97 | .buttons { 98 | button { 99 | background-color: transparent; 100 | border: 1px solid $theme-color; 101 | 102 | &:hover { 103 | border: 1px solid $light-purple; 104 | box-shadow: inset 0px 0px 0px 1px $light-purple; 105 | } 106 | 107 | &:focus { 108 | outline: none; 109 | color: $white; 110 | border: 1px solid $light-purple; 111 | box-shadow: inset 0px 0px 0px 1px $light-purple; 112 | background-color: $dark-theme-highlight-color; 113 | } 114 | } 115 | } 116 | } 117 | 118 | .input-window { 119 | .message-input-container { 120 | input[type="text"] { 121 | background-color: $dark-background-color; 122 | color: $light-text-color; 123 | border: 1px solid $white; 124 | 125 | &:hover { 126 | border: 1px solid $theme-color; 127 | box-shadow: inset 0px 0px 0px 1px $theme-color; 128 | } 129 | 130 | &:focus { 131 | outline: none; 132 | border: 1px solid $theme-color; 133 | box-shadow: inset 0px 0px 0px 1px $theme-color; 134 | background: $dark-theme-highlight-color; 135 | } 136 | } 137 | 138 | ::placeholder { 139 | color: transparentize($light-text-color, 0.3); 140 | } 141 | 142 | .emoji-button-container { 143 | button { 144 | emoji-button { 145 | 146 | &:hover { 147 | svg path#icon { 148 | fill: $theme-color; 149 | } 150 | } 151 | 152 | &:focus { 153 | svg path#icon { 154 | fill: $light-purple; 155 | } 156 | } 157 | } 158 | } 159 | } 160 | 161 | } 162 | 163 | input[type="submit"] { 164 | background-color: $dark-theme-color; 165 | color: $light-text-color; 166 | border: 1px solid $white; 167 | 168 | &:hover { 169 | border: 1px solid $theme-color; 170 | box-shadow: inset 0px 0px 0px 1px $theme-color; 171 | } 172 | 173 | &:focus { 174 | outline: none; 175 | border: 1px solid $theme-color; 176 | box-shadow: inset 0px 0px 0px 1px $theme-color; 177 | background-color: $dark-theme-highlight-color; 178 | } 179 | } 180 | } 181 | 182 | .highlight-text { 183 | color: $light-text-color; 184 | } 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /src/components/_loader.scss: -------------------------------------------------------------------------------- 1 | .loader, 2 | .loader:before, 3 | .loader:after { 4 | border-radius: 50%; 5 | width: 2.5em; 6 | height: 2.5em; 7 | -webkit-animation-fill-mode: both; 8 | animation-fill-mode: both; 9 | -webkit-animation: load7 1.8s infinite ease-in-out; 10 | animation: load7 1.8s infinite ease-in-out; 11 | } 12 | .loader { 13 | color: $theme-color; 14 | font-size: 10px; 15 | margin: 1rem auto; 16 | margin-bottom: 2rem; 17 | position: relative; 18 | text-indent: -9999em; 19 | -webkit-transform: translateZ(0); 20 | -ms-transform: translateZ(0); 21 | transform: translateZ(0); 22 | -webkit-animation-delay: -0.16s; 23 | animation-delay: -0.16s; 24 | } 25 | .loader:before, 26 | .loader:after { 27 | content: ''; 28 | position: absolute; 29 | top: 0; 30 | } 31 | .loader:before { 32 | left: -3.5em; 33 | -webkit-animation-delay: -0.32s; 34 | animation-delay: -0.32s; 35 | } 36 | .loader:after { 37 | left: 3.5em; 38 | } 39 | @keyframes load7 { 40 | 0%, 41 | 80%, 42 | 100% { 43 | box-shadow: 0 2.5em 0 -1.3em; 44 | } 45 | 40% { 46 | box-shadow: 0 2.5em 0 0; 47 | } 48 | } -------------------------------------------------------------------------------- /src/components/_variables.scss: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css?family=Assistant&display=swap'); 2 | 3 | $purple: #785BEC; 4 | $light-purple: #ebe6fc; 5 | $medium-purple: #4D3A97; 6 | $charcoal: #828282; 7 | $light-color: #F2F2F2; 8 | $gray-color: $charcoal; 9 | $dark-color: #04090F; 10 | $yellow: #FFFACD; 11 | $dark-blue: #2660A4; 12 | $white: #ffffff; 13 | $highlight-color: $yellow; 14 | $theme-color: $purple; 15 | $theme-light-color: $light-purple; 16 | $theme-font: 'Assistant', 'Helvetica', sans-serif; 17 | $theme-highlight-color: $medium-purple; 18 | 19 | 20 | /* Dark mode colors */ 21 | 22 | $dark-background-color: #0F1116; 23 | $light-background-color: #ffffff; 24 | $light-text-color: #ffffff; 25 | $dark-text-color: #0F1116; 26 | $dark-theme-color: #4F4F4F; 27 | $dark-theme-highlight-color: #211943; 28 | 29 | 30 | $base-font-size: 16px; -------------------------------------------------------------------------------- /src/components/chatbox.jsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import PropTypes from "prop-types" 3 | import { Transition } from 'react-transition-group'; 4 | import * as util from "util"; 5 | import * as os from "os"; 6 | import * as path from "path"; 7 | import * as fs from "fs"; 8 | import { LocalStorage } from "node-localstorage"; 9 | import * as olm from "olm/olm_legacy.js" 10 | global.Olm = olm 11 | 12 | import * as matrix from "matrix-js-sdk"; 13 | import {uuid} from "uuidv4" 14 | 15 | import Message from "./message"; 16 | import Dock from "./dock"; 17 | import Header from "./header"; 18 | import EmojiSelector from './emoji-selector'; 19 | 20 | import './styles.scss'; 21 | 22 | 23 | const ENCRYPTION_CONFIG = { "algorithm": "m.megolm.v1.aes-sha2" }; 24 | const ENCRYPTION_NOTICE = "Messages in this chat are secured with end-to-end encryption." 25 | const UNENCRYPTION_NOTICE = "Messages in this chat are not encrypted." 26 | const RESTARTING_UNENCRYPTED_CHAT_MESSAGE = "Restarting chat without encryption." 27 | const WAIT_TIME_MS = 120000 // 2 minutes 28 | const CHAT_IS_OFFLINE_NOTICE = "Chat is offline" 29 | 30 | const DEFAULT_MATRIX_SERVER = "https://matrix.rhok.space/" 31 | const DEFAULT_BOT_ID = "@help-bot:rhok.space" 32 | const DEFAULT_TERMS_URL = "https://tosdr.org/" 33 | const DEFAULT_ROOM_NAME = "Support Chat" 34 | const DEFAULT_INTRO_MESSAGE = "This chat application does not collect any of your personal data or any data from your use of this service." 35 | const DEFAULT_AGREEMENT_MESSAGE = "Do you want to continue?" 36 | const DEFAULT_CONFIRMATION_MESSAGE = "Waiting for a facilitator to join the chat..." 37 | const DEFAULT_EXIT_MESSAGE = "The chat is closed. You may close this window." 38 | const DEFAULT_ANONYMOUS_DISPLAY_NAME="Anonymous" 39 | const DEFAULT_CHAT_UNAVAILABLE_MESSAGE = "The chat service is not available right now. Please try again later." 40 | const DEFAULT_WAIT_MESSAGE = "Please be patient, our online facilitators are currently responding to other support requests." 41 | 42 | 43 | class ChatBox extends React.Component { 44 | constructor(props) { 45 | super(props) 46 | this.initialState = { 47 | opened: false, 48 | showDock: true, 49 | client: null, 50 | ready: true, 51 | accessToken: null, 52 | userId: null, 53 | password: null, 54 | localStorage: null, 55 | messages: [], 56 | inputValue: "", 57 | errors: [], 58 | roomId: null, 59 | typingStatus: null, 60 | awaitingAgreement: true, 61 | emojiSelectorOpen: false, 62 | facilitatorInvited: false, 63 | isMobile: true, 64 | isSlowConnection: true, 65 | decryptionErrors: {}, 66 | messagesInFlight: [] 67 | } 68 | this.state = this.initialState 69 | this.chatboxInput = React.createRef(); 70 | this.messageWindow = React.createRef(); 71 | this.termsUrl = React.createRef(); 72 | } 73 | 74 | detectMobile = () => { 75 | let isMobile = false; 76 | 77 | if ( /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) ) { 78 | isMobile = true; 79 | } 80 | 81 | if (screen.width < 767) { 82 | isMobile = true; 83 | } 84 | 85 | this.setState({ isMobile }) 86 | } 87 | 88 | detectSlowConnection = () => { 89 | let isSlowConnection = false; 90 | 91 | const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection; 92 | 93 | if (typeof connection !== 'undefined' || connection === null) { 94 | const connectionType = connection.effectiveType; 95 | const slowConnections = ['slow-2g', '2g'] 96 | 97 | isSlowConnection = slowConnections.includes(connectionType) 98 | } 99 | 100 | this.setState({ isSlowConnection }) 101 | } 102 | 103 | handleToggleOpen = () => { 104 | this.setState((prev) => { 105 | let { showDock } = prev; 106 | if (!prev.opened) { 107 | showDock = false; 108 | } 109 | return { 110 | showDock, 111 | opened: !prev.opened, 112 | }; 113 | }); 114 | } 115 | 116 | toggleEmojiSelector = (e) => { 117 | e.preventDefault(); 118 | this.setState({ emojiSelectorOpen: !this.state.emojiSelectorOpen }) 119 | } 120 | 121 | closeEmojiSelector = () => { 122 | this.setState({ emojiSelectorOpen: false }) 123 | } 124 | 125 | handleWidgetExit = () => { 126 | this.setState({ 127 | showDock: true, 128 | }); 129 | } 130 | 131 | handleWidgetEnter = () => { 132 | if (this.state.awaitingAgreement) { 133 | this.termsUrl.current.focus() 134 | } else { 135 | this.chatboxInput.current.focus() 136 | } 137 | } 138 | 139 | handleExitChat = () => { 140 | if (this.state.client) { 141 | this.exitChat() 142 | } else { 143 | this.setState(this.initialState) 144 | } 145 | } 146 | 147 | exitChat = async () => { 148 | if (!this.state.client) return null; 149 | 150 | await this.state.client.leave(this.state.roomId) 151 | 152 | const auth = { 153 | type: 'm.login.password', 154 | user: this.state.userId, 155 | identifier: { 156 | type: "m.id.user", 157 | user: this.state.userId, 158 | }, 159 | password: this.state.password, 160 | }; 161 | 162 | await this.state.client.deactivateAccount(auth, true) 163 | await this.state.client.stopClient() 164 | await this.state.client.clearStores() 165 | 166 | this.state.localStorage.clear() 167 | this.setState(this.initialState) 168 | } 169 | 170 | createLocalStorage = async (deviceId, sessionId) => { 171 | let localStorage = global.localStorage; 172 | if (typeof localStorage === "undefined" || localStorage === null) { 173 | const deviceDesc = `matrix-chat-${deviceId}-${sessionId}` 174 | const localStoragePath = path.resolve(path.join(os.homedir(), ".local-storage", deviceDesc)) 175 | localStorage = new LocalStorage(localStoragePath); 176 | } 177 | return localStorage; 178 | } 179 | 180 | createClientWithAccount = async () => { 181 | const tmpClient = matrix.createClient(this.props.matrixServerUrl) 182 | 183 | try { 184 | await tmpClient.registerRequest({}) 185 | } catch(err) { 186 | const username = uuid() 187 | const password = uuid() 188 | const sessionId = err.data.session 189 | 190 | const account = await tmpClient.registerRequest({ 191 | auth: {session: sessionId, type: "m.login.dummy"}, 192 | inhibit_login: false, 193 | password: password, 194 | username: username, 195 | x_show_msisdn: true, 196 | }) 197 | 198 | const localStorage = await this.createLocalStorage(account.device_id, sessionId) 199 | 200 | this.setState({ 201 | accessToken: account.access_token, 202 | userId: account.user_id, 203 | username: username, 204 | password: password, 205 | localStorage: localStorage, 206 | sessionId: sessionId, 207 | deviceId: account.device_id, 208 | }) 209 | 210 | let opts = { 211 | baseUrl: this.props.matrixServerUrl, 212 | accessToken: account.access_token, 213 | userId: account.user_id, 214 | deviceId: account.device_id, 215 | sessionStore: new matrix.WebStorageSessionStore(localStorage), 216 | } 217 | 218 | return matrix.createClient(opts) 219 | } 220 | } 221 | 222 | initializeChat = async () => { 223 | this.setState({ ready: false }) 224 | 225 | const client = await this.createClientWithAccount() 226 | this.setState({ 227 | client: client 228 | }) 229 | client.setDisplayName(this.props.anonymousDisplayName) 230 | this.setMatrixListeners(client) 231 | 232 | try { 233 | await client.initCrypto() 234 | } catch(err) { 235 | return this.initializeUnencryptedChat() 236 | } 237 | 238 | await client.startClient() 239 | await this.createRoom(client) 240 | } 241 | 242 | initializeUnencryptedChat = async () => { 243 | if (this.state.client) { 244 | this.state.client.leave(this.state.roomId) 245 | this.state.client.stopClient() 246 | this.state.client.clearStores() 247 | this.state.localStorage.clear() 248 | } 249 | 250 | this.setState({ 251 | ready: false, 252 | facilitatorInvited: false, 253 | decryptionErrors: {}, 254 | roomId: null, 255 | typingStatus: null, 256 | client: null, 257 | isCryptoEnabled: false, 258 | }) 259 | 260 | this.displayBotMessage({ body: RESTARTING_UNENCRYPTED_CHAT_MESSAGE }) 261 | 262 | let opts = { 263 | baseUrl: this.props.matrixServerUrl, 264 | accessToken: this.state.accessToken, 265 | userId: this.state.userId, 266 | deviceId: this.state.deviceId, 267 | } 268 | 269 | let client; 270 | client = matrix.createClient(opts) 271 | this.setState({ 272 | client: client, 273 | }) 274 | 275 | try { 276 | this.setMatrixListeners(client) 277 | client.setDisplayName(this.props.anonymousDisplayName) 278 | await this.createRoom(client) 279 | await client.startClient() 280 | this.displayBotMessage({ body: UNENCRYPTION_NOTICE }) 281 | } catch(err) { 282 | console.log("error", err) 283 | this.handleInitError(err) 284 | } 285 | 286 | } 287 | 288 | handleInitError = (err) => { 289 | console.log("error", err) 290 | this.displayBotMessage({ body: this.props.chatUnavailableMessage }) 291 | this.setState({ ready: true }) 292 | } 293 | 294 | handleDecryptionError = async (event, err) => { 295 | if (this.state.client) { 296 | const isCryptoEnabled = await this.state.client.isCryptoEnabled() 297 | const isRoomEncrypted = this.state.client.isRoomEncrypted(this.state.roomId) 298 | 299 | if (!isCryptoEnabled || !isRoomEncrypted) { 300 | return this.initializeUnencryptedChat() 301 | } 302 | } 303 | 304 | const eventId = event.getId() 305 | this.displayFakeMessage({ body: '** Unable to decrypt message **' }, event.getSender(), eventId) 306 | this.setState({ decryptionErrors: { [eventId]: true }}) 307 | } 308 | 309 | verifyAllRoomDevices = async (client, room) => { 310 | if (!room) return; 311 | if (!client) return; 312 | if (!this.state.isCryptoEnabled) return; 313 | 314 | let members = (await room.getEncryptionTargetMembers()).map(x => x["userId"]) 315 | let memberkeys = await client.downloadKeys(members); 316 | for (const userId in memberkeys) { 317 | for (const deviceId in memberkeys[userId]) { 318 | await client.setDeviceVerified(userId, deviceId); 319 | } 320 | } 321 | } 322 | 323 | createRoom = async (client) => { 324 | const currentDate = new Date() 325 | const chatDate = currentDate.toLocaleDateString() 326 | const chatTime = currentDate.toLocaleTimeString() 327 | let roomConfig = { 328 | room_alias_name: `private-support-chat-${uuid()}`, 329 | invite: [this.props.botId], 330 | visibility: 'private', 331 | name: `${chatTime}, ${chatDate} - ${this.props.roomName}`, 332 | } 333 | 334 | const isCryptoEnabled = await client.isCryptoEnabled() 335 | 336 | if (isCryptoEnabled) { 337 | roomConfig.initial_state = [ 338 | { 339 | type: 'm.room.encryption', 340 | state_key: '', 341 | content: ENCRYPTION_CONFIG, 342 | }, 343 | ] 344 | } 345 | 346 | const { room_id } = await client.createRoom(roomConfig) 347 | 348 | client.setPowerLevel(room_id, this.props.botId, 100) 349 | 350 | this.setState({ 351 | roomId: room_id, 352 | isCryptoEnabled 353 | }) 354 | } 355 | 356 | sendMessage = async (message) => { 357 | if (this.state.client && this.state.roomId) { 358 | try { 359 | await this.state.client.sendTextMessage(this.state.roomId, message) 360 | } catch(err) { 361 | switch (err["name"]) { 362 | case "UnknownDeviceError": 363 | Object.keys(err.devices).forEach((userId) => { 364 | Object.keys(err.devices[userId]).map(async (deviceId) => { 365 | await this.state.client.setDeviceKnown(userId, deviceId, true); 366 | }); 367 | }); 368 | this.sendMessage(message) 369 | break; 370 | default: 371 | this.displayBotMessage({ body: "Your message was not sent." }) 372 | console.log("Error sending message", err); 373 | } 374 | } 375 | } 376 | } 377 | 378 | displayFakeMessage = (content, sender, messageId=uuid()) => { 379 | const msgList = [...this.state.messages] 380 | const msg = { 381 | id: messageId, 382 | type: 'm.room.message', 383 | sender: sender, 384 | roomId: this.state.roomId, 385 | content: content, 386 | } 387 | msgList.push(msg) 388 | 389 | this.setState({ messages: msgList }) 390 | } 391 | 392 | displayBotMessage = (content, roomId) => { 393 | const msgList = [...this.state.messages] 394 | const msg = { 395 | id: uuid(), 396 | type: 'm.room.message', 397 | sender: this.props.botId, 398 | roomId: roomId || this.state.roomId, 399 | content: content, 400 | } 401 | msgList.push(msg) 402 | 403 | this.setState({ messages: msgList }) 404 | } 405 | 406 | handleMessageEvent = event => { 407 | const message = { 408 | id: event.getId(), 409 | type: event.getType(), 410 | sender: event.getSender(), 411 | roomId: event.getRoomId(), 412 | content: event.getContent(), 413 | } 414 | 415 | if (message.content.showToUser && message.content.showToUser !== this.state.userId) { 416 | return; 417 | } 418 | 419 | if (message.content.body.startsWith('!bot') && message.sender !== this.state.userId) { 420 | return; 421 | } 422 | 423 | const messagesInFlight = [...this.state.messagesInFlight] 424 | const placeholderMessageIndex = messagesInFlight.findIndex(msg => msg === message.content.body) 425 | if (placeholderMessageIndex > -1) { 426 | messagesInFlight.splice(placeholderMessageIndex, 1) 427 | this.setState({ messagesInFlight }) 428 | } 429 | 430 | // check for decryption error message and replace with decrypted message 431 | // or push message to messages array 432 | const messages = [...this.state.messages] 433 | const decryptionErrors = {...this.state.decryptionErrors} 434 | delete decryptionErrors[message.id] 435 | const existingMessageIndex = messages.findIndex(({ id }) => id === message.id) 436 | 437 | if (existingMessageIndex > -1) { 438 | messages.splice(existingMessageIndex, 1, message) 439 | } else { 440 | messages.push(message) 441 | } 442 | 443 | this.setState({ messages, decryptionErrors }) 444 | } 445 | 446 | 447 | handleKeyDown = (e) => { 448 | switch (e.keyCode) { 449 | case 27: 450 | if (this.state.emojiSelectorOpen) { 451 | this.closeEmojiSelector() 452 | } else if (this.state.opened) { 453 | this.handleToggleOpen() 454 | }; 455 | default: 456 | break; 457 | } 458 | } 459 | 460 | setMatrixListeners = client => { 461 | client.on("Room.timeline", (event, room) => { 462 | const eventType = event.getType() 463 | const content = event.getContent() 464 | const sender = event.getSender() 465 | 466 | if (eventType === "m.room.encryption") { 467 | this.displayBotMessage({ body: ENCRYPTION_NOTICE }, room.room_id) 468 | this.verifyAllRoomDevices(client, room) 469 | } 470 | 471 | if (eventType === "m.room.message" && !this.state.isCryptoEnabled) { 472 | if (event.isEncrypted()) { 473 | return; 474 | } 475 | this.handleMessageEvent(event) 476 | } 477 | 478 | if (eventType === "m.room.member" && content.membership === "invite" && sender === this.props.botId) { 479 | this.setState({ facilitatorInvited: true }) 480 | } 481 | 482 | if (eventType === "m.room.member" && content.membership === "join" && sender !== this.props.botId && sender !== this.state.userId) { 483 | this.verifyAllRoomDevices(client, room) 484 | this.setState({ facilitatorId: sender, ready: true }) 485 | window.clearInterval(this.state.timeoutId) 486 | } 487 | }); 488 | 489 | 490 | client.on("Event.decrypted", (event, err) => { 491 | if (err) { 492 | return this.handleDecryptionError(event, err) 493 | } 494 | if (event.getType() === "m.room.message") { 495 | const content = event.getContent() 496 | 497 | if (content.msgtype === "m.notice" && content.body === CHAT_IS_OFFLINE_NOTICE) { 498 | this.setState({ ready: true }) 499 | return window.clearInterval(this.state.timeoutId) 500 | } 501 | this.handleMessageEvent(event) 502 | } 503 | }); 504 | 505 | client.on("RoomMember.typing", (event, member) => { 506 | if (member.typing && member.roomId === this.state.roomId) { 507 | this.setState({ typingStatus: `${member.name} is typing...` }) 508 | } 509 | else { 510 | this.setState({ typingStatus: null }) 511 | } 512 | }); 513 | } 514 | 515 | componentDidUpdate(prevProps, prevState) { 516 | if (prevState.messages.length !== this.state.messages.length) { 517 | if (this.messageWindow.current.scrollTo) { 518 | this.messageWindow.current.scrollTo(0, this.messageWindow.current.scrollHeight) 519 | } 520 | } 521 | 522 | if (!prevState.facilitatorInvited && this.state.facilitatorInvited) { 523 | this.displayBotMessage({ body: this.props.confirmationMessage }) 524 | } 525 | 526 | if (!prevState.opened && this.state.opened) { 527 | this.detectMobile() 528 | // not sure what to do with this 529 | // this.detectSlowConnection() 530 | } 531 | } 532 | 533 | componentDidMount() { 534 | document.addEventListener("keydown", this.handleKeyDown, false); 535 | window.addEventListener('beforeunload', this.exitChat) 536 | } 537 | 538 | componentWillUnmount() { 539 | document.removeEventListener("keydown", this.handleKeyDown, false); 540 | window.removeEventListener('beforeunload', this.exitChat) 541 | this.exitChat(); 542 | } 543 | 544 | handleInputChange = e => { 545 | this.setState({ inputValue: e.target.value }) 546 | } 547 | 548 | handleAcceptTerms = () => { 549 | this.setState({ awaitingAgreement: false }) 550 | this.startWaitTimeForFacilitator() 551 | try { 552 | this.initializeChat() 553 | } catch(err) { 554 | this.handleInitError(err) 555 | } 556 | } 557 | 558 | startWaitTimeForFacilitator = () => { 559 | const timeoutId = window.setInterval(() => { 560 | if (!this.state.facilitatorId) { 561 | this.displayBotMessage({ body: this.props.waitMessage }) 562 | } 563 | }, WAIT_TIME_MS) 564 | 565 | this.setState({ timeoutId }) 566 | } 567 | 568 | handleRejectTerms = () => { 569 | this.exitChat() 570 | this.displayBotMessage({ body: this.props.exitMessage }) 571 | } 572 | 573 | handleSubmit = e => { 574 | e.preventDefault() 575 | const message = this.state.inputValue 576 | if (!Boolean(message)) return null; 577 | 578 | if (this.state.isCryptoEnabled && !(this.state.client.isRoomEncrypted(this.state.roomId) && this.state.client.isCryptoEnabled())) return null; 579 | 580 | if (this.state.client && this.state.roomId) { 581 | const messagesInFlight = [...this.state.messagesInFlight] 582 | messagesInFlight.push(message) 583 | this.setState({ inputValue: "", messagesInFlight }, () => this.sendMessage(message)) 584 | this.chatboxInput.current.focus() 585 | } 586 | } 587 | 588 | onEmojiClick = (event, emojiObject) => { 589 | event.preventDefault() 590 | const { emoji } = emojiObject; 591 | this.setState({ 592 | inputValue: this.state.inputValue.concat(emoji), 593 | emojiSelectorOpen: false, 594 | }, this.chatboxInput.current.focus()) 595 | } 596 | 597 | render() { 598 | const { ready, messages, messagesInFlight, inputValue, userId, roomId, typingStatus, opened, showDock, emojiSelectorOpen, isMobile, decryptionErrors } = this.state; 599 | const inputLabel = 'Send a message...' 600 | 601 | return ( 602 |