implement notification UI (#54)

* wip

* ui wip

* finish notification for invite, like, repost, and reply

* add notification for accepting invite

* add notification for mentions

* fix build and test

* fix: add missing html-entities dep

* add notification for chat messages (#68)

* add notification for chat messages

* add chat notifications per chat id

* pin gun to v0.2020.1232

* update pnpm-locl

* Search (#70)

* wip

* add search input

* add ui for search results

* add keydown control and user search to GlobalSearchInput

* fix mobile onClick timing

* reset selected index on blur

* add zindex

* notif: store last read timestamp in backend (#71)

* notif: store last read timestamp in backend

* store last read timestamp of chat in backend

* fix typo

* remove console.log

* use enum for notification types

* use ContextReplacementPlugin

* fix prettier and unit test

* bump chat when unread

Co-authored-by: r1oga <38692952+r1oga@users.noreply.github.com>
This commit is contained in:
tsukino
2022-12-08 16:30:40 +08:00
committed by GitHub
parent 271af933f9
commit da19b5627e
34 changed files with 38882 additions and 87 deletions

37704
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -38,7 +38,7 @@
"ethereum-blockies-base64": "^1.0.2",
"eventemitter2": "^6.4.9",
"fast-deep-equal": "^3.1.3",
"gun": "^0.2020.1238",
"gun": "0.2020.1232",
"isomorphic-fetch": "^3.0.0",
"lodash.debounce": "^4.0.8",
"magnet-uri": "^6.2.0",
@@ -48,6 +48,7 @@
"pretty-bytes": "^6.0.0",
"react": "~16",
"react-dom": "~16",
"react-popper": "^2.3.0",
"react-qr-code": "^2.0.8",
"react-qr-reader": "^2.2.0",
"react-redux": "^7.2.3",
@@ -85,6 +86,7 @@
"@types/redux-logger": "^3.0.9",
"@types/sinon": "^10.0.13",
"@types/webtorrent": "^0.109.3",
"assert": "^2.0.0",
"browserify": "^17.0.0",
"concurrently": "^7.6.0",
"copy-webpack-plugin": "^11.0.0",
@@ -92,6 +94,7 @@
"css-loader": "^6.7.2",
"fake-indexeddb": "^4.0.0",
"file-loader": "^6.2.0",
"html-entities": "^2.3.3",
"html-webpack-plugin": "^5.5.0",
"https-browserify": "^1.0.0",
"husky": "^8.0.2",

155
pnpm-lock.yaml generated
View File

@@ -29,6 +29,7 @@ specifiers:
'@types/webtorrent': ^0.109.3
'@zk-kit/identity': ^1.4.1
'@zk-kit/protocols': ^1.11.1
assert: ^2.0.0
bn.js: ^5.2.1
browserify: ^17.0.0
classnames: ^2.3.2
@@ -46,7 +47,8 @@ specifiers:
fake-indexeddb: ^4.0.0
fast-deep-equal: ^3.1.3
file-loader: ^6.2.0
gun: ^0.2020.1238
gun: 0.2020.1232
html-entities: ^2.3.3
html-webpack-plugin: ^5.5.0
https-browserify: ^1.0.0
husky: ^8.0.2
@@ -69,6 +71,7 @@ specifiers:
pretty-bytes: ^6.0.0
react: ~16
react-dom: ~16
react-popper: ^2.3.0
react-qr-code: ^2.0.8
react-qr-reader: ^2.2.0
react-redux: ^7.2.3
@@ -118,7 +121,7 @@ dependencies:
ethereum-blockies-base64: 1.0.2
eventemitter2: 6.4.9
fast-deep-equal: 3.1.3
gun: 0.2020.1238
gun: 0.2020.1232
isomorphic-fetch: 3.0.0
lodash.debounce: 4.0.8
magnet-uri: 6.2.0
@@ -128,6 +131,7 @@ dependencies:
pretty-bytes: 6.0.0
react: 16.14.0
react-dom: 16.14.0_react@16.14.0
react-popper: 2.3.0_wcqkhtmu7mswc6yz4uyexck3ty
react-qr-code: 2.0.8_react@16.14.0
react-qr-reader: 2.2.1_wcqkhtmu7mswc6yz4uyexck3ty
react-redux: 7.2.9_wcqkhtmu7mswc6yz4uyexck3ty
@@ -141,7 +145,7 @@ dependencies:
redux-thunk: 2.4.2_redux@4.2.0
remarkable: 2.0.1
web3: 1.8.1
web3modal: 1.9.10_react-is@18.2.0
web3modal: 1.9.10
webtorrent: 1.9.4
devDependencies:
@@ -163,6 +167,7 @@ devDependencies:
'@types/redux-logger': 3.0.9
'@types/sinon': 10.0.13
'@types/webtorrent': 0.109.3
assert: 2.0.0
browserify: 17.0.0
concurrently: 7.6.0
copy-webpack-plugin: 11.0.0_webpack@5.75.0
@@ -170,6 +175,7 @@ devDependencies:
css-loader: 6.7.2_webpack@5.75.0
fake-indexeddb: 4.0.0
file-loader: 6.2.0_webpack@5.75.0
html-entities: 2.3.3
html-webpack-plugin: 5.5.0_webpack@5.75.0
https-browserify: 1.0.0
husky: 8.0.2
@@ -177,7 +183,7 @@ devDependencies:
jest: 29.3.1_@types+node@18.11.9
jest-environment-jsdom: 29.3.1
jest-silent-reporter: 0.5.0
jsdom-worker: 0.3.0_node-fetch@2.6.7
jsdom-worker: 0.3.0
node-loader: 2.0.0_webpack@5.75.0
node-sass: 8.0.0
os-browserify: 0.3.0
@@ -188,7 +194,7 @@ devDependencies:
stream-browserify: 3.0.0
stream-http: 3.2.0
style-loader: 3.3.1_webpack@5.75.0
ts-jest: 29.0.3_6crhf7ajeizammv76u753sn6i4
ts-jest: 29.0.3_4f6uxrzmuwipl5rr3bcogf6k74
ts-loader: 9.4.1_vfotqvx6lgcbf3upbs6hgaza4q
typescript: 4.9.3
webpack: 5.75.0_webpack-cli@5.0.0
@@ -2218,6 +2224,11 @@ packages:
resolution: {integrity: sha512-ByxmJgv8vjmDcl3IDToxL2yrWFrRtFpZAToY0f46XFXl8zS081t7El5MXIodwm7RC6DhHBRoOSMLFSPKCtHukg==}
dev: false
/addressparser/0.3.2:
resolution: {integrity: sha512-fDlslCJpojuY1cnb7tY7COAriA7cdSzDiWyrWNdFn7Cjd+jrEgZavqkOgD/wg+eH765YPnQjqlS88OL/Q0Qtkg==}
dev: false
optional: true
/aes-js/3.0.0:
resolution: {integrity: sha512-H7wUZRn8WpTq9jocdxQ2c8x2sKo9ZVmzfRE13GiNJXfp7NcKYEdvl3vspKjXox6RIG2VtaRe4JFvxG4rqp2Zuw==}
dev: false
@@ -2250,10 +2261,8 @@ packages:
indent-string: 4.0.0
dev: true
/ajv-formats/2.1.1_ajv@8.11.2:
/ajv-formats/2.1.1:
resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==}
peerDependencies:
ajv: ^8.0.0
peerDependenciesMeta:
ajv:
optional: true
@@ -2430,6 +2439,15 @@ packages:
util: 0.10.3
dev: true
/assert/2.0.0:
resolution: {integrity: sha512-se5Cd+js9dXJnu6Ag2JFc00t+HmHOen+8Q+L7O9zI0PqQXr20uk2J0XQqMxZEeo5U50o8Nvmmx7dZrl+Ufr35A==}
dependencies:
es6-object-assign: 1.1.0
is-nan: 1.3.2
object-is: 1.1.5
util: 0.12.5
dev: true
/async-foreach/0.1.3:
resolution: {integrity: sha512-VUeSMD8nEGBWaZK4lizI1sf3yEC7pnAQ/mrI7pC2fBz2s/tq5jWWEngTwaf0Gruu/OoXRGLGg1XFqpYBiGTYJA==}
dev: true
@@ -2518,7 +2536,7 @@ packages:
babel-plugin-syntax-jsx: 6.18.0
lodash: 4.17.21
picomatch: 2.3.1
styled-components: 5.3.6_gzjxbbqgwmhffys5rngtiuy3yq
styled-components: 5.3.6_wcqkhtmu7mswc6yz4uyexck3ty
dev: false
/babel-plugin-syntax-jsx/6.18.0:
@@ -4127,6 +4145,14 @@ packages:
engines: {node: '>=8'}
dev: true
/define-properties/1.1.4:
resolution: {integrity: sha512-uckOqKcfaVvtBdsVkdPv3XjveQJsNQqmhXgRi8uhvWWuPYZCNlzT8qAyblUgNoXdHdjMTzAqeGjAoli8f+bzPA==}
engines: {node: '>= 0.4'}
dependencies:
has-property-descriptors: 1.0.0
object-keys: 1.1.1
dev: true
/defined/1.0.1:
resolution: {integrity: sha512-hsBd2qSVCRE+5PmNdHt1uzyrFu5d3RwmFDKzyNZMFq/EwDNJF7Ee5+D5oEKF0hU6LhtoUF1macFvOe4AskQC1Q==}
dev: true
@@ -4390,6 +4416,29 @@ packages:
minimalistic-assert: 1.0.1
minimalistic-crypto-utils: 1.0.1
/emailjs-base64/1.1.4:
resolution: {integrity: sha512-4h0xp1jgVTnIQBHxSJWXWanNnmuc5o+k4aHEpcLXSToN8asjB5qbXAexs7+PEsUKcEyBteNYsSvXUndYT2CGGA==}
dev: false
optional: true
/emailjs-mime-codec/2.0.9:
resolution: {integrity: sha512-7qJo4pFGcKlWh/kCeNjmcgj34YoJWY0ekZXEHYtluWg4MVBnXqGM4CRMtZQkfYwitOhUgaKN5EQktJddi/YIDQ==}
dependencies:
emailjs-base64: 1.1.4
ramda: 0.26.1
text-encoding: 0.7.0
dev: false
optional: true
/emailjs/2.2.0:
resolution: {integrity: sha512-J9HNx13GA5DnJma10YxsSqYCErTyB0KoVflTddPTyKlEVHM0MckZXn/zDqovdacwWkHCxqC9AKVY8GMPaGvaGQ==}
requiresBuild: true
dependencies:
addressparser: 0.3.2
emailjs-mime-codec: 2.0.9
dev: false
optional: true
/emittery/0.13.1:
resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==}
engines: {node: '>=12'}
@@ -4484,6 +4533,10 @@ packages:
es6-symbol: 3.1.3
dev: false
/es6-object-assign/1.1.0:
resolution: {integrity: sha512-MEl9uirslVwqQU369iHNWZXsI8yaZYGg/D65aOgZkeyFJwHYSxilf7rQzXKI7DdDuBPrBXbfk3sl9hJhmd5AUw==}
dev: true
/es6-promise/4.2.8:
resolution: {integrity: sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==}
dev: false
@@ -5560,16 +5613,19 @@ packages:
/graceful-fs/4.2.10:
resolution: {integrity: sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==}
/gun/0.2020.1238:
resolution: {integrity: sha512-8rKaAN74o3RTc2/a3CZca3Rx6rWnYpB1nS7mCtWH+LC7wvt2tzPR1yfRCq61haoR/Vtbl45qdq8iyoz9lAY8mg==}
/gun/0.2020.1232:
resolution: {integrity: sha512-/LAooFgiIwbHt6geaY76uqgrsAS0LmSVvynw7gsaJL/Irn8pZh/Irxoq0iUTcrUR2TzAK81LKLgeM/5pLQZ8Wg==}
engines: {node: '>=0.8.4'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: ^5.0.2
dependencies:
ws: 7.5.9
optionalDependencies:
'@peculiar/webcrypto': 1.4.1
transitivePeerDependencies:
- bufferutil
- utf-8-validate
buffer: 5.7.1
emailjs: 2.2.0
text-encoding: 0.7.0
dev: false
/handle-thing/2.0.1:
@@ -5603,6 +5659,12 @@ packages:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
/has-property-descriptors/1.0.0:
resolution: {integrity: sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ==}
dependencies:
get-intrinsic: 1.1.3
dev: true
/has-symbol-support-x/1.4.2:
resolution: {integrity: sha512-3ToOva++HaW+eCpgqZrCfN51IPB+7bJNVT6CUATzueB5Heb8o6Nam0V3HG5dlDvZU1Gn5QLcbahiKw/XVk5JJw==}
dev: true
@@ -6296,6 +6358,14 @@ packages:
resolution: {integrity: sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==}
dev: true
/is-nan/1.3.2:
resolution: {integrity: sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==}
engines: {node: '>= 0.4'}
dependencies:
call-bind: 1.0.2
define-properties: 1.1.4
dev: true
/is-natural-number/4.0.1:
resolution: {integrity: sha512-Y4LTamMe0DDQIIAlaer9eKebAlDSV6huy+TWhJVPlzZh2o4tRP5SQWFlLn5N0To4mDD22/qdOq+veo1cSISLgQ==}
dev: true
@@ -6978,13 +7048,12 @@ packages:
resolution: {integrity: sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==}
dev: false
/jsdom-worker/0.3.0_node-fetch@2.6.7:
/jsdom-worker/0.3.0:
resolution: {integrity: sha512-nlPmN0i93+e6vxzov8xqLMR+MBs/TAYeSviehivzqovHH0AgooVx9pQ/otrygASppPvdR+V9Jqx5SMe8+FcADg==}
peerDependencies:
node-fetch: '*'
dependencies:
mitt: 3.0.0
node-fetch: 2.6.7
uuid-v4: 0.1.0
dev: true
@@ -7947,6 +8016,7 @@ packages:
optional: true
dependencies:
whatwg-url: 5.0.0
dev: false
/node-forge/1.3.1:
resolution: {integrity: sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==}
@@ -8135,6 +8205,19 @@ packages:
/object-inspect/1.12.2:
resolution: {integrity: sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ==}
/object-is/1.1.5:
resolution: {integrity: sha512-3cyDsyHgtmi7I7DfSSI2LDp6SK2lwvtbg0p0R1e0RvTqF5ceGx+K2dfSjm1bKDMVCFEDAQvy+o8c6a7VujOddw==}
engines: {node: '>= 0.4'}
dependencies:
call-bind: 1.0.2
define-properties: 1.1.4
dev: true
/object-keys/1.1.1:
resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==}
engines: {node: '>= 0.4'}
dev: true
/oboe/2.1.5:
resolution: {integrity: sha512-zRFWiF+FoicxEs3jNI/WYUrVEgA7DeET/InK0XQuudGHRg8iIob3cNPrJTKaz4004uaA9Pbe+Dwa8iluhjLZWA==}
dependencies:
@@ -8864,6 +8947,11 @@ packages:
ffjavascript: 0.2.55
dev: false
/ramda/0.26.1:
resolution: {integrity: sha512-hLWjpy7EnsDBb0p+Z3B7rPi3GDeRG5ZtiI33kJhTt+ORCd38AbAIjB/9zRIUoeTbE/AVX5ZkU7m6bznsvrf8eQ==}
dev: false
optional: true
/random-access-file/2.2.1:
resolution: {integrity: sha512-RGU0xmDqdOyEiynob1KYSeh8+9c9Td1MJ74GT1viMEYAn8SJ9oBtWCXLsYZukCF46yududHOdM449uRYbzBrZQ==}
dependencies:
@@ -8943,6 +9031,7 @@ packages:
/react-is/18.2.0:
resolution: {integrity: sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==}
dev: true
/react-popper/2.3.0_ahsyp7paedy7ttpixxrzktxjdi:
resolution: {integrity: sha512-e1hj8lL3uM+sgSR4Lxzn5h1GxBlpa4CQz0XLF8kx4MDrDRWY0Ena4c97PUeSX9i5W3UAfDP0z0FXCTQkoXUl3Q==}
@@ -8958,6 +9047,19 @@ packages:
warning: 4.0.3
dev: false
/react-popper/2.3.0_wcqkhtmu7mswc6yz4uyexck3ty:
resolution: {integrity: sha512-e1hj8lL3uM+sgSR4Lxzn5h1GxBlpa4CQz0XLF8kx4MDrDRWY0Ena4c97PUeSX9i5W3UAfDP0z0FXCTQkoXUl3Q==}
peerDependencies:
'@popperjs/core': ^2.0.0
react: ^16.8.0 || ^17 || ^18
react-dom: ^16.8.0 || ^17 || ^18
dependencies:
react: 16.14.0
react-dom: 16.14.0_react@16.14.0
react-fast-compare: 3.2.0
warning: 4.0.3
dev: false
/react-qr-code/2.0.8_react@16.14.0:
resolution: {integrity: sha512-zYO9EAPQU8IIeD6c6uAle7NlKOiVKs8ji9hpbWPTGxO+FLqBN2on+XCXQvnhm91nrRd306RvNXUkUNcXXSfhWA==}
peerDependencies:
@@ -9485,7 +9587,7 @@ packages:
dependencies:
'@types/json-schema': 7.0.11
ajv: 8.11.2
ajv-formats: 2.1.1_ajv@8.11.2
ajv-formats: 2.1.1
ajv-keywords: 5.1.0_ajv@8.11.2
dev: true
@@ -10232,7 +10334,7 @@ packages:
webpack: 5.75.0_webpack-cli@5.0.0
dev: true
/styled-components/5.3.6_gzjxbbqgwmhffys5rngtiuy3yq:
/styled-components/5.3.6_wcqkhtmu7mswc6yz4uyexck3ty:
resolution: {integrity: sha512-hGTZquGAaTqhGWldX7hhfzjnIYBZ0IXQXkCYdvF1Sq3DsUaLx6+NTHC5Jj1ooM2F68sBiVz3lvhfwQs/S3l6qg==}
engines: {node: '>=10'}
requiresBuild: true
@@ -10251,7 +10353,6 @@ packages:
hoist-non-react-statics: 3.3.2
react: 16.14.0
react-dom: 16.14.0_react@16.14.0
react-is: 18.2.0
shallowequal: 1.1.0
supports-color: 5.5.0
dev: false
@@ -10438,6 +10539,12 @@ packages:
deprecated: testrpc has been renamed to ganache-cli, please use this package from now on.
dev: false
/text-encoding/0.7.0:
resolution: {integrity: sha512-oJQ3f1hrOnbRLOcwKz0Liq2IcrvDeZRHXhd9RgLrsT+DjWY/nty1Hi7v3dtkaEYbPYe0mUoOfzRrMwfXXwgPUA==}
deprecated: no longer maintained
dev: false
optional: true
/thirty-two/1.0.2:
resolution: {integrity: sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA==}
engines: {node: '>=0.2.6'}
@@ -10557,6 +10664,7 @@ packages:
/tr46/0.0.3:
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
dev: false
/tr46/2.1.0:
resolution: {integrity: sha512-15Ih7phfcdP5YxqiB+iDtLoaTz4Nd35+IiAv0kQ5FNKHzXgdWqPoTIqEDDJmXceQt4JZk6lVPT8lnDlPpGDppw==}
@@ -10598,7 +10706,7 @@ packages:
resolution: {integrity: sha512-c3zayb8/kWWpycWYg87P71E1S1ZL6b6IJxfb5fvsUgsf0S2MVGaDhDXXjDMpdCpfWXqptc+4mXwmiy1ypXqRAA==}
dev: false
/ts-jest/29.0.3_6crhf7ajeizammv76u753sn6i4:
/ts-jest/29.0.3_4f6uxrzmuwipl5rr3bcogf6k74:
resolution: {integrity: sha512-Ibygvmuyq1qp/z3yTh9QTwVVAbFdDy/+4BtIQR2sp6baF2SJU/8CKK/hhnGIDY2L90Az2jIqTwZPnN2p+BweiQ==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
hasBin: true
@@ -10619,7 +10727,6 @@ packages:
esbuild:
optional: true
dependencies:
'@babel/core': 7.20.2
bs-logger: 0.2.6
fast-json-stable-stringify: 2.1.0
jest: 29.3.1_@types+node@18.11.9
@@ -11378,14 +11485,14 @@ packages:
- utf-8-validate
dev: false
/web3modal/1.9.10_react-is@18.2.0:
/web3modal/1.9.10:
resolution: {integrity: sha512-gRByp+toRiADwkJLLGRXsnIVbLS1aJB71sJyryS6C7cF6jJ3cRN1LbPYEMObMyJkyjOZonx0CNZVAYGiD099aA==}
dependencies:
detect-browser: 5.3.0
prop-types: 15.8.1
react: 16.14.0
react-dom: 16.14.0_react@16.14.0
styled-components: 5.3.6_gzjxbbqgwmhffys5rngtiuy3yq
styled-components: 5.3.6_wcqkhtmu7mswc6yz4uyexck3ty
tslib: 1.14.1
transitivePeerDependencies:
- react-is
@@ -11404,6 +11511,7 @@ packages:
/webidl-conversions/3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
dev: false
/webidl-conversions/4.0.2:
resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==}
@@ -11694,6 +11802,7 @@ packages:
dependencies:
tr46: 0.0.3
webidl-conversions: 3.0.1
dev: false
/whatwg-url/8.7.0:
resolution: {integrity: sha512-gAojqb/m9Q8a5IV96E3fHJM70AzCkgt4uXYX2O7EmuyOnLrViCQlsEBmF9UQIu3/aeAIp2U17rtbpZWNntQqdg==}

View File

@@ -84,10 +84,10 @@ export default function Avatar(props: Props): ReactElement {
}, [name, address, groupName]);
useEffect(() => {
if (username) {
if (username && !user) {
dispatch(getUser(username));
}
}, [username]);
}, [username, user]);
useEffect(() => {
(async () => {

View File

@@ -7,6 +7,8 @@ import classNames from 'classnames';
import { useAccount, useGunLoggedIn } from '../../ducks/web3';
import { useSelectedLocalId } from '../../ducks/worker';
import { fetchNameByAddress } from '../../util/web3';
import NotificationIcon from '../NotificationIcon';
import ChatNavIcon from '../ChatNavIcon';
export default function BottomNav(): ReactElement {
const loggedIn = useGunLoggedIn();
@@ -31,8 +33,9 @@ export default function BottomNav(): ReactElement {
<div className="bottom-nav">
<BottomNavIcon fa="fas fa-home" pathname="/home" disabled={!loggedIn} />
{/*<BottomNavIcon fa="fas fa-envelope" pathname={`/${ensName || address}/`} disabled={!loggedIn} />*/}
<BottomNavIcon fa="fas fa-envelope" pathname={`/chat`} disabled={!selectedLocalId} />
<ChatNavIcon fa="fas fa-envelope" pathname={`/chat`} disabled={!selectedLocalId} />
<BottomNavIcon fa="fas fa-globe-asia" pathname="/explore" />
<NotificationIcon fa="fas fa-bell" pathname="/notifications" />
<Web3Button className="bottom-nav__web3-icon" />
</div>
);

View File

@@ -5,6 +5,7 @@ import React, {
ReactElement,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import classNames from 'classnames';
@@ -16,7 +17,13 @@ import Avatar, { Username } from '../Avatar';
import Textarea from '../Textarea';
import { generateZkIdentityFromHex, sha256, signWithP256 } from '../../util/crypto';
import { FromNow } from '../ChatMenu';
import { useChatId, useChatMessage, useMessagesByChatId, zkchat } from '../../ducks/chats';
import {
setLastReadForChatId,
useChatId,
useChatMessage,
useMessagesByChatId,
zkchat,
} from '../../ducks/chats';
import Icon from '../Icon';
import SpinnerGIF from '../../../static/icons/spinner.gif';
import { useDispatch } from 'react-redux';
@@ -30,6 +37,7 @@ export default function ChatContent(): ReactElement {
const messages = useMessagesByChatId(chatId);
const chat = useChatId(chatId);
const params = useParams<{ chatId: string }>();
const dispatch = useDispatch();
const loadMore = useCallback(async () => {
if (!chat) return;
@@ -40,6 +48,10 @@ export default function ChatContent(): ReactElement {
loadMore();
}, [loadMore]);
useEffect(() => {
dispatch(setLastReadForChatId(chatId));
}, [chatId, messages]);
if (!chat) return <></>;
return (
@@ -97,6 +109,7 @@ function ChatHeader(): ReactElement {
}
function ChatEditor(): ReactElement {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const { chatId } = useParams<{ chatId: string }>();
const selected = useSelectedLocalId();
const [content, setContent] = useState('');
@@ -179,11 +192,11 @@ function ChatEditor(): ReactElement {
setError(e.message);
} finally {
setSending(false);
e.currentTarget.focus();
textareaRef.current?.focus();
}
}
},
[submitMessage]
[submitMessage, textareaRef]
);
const onChange = useCallback((e: ChangeEvent<HTMLTextAreaElement>) => {
@@ -199,6 +212,7 @@ function ChatEditor(): ReactElement {
<div className="flex flex-row w-full">
<div className="chat-content__editor ml-2">
<Textarea
_ref={textareaRef}
key={chatId}
className="text-light border mr-2 my-2"
// ref={(el) => el?.focus()}

View File

@@ -21,6 +21,7 @@ import chats, {
useChatId,
useChatIds,
useLastNMessages,
useUnreadChatMessages,
zkchat,
} from '../../ducks/chats';
import Icon from '../Icon';
@@ -35,9 +36,6 @@ import { useThemeContext } from '../ThemeContext';
export default function ChatMenu(): ReactElement {
const selected = useSelectedLocalId();
const selecteduser = useUser(selected?.address);
const history = useHistory();
const dispatch = useDispatch();
const chatIds = useChatIds();
const [showingCreateChat, setShowingCreateChat] = useState(false);
const [selectedNewConvo, selectNewConvo] = useState<Chat | null>(null);
@@ -45,18 +43,6 @@ export default function ChatMenu(): ReactElement {
const [searchResults, setSearchResults] = useState<Chat[] | null>(null);
const params = useParams<{ chatId: string }>();
useEffect(() => {
if (selecteduser?.ecdh && selected?.type === 'gun') {
setTimeout(() => {
dispatch(fetchChats(selecteduser.ecdh));
}, 500);
} else if (selected?.type === 'interrep' || selected?.type === 'taz') {
setTimeout(() => {
dispatch(fetchChats(selected.identityCommitment));
}, 500);
}
}, [selected, selecteduser]);
const onSearchNewChatChange = useCallback(async (e: ChangeEvent<HTMLInputElement>) => {
setSearchParam(e.target.value);
const res = await fetch(`${config.indexerAPI}/v1/zkchat/chats/search/${e.target.value}`);
@@ -218,6 +204,7 @@ function ChatMenuItem(props: {
const history = useHistory();
const [last] = useLastNMessages(props.chatId, 1);
const theme = useThemeContext();
const unreads = useUnreadChatMessages(props.chatId);
const isSelected = props.chatId === params.chatId;
@@ -301,11 +288,18 @@ function ChatMenuItem(props: {
</div>
)}
</div>
{!props.hideLastChat && (
<div className={classNames('flex-grow-0 flex-shrink-0 mt-1 text-gray-500')}>
{last?.timestamp && <FromNow className="text-xs" timestamp={last.timestamp} />}
</div>
)}
<div className="flex flex-col items-end justify-center">
{!!unreads && (
<div className="flex flex-row items-center justify-center bg-red-500 text-white text-xs rounded-full w-4 h-4">
{unreads}
</div>
)}
{!props.hideLastChat && (
<div className={classNames('flex-grow-0 flex-shrink-0 mt-1 text-gray-500')}>
{last?.timestamp && <FromNow className="text-xs" timestamp={last.timestamp} />}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,51 @@
import React, { ReactElement } from 'react';
import { useHistory, useLocation } from 'react-router';
import Icon from '../Icon';
import classNames from 'classnames';
import { useUnreadChatMessagesAll } from '../../ducks/chats';
export default function ChatNavIcon(props: {
fa: string;
pathname: string;
disabled?: boolean;
isTop?: boolean;
className?: string;
}): ReactElement {
const history = useHistory();
const { pathname } = useLocation();
const count = useUnreadChatMessagesAll();
const iconClass = props.isTop ? 'top-nav__icon' : 'bottom-nav__icon';
return (
<div
className={classNames(
'flex',
'flex-row',
'items-center',
'justify-center',
props.className,
iconClass,
{
[`${iconClass}--selected`]: pathname === props.pathname,
[`${iconClass}--disabled`]: props.disabled,
}
)}>
<Icon
className="relative"
onClick={
pathname !== props.pathname && !props.disabled
? () => history.push(props.pathname)
: undefined
}
fa={props.fa}
size={1.125}>
{count > 0 && (
<div className="bg-red-500 text-white rounded-full text-xs notification-icon__counter">
{count}
</div>
)}
</Icon>
</div>
);
}

View File

@@ -58,6 +58,7 @@ export function UserRow(props: {
name?: string;
group?: string;
onClick?: () => void;
highlight?: boolean;
}): ReactElement {
const history = useHistory();
const [username, setUsername] = useState('');
@@ -95,6 +96,8 @@ export function UserRow(props: {
{
'hover:bg-gray-200': theme !== 'dark',
'hover:bg-gray-800': theme === 'dark',
'bg-gray-200': props.highlight && theme !== 'dark',
'bg-gray-800': props.highlight && theme === 'dark',
}
)}
onClick={props.onClick || onClick}>

View File

@@ -0,0 +1,61 @@
@import '../../util/variable';
.global-search {
@extend %row-nowrap;
border: 1px solid transparent;
border-radius: 1rem;
padding: 0 1rem;
.input-group {
background-color: transparent !important;
}
&:focus-within {
border-color: $primary-color;
.icon {
color: $primary-color;
}
}
&--has-results {
border-bottom: 0;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
&__results {
overflow: hidden;
overflow-y: auto;
top: 100%;
width: 100%;
max-height: 22.5rem;
left: 0;
border: 1px solid $primary-color;
border-top: 0;
z-index: 300;
}
&__result {
&--selected {
background-color: rgba($black, 0.1);
}
}
}
.dark {
.global-search {
&__result {
&--selected {
background-color: rgba($white, 0.1);
}
}
}
}
@media only screen and (max-width: 768px) {
.global-search {
width: 100%;
margin-right: 1rem;
}
}

View File

@@ -0,0 +1,138 @@
import React, { KeyboardEvent, ReactElement, useCallback, useEffect, useState } from 'react';
import Input from '../Input';
import Icon from '../Icon';
import classNames from 'classnames';
import './global-search.scss';
import { useThemeContext } from '../ThemeContext';
import debounce from 'lodash.debounce';
import { useHistory } from 'react-router';
import { searchUsers } from '../../ducks/users';
import store from '../../store/configureAppStore';
import { UserRow } from '../DiscoverUserPanel';
type Props = {
className?: string;
defaultValue?: string;
};
// @ts-ignore
const debouncedSearchUsers = debounce(query => store.dispatch(searchUsers(query)), 100);
export default function GlobalSearchInput(props: Props): ReactElement {
const theme = useThemeContext();
const history = useHistory();
const [query, setQuery] = useState(props.defaultValue || '');
const [userResults, setUserResults] = useState<string[]>([]);
const [focused, setFocus] = useState(false);
const [selectedIndex, selectIndex] = useState(-1);
const search = useCallback(async () => {
const list: any = await debouncedSearchUsers(query);
setUserResults(list.map(({ address }: any) => address));
}, [query]);
const onBlur = useCallback(() => {
setTimeout(() => {
setUserResults([]);
setFocus(false);
selectIndex(-1);
}, 100);
}, []);
const onFocus = useCallback(() => {
setFocus(true);
search();
}, [search]);
const onChange = useCallback(async (e: any) => {
setQuery(e.target.value);
}, []);
const onEnter = useCallback(
async (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
if (selectedIndex <= 0) {
history.push(`/search?q=${encodeURIComponent(query)}`);
} else if (selectedIndex <= userResults.length) {
history.push(`/${userResults[selectedIndex - 1]}/`);
}
}
},
[query, selectedIndex, userResults]
);
const onKeyDown = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
const len = userResults.length;
switch (e.key) {
case 'ArrowDown':
selectIndex(Math.min(selectedIndex + 1, len));
break;
case 'ArrowUp':
selectIndex(Math.max(selectedIndex - 1, 0));
break;
}
},
[userResults, query, selectedIndex]
);
useEffect(() => {
(async function () {
if (!query) {
setUserResults([]);
return;
}
search();
})();
}, [search]);
return (
<div className={classNames('flex flex-col relative', props.className)}>
<div
className={classNames('global-search', props.className, {
'bg-gray-100 focus:bg-white': theme !== 'dark',
'bg-gray-900 focus:bg-black': theme === 'dark',
'global-search--has-results': !!query && focused,
})}>
<Icon fa="fas fa-search" className="text-gray-500" />
<Input
type="text"
className="text-sm"
placeholder="Search Zkitter"
onChange={onChange}
onKeyPress={onEnter}
defaultValue={props.defaultValue}
value={query}
onFocus={onFocus}
onBlur={onBlur}
onKeyDown={onKeyDown}
/>
</div>
{!!query && focused && (
<div
className={classNames('absolute global-search__results', {
'bg-white': theme !== 'dark',
'bg-black': theme === 'dark',
})}>
<div
className={classNames(
'flex flex-row flex-nowrap px-4 py-2',
'cursor-pointer items-center transition',
{
'global-search__result--selected': selectedIndex === 0,
'hover:bg-gray-200': theme !== 'dark',
'hover:bg-gray-800': theme === 'dark',
}
)}
onClick={() => history.push(`/search?q=${encodeURIComponent(query)}`)}>
{`Search for "${query}"`}
</div>
{userResults.map((address, i) => (
<UserRow key={address} name={address} highlight={selectedIndex === i + 1} />
))}
</div>
)}
</div>
);
}

View File

@@ -6,12 +6,13 @@ import PostModerationPanel from '../PostModerationPanel';
import Icon from '../Icon';
import classNames from 'classnames';
import GroupMembersPanel from '../GroupMembersPanel';
import GlobalSearchInput from '../GlobalSearchInput';
export default function MetaPanel(props: { className?: string }): ReactElement {
return (
<Switch>
<Route path="/explore">
<DefaultMetaPanels className={props.className} />
<ExploreMetaPanels className={props.className} />
</Route>
<Route path="/:name/status/:hash">
<PostMetaPanels className={props.className} />
@@ -23,9 +24,11 @@ export default function MetaPanel(props: { className?: string }): ReactElement {
<DefaultMetaPanels className={props.className} />
</Route>
<Route path="/home">
<DefaultMetaPanels className={props.className} />
<ExploreMetaPanels className={props.className} />
</Route>
<Route path="/notifications">
<ExploreMetaPanels className={props.className} />
</Route>
<Route path="/notifications" />
<Route path="/settings" />
<Route path="/create-local-backup" />
<Route path="/onboarding/interrep" />
@@ -49,6 +52,17 @@ function DefaultMetaPanels(props: { className?: string }): ReactElement {
);
}
function ExploreMetaPanels(props: { className?: string }): ReactElement {
return (
<div className={classNames('app__meta-content', props.className)}>
<GlobalSearchInput className="mt-2" />
<DiscoverUserPanel key="discover-user" />
<DiscoverTagPanel key="discover-tag" />
<AppFooter />
</div>
);
}
function ProfileMetaPanels(props: { className?: string }): ReactElement {
return (
<div className={classNames('app__meta-content', props.className)}>

View File

@@ -0,0 +1,52 @@
import React, { ReactElement } from 'react';
import { useHistory, useLocation } from 'react-router';
import Icon from '../Icon';
import classNames from 'classnames';
import './notification-icon.scss';
import { useUnreadCounts } from '../../ducks/app';
export default function NotificationIcon(props: {
fa: string;
pathname: string;
disabled?: boolean;
isTop?: boolean;
className?: string;
}): ReactElement {
const history = useHistory();
const { pathname } = useLocation();
const count = useUnreadCounts();
const iconClass = props.isTop ? 'top-nav__icon' : 'bottom-nav__icon';
return (
<div
className={classNames(
'flex',
'flex-row',
'items-center',
'justify-center',
props.className,
iconClass,
{
[`${iconClass}--selected`]: pathname === props.pathname,
[`${iconClass}--disabled`]: props.disabled,
}
)}>
<Icon
className="relative"
onClick={
pathname !== props.pathname && !props.disabled
? () => history.push(props.pathname)
: undefined
}
fa={props.fa}
size={1.125}>
{count > 0 && (
<div className="bg-red-500 text-white rounded-full text-xs notification-icon__counter">
{count}
</div>
)}
</Icon>
</div>
);
}

View File

@@ -0,0 +1,17 @@
@import '../../util/variable';
.notification-icon {
&__counter {
position: absolute;
top: -0.375rem;
right: -0.375rem;
width: 0.875rem;
height: 0.875rem;
border-radius: 50%;
display: flex;
flex-flow: row nowrap;
align-items: center;
justify-content: center;
font-size: 0.625rem;
}
}

View File

@@ -6,7 +6,7 @@ import { useHistory } from 'react-router';
import { convertMarkdownToDraft, DraftEditor } from '../DraftEditor';
import { fetchLikersByPost, useMeta, usePost, useZKGroupFromPost } from '../../ducks/posts';
import { useUser } from '../../ducks/users';
import { PostMessageSubType } from '../../util/message';
import { MessageType, PostMessageSubType } from '../../util/message';
import { getHandle, getUsername } from '../../util/user';
import { useThemeContext } from '../ThemeContext';
@@ -61,6 +61,7 @@ export default function ExpandedPost(
address={user?.address}
incognito={post.creator === ''}
group={zkGroup}
twitterUsername={post.type === MessageType._TWEET ? post.creator : undefined}
/>
<div className="flex flex-col flex-nowrap items-start text-light w-full cursor-pointer">
<div className="font-bold text-base mr-1 hover:underline" onClick={gotoUserProfile}>

View File

@@ -74,7 +74,7 @@ export default function RegularPost(
<div>
<Avatar
className="mr-3 w-12 h-12"
address={user?.username}
address={user?.address}
incognito={post.creator === ''}
group={zkGroup}
twitterUsername={post.type === MessageType._TWEET ? post.creator : undefined}

View File

@@ -4,7 +4,7 @@ import './textarea.scss';
import { useThemeContext } from '../ThemeContext';
type Props = {
ref?: LegacyRef<HTMLTextAreaElement>;
_ref?: LegacyRef<HTMLTextAreaElement>;
label?: string;
errorMessage?: string;
} & TextareaHTMLAttributes<HTMLTextAreaElement>;
@@ -23,7 +23,7 @@ export default function Textarea(props: Props): ReactElement {
'focus-within:border-gray-600 border-gray-800': !textareaProps.readOnly && theme === 'dark',
})}>
{label && <div className="textarea-group__label">{label}</div>}
<textarea ref={props.ref} {...textareaProps} />
<textarea ref={props._ref} {...textareaProps} />
{errorMessage && <small className="error-message">{errorMessage}</small>}
</div>
);

View File

@@ -16,6 +16,9 @@ import Modal from '../Modal';
import MetaPanel from '../MetaPanel';
import { useThemeContext } from '../ThemeContext';
import config from '../../util/config';
import NotificationIcon from '../NotificationIcon';
import ChatNavIcon from '../ChatNavIcon';
import GlobalSearchInput from '../GlobalSearchInput';
export default function TopNav(): ReactElement {
const theme = useThemeContext();
@@ -37,6 +40,7 @@ export default function TopNav(): ReactElement {
)}>
<div className={classNames('flex flex-row flex-nowrap items-center flex-grow flex-shrink-0')}>
<Switch>
<Route path="/search" component={SearchHeaderGroup} />
<Route path="/explore" component={GlobalHeaderGroup} />
<Route path="/home" component={GlobalHeaderGroup} />
<Route path="/tag/:tagName" component={TagHeaderGroup} />
@@ -46,7 +50,7 @@ export default function TopNav(): ReactElement {
<Route path="/onboarding/interrep" component={DefaultHeaderGroup} />
<Route path="/connect/twitter" component={DefaultHeaderGroup} />
<Route path="/signup" component={DefaultHeaderGroup} />
<Route path="/notification" component={DefaultHeaderGroup} />
<Route path="/notifications" component={DefaultHeaderGroup} />
<Route path="/chat/:chatId?" component={ChatHeaderGroup} />
<Route path="/settings" component={SettingHeaderGroup} />
<Route path="/:name" component={UserProfileHeaderGroup} />
@@ -101,9 +105,9 @@ function NavIconRow() {
}
)}>
<TopNavIcon fa="fas fa-home" pathname="/home" disabled={!loggedIn} />
<TopNavIcon fa="fas fa-envelope" pathname={`/chat`} disabled={!selectedLocalId} />
<ChatNavIcon isTop fa="fas fa-envelope" pathname="/chat" disabled={!selectedLocalId} />
<TopNavIcon fa="fas fa-globe-asia" pathname="/explore" />
{/*<TopNavIcon fa="fas fa-bell" pathname="/notifications" />*/}
<NotificationIcon isTop fa="fas fa-bell" pathname="/notifications" />
</div>
);
}
@@ -138,6 +142,50 @@ function DefaultHeaderGroup() {
);
}
function SearchHeaderGroup() {
const loggedIn = useGunLoggedIn();
const account = useAccount();
const selectedLocalId = useSelectedLocalId();
const history = useHistory();
const [ensName, setEnsName] = useState('');
const params = new URLSearchParams(location.search);
const query = params.get('q') || undefined;
const goBack = useCallback(() => {
if (history.action !== 'POP') return history.goBack();
history.push('/');
}, [history]);
let address = '';
if (loggedIn) {
address = selectedLocalId?.address || account;
}
useEffect(() => {
(async () => {
const ens = await fetchNameByAddress(address);
setEnsName(ens);
})();
}, [address]);
return (
<div
className={classNames(
'flex flex-row flex-nowrap flex-grow items-center flex-shrink-0',
'p-1 mx-4'
)}>
<Icon
className="w-8 h-8 flex flex-row items-center justify-center top-nav__back-icon"
fa="fas fa-chevron-left"
onClick={goBack}
/>
<GlobalSearchInput defaultValue={query} />
<TopNavContextButton />
</div>
);
}
function GlobalHeaderGroup() {
const loggedIn = useGunLoggedIn();
const account = useAccount();

View File

@@ -37,12 +37,21 @@ export enum Item {
const useItem = (item: Item, id: string) => {
if (item === Item.Like) return { fetch: fetchLikersByPost, count: useMeta(id).likeCount };
if (item === Item.Follower)
return { fetch: fetchUserFollowers, count: useUser(id)?.meta.followerCount };
return {
fetch: fetchUserFollowers,
count: useUser(id)?.meta?.followerCount || 0,
};
if (item === Item.Following)
return { fetch: fetchUserFollowings, count: useUser(id)?.meta.followingCount };
return {
fetch: fetchUserFollowings,
count: useUser(id)?.meta?.followingCount || 0,
};
return { fetch: fetchRetweetsByPost, count: useMeta(id).repostCount };
return {
fetch: fetchRetweetsByPost,
count: useMeta(id).repostCount,
};
};
function MaybePlural(props: { users: string[]; text: Item }): ReactElement {

View File

@@ -388,7 +388,7 @@ function CurrentUserItem(props: {
const gotoProfile = useCallback(() => {
if (!selectedUser) return;
const { ens, name, address } = selectedUser;
history.push(`/${ens || address}`);
history.push(`/${ens || address}/`);
props.closePopup();
}, [selectedUser]);

View File

@@ -1,11 +1,14 @@
import { useSelector } from 'react-redux';
import { AppRootState } from '../store/configureAppStore';
import deepEqual from 'fast-deep-equal';
import config from '../util/config';
const THEME_LS_KEY = 'theme';
enum ActionTypes {
SET_THEME = 'app/setTheme',
UPDATE_NOTIFICATIONS = 'app/updateNotifications',
UPDATE_LAST_READ = 'app/updateLastRead',
}
type Action<payload> = {
@@ -17,6 +20,7 @@ type Action<payload> = {
type State = {
theme: string;
notifications: number;
};
const getTheme = () => {
@@ -26,8 +30,9 @@ const getTheme = () => {
return 'light';
};
const initialState = {
const initialState: State = {
theme: getTheme(),
notifications: 0,
};
export const setTheme = (theme: 'dark' | 'light') => {
@@ -38,6 +43,49 @@ export const setTheme = (theme: 'dark' | 'light') => {
};
};
export const updateLastReadTimestamp =
() => async (dispatch: any, getState: () => AppRootState) => {
const {
worker: { selected },
} = getState();
if (selected?.type !== 'gun') return;
const { address } = selected;
const res = await fetch(`${config.indexerAPI}/v1/lastread/${address}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
lastread: Date.now(),
}),
});
const json = await res.json();
if (json.error) {
throw new Error(json.payload);
}
dispatch({
type: ActionTypes.UPDATE_NOTIFICATIONS,
payload: 0,
});
};
export const updateNotifications = () => async (dispatch: any, getState: () => AppRootState) => {
const {
worker: { selected },
} = getState();
if (selected?.type !== 'gun') return;
const { address } = selected;
const resp = await fetch(`${config.indexerAPI}/v1/${address}/notifications/unread`);
const json = await resp.json();
if (json.error || !json.payload?.TOTAL) return 0;
dispatch({
type: ActionTypes.UPDATE_NOTIFICATIONS,
payload: json.payload.TOTAL || 0,
});
};
export default function app(state = initialState, action: Action<any>): State {
switch (action.type) {
case ActionTypes.SET_THEME:
@@ -45,6 +93,11 @@ export default function app(state = initialState, action: Action<any>): State {
...state,
theme: action.payload,
};
case ActionTypes.UPDATE_NOTIFICATIONS:
return {
...state,
notifications: action.payload,
};
default:
return state;
}
@@ -57,3 +110,9 @@ export const useSetting = () => {
};
}, deepEqual);
};
export const useUnreadCounts = () => {
return useSelector((state: AppRootState) => {
return state.app.notifications;
}, deepEqual);
};

View File

@@ -14,6 +14,16 @@ export const zkchat = new ZKChatClient({
sse.on('NEW_CHAT_MESSAGE', async (payload: any) => {
const message = await zkchat.inflateMessage(payload);
zkchat.prependMessage(message);
const chat: Chat = {
type: 'DIRECT',
receiver: '',
receiverECDH: message.receiver.ecdh!,
senderECDH: message.sender.ecdh!,
senderHash: message.sender.hash,
};
const chatId = zkchat.deriveChatId(chat);
// @ts-ignore
store.dispatch(incrementUnreadForChatId(chatId));
});
const onNewMessage = (message: ChatMessage) => {
@@ -33,6 +43,9 @@ zkchat.on(EVENTS.MESSAGE_PREPENDED, onNewMessage);
zkchat.on(EVENTS.CHAT_CREATED, (chat: Chat) => {
store.dispatch(addChat(chat));
});
zkchat.on(EVENTS.IDENTITY_CHANGED, () => {
store.dispatch(setChats(zkchat.activeChats));
});
enum ActionTypes {
SET_CHATS = 'chats/setChats',
@@ -40,11 +53,12 @@ enum ActionTypes {
ADD_CHAT = 'chats/addChat',
SET_CHAT_NICKNAME = 'chats/setChatNickname',
SET_MESSAGE = 'chats/SET_MESSAGE',
SET_UNREAD = 'chats/SET_UNREAD',
}
type Action<payload> = {
type: ActionTypes;
payload?: payload;
payload: payload;
meta?: any;
error?: boolean;
};
@@ -56,6 +70,9 @@ type State = {
[chatId: string]: InflatedChat;
};
};
unreads: {
[chatId: string]: number;
};
messages: {
[messageId: string]: ChatMessage;
};
@@ -67,6 +84,7 @@ export type InflatedChat = Chat & {
};
const initialState: State = {
unreads: {},
chats: {
order: [],
map: {},
@@ -104,6 +122,14 @@ const setMessage = (msg: ChatMessage): Action<ChatMessage> => ({
payload: msg,
});
const setUnread = (
chatId: string,
unreads: number
): Action<{ chatId: string; unreads: number }> => ({
type: ActionTypes.SET_UNREAD,
payload: { chatId, unreads },
});
const setMessagesForChat = (
chatId: string,
messages: string[]
@@ -112,10 +138,62 @@ const setMessagesForChat = (
payload: { chatId, messages },
});
export const fetchChats =
(address: string) => async (dispatch: Dispatch, getState: () => AppRootState) => {
await zkchat.fetchActiveChats(address);
dispatch(setChats(zkchat.activeChats));
export const setLastReadForChatId =
(chatId: string) => async (dispatch: Dispatch, getState: () => AppRootState) => {
const {
chats: {
chats: { map },
},
} = getState();
const chat = map[chatId];
if (!zkchat.identity || !chat) return;
const res = await fetch(
`${config.indexerAPI}/v1/lastread/${zkchat.identity.ecdh.pub}/${chat.receiverECDH}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
lastread: Date.now(),
}),
}
);
const json = await res.json();
if (json.error) {
throw new Error(json.payload);
}
dispatch(setUnread(chatId, 0));
};
export const fetchChats = (address: string) => async (dispatch: Dispatch) => {
await zkchat.fetchActiveChats(address);
dispatch(setChats(zkchat.activeChats));
};
export const fetchUnreads = () => async (dispatch: Dispatch, getState: () => AppRootState) => {
for (const chat of Object.values(zkchat.activeChats)) {
const { senderECDH, receiverECDH } = chat || {};
const chatId = zkchat.deriveChatId(chat);
const resp = await fetch(
`${config.indexerAPI}/v1/zkchat/chat-messages/dm/${receiverECDH}/${senderECDH}/unread`
);
const json = await resp.json();
if (!json.error) {
dispatch(setUnread(chatId, json.payload));
}
}
};
export const incrementUnreadForChatId =
(chatId: string) => async (dispatch: Dispatch, getState: () => AppRootState) => {
const {
chats: { unreads },
} = getState();
dispatch(setUnread(chatId, (unreads[chatId] || 0) + 1));
};
export default function chats(state = initialState, action: Action<any>): State {
@@ -128,6 +206,8 @@ export default function chats(state = initialState, action: Action<any>): State
return handleSetMessagesForChats(state, action);
case ActionTypes.SET_CHAT_NICKNAME:
return handeSetNickname(state, action);
case ActionTypes.SET_UNREAD:
return handleSetUnread(state, action);
case ActionTypes.SET_MESSAGE:
return {
...state,
@@ -244,9 +324,31 @@ function handleSetMessagesForChats(
};
}
function handleSetUnread(state: State, action: Action<{ chatId: string; unreads: number }>): State {
const { chatId, unreads } = action.payload;
const { chats } = state;
return {
...state,
chats: {
...chats,
order: unreads ? [chatId].concat(chats.order.filter(id => id !== chatId)) : chats.order,
},
unreads: {
...state.unreads,
[chatId]: unreads,
},
};
}
export const useChatIds = () => {
return useSelector((state: AppRootState) => {
return state.chats.chats.order;
const {
chats: {
chats: { order },
},
} = state;
return order;
}, deepEqual);
};
@@ -286,3 +388,19 @@ export const useChatMessage = (messageId: string) => {
return state.chats.messages[messageId];
}, deepEqual);
};
export const useUnreadChatMessagesAll = () => {
return useSelector((state: AppRootState) => {
const {
chats: { order },
unreads,
} = state.chats;
return order.reduce((sum, id) => (sum += unreads[id] || 0), 0);
}, deepEqual);
};
export const useUnreadChatMessages = (chatId: string) => {
return useSelector((state: AppRootState) => {
return state.chats.unreads[chatId] || 0;
}, deepEqual);
};

View File

@@ -16,6 +16,8 @@ import config from '../util/config';
import { Dispatch } from 'redux';
import { useHistory } from 'react-router';
import { useCallback } from 'react';
import { EditorState } from 'draft-js';
import { convertMarkdownToDraft } from '../components/DraftEditor';
enum ActionTypes {
SET_POSTS = 'posts/setPosts',
@@ -205,6 +207,16 @@ export const fetchPosts =
return json.payload.map((post: any) => post.messageId);
};
export const fetchNotifications =
(address: string, limit = 10, offset = 0) =>
async () => {
const resp = await fetch(
`${config.indexerAPI}/v1/${address}/notifications?limit=${limit}&offset=${offset}`
);
const json = await resp.json();
return json.payload;
};
export const fetchLikedBy =
(creator?: string, limit = 10, offset = 0) =>
async (dispatch: ThunkDispatch<any, any, any>, getState: () => AppRootState) => {
@@ -343,6 +355,21 @@ const processPosts = (posts: any[]) => async (dispatch: Dispatch) => {
}, 0);
};
export const searchPosts =
(query: string, limit = 20, offset = 0) =>
async (dispatch: ThunkDispatch<any, any, any>, getState: () => AppRootState) => {
const resp = await fetch(`${config.indexerAPI}/v1/posts/search`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, limit, offset }),
});
const json = await resp.json();
if (json.error) throw new Error(json.payload);
return json.payload.map((p: any) => p.messageId);
};
export const fetchHomeFeed =
(limit = 10, offset = 0) =>
async (dispatch: ThunkDispatch<any, any, any>, getState: () => AppRootState) => {
@@ -501,6 +528,16 @@ export const usePost = (messageId?: string): Post | null => {
}, deepEqual);
};
export const usePostContent = (messageId?: string): string => {
return useSelector((state: AppRootState) => {
const post = state.posts.map[messageId || ''];
if (!post) return '';
return post.payload.content.slice(0, 512);
}, deepEqual);
};
export const useMeta = (messageId = '') => {
return useSelector((state: AppRootState): PostMeta => {
return (
@@ -518,7 +555,7 @@ export const useZKGroupFromPost = (messageId?: string) => {
return useSelector((state: AppRootState): string | undefined => {
if (!messageId) return;
const post = state.posts.meta[messageId];
if (!post) return undefined;
if (!post?.interepProvider) return undefined;
return post.interepProvider === 'taz'
? 'semaphore_taz_members'

View File

@@ -19,7 +19,7 @@ import { connectZKPR } from '../../ducks/zkpr';
import SettingView from '../SettingView';
import MetaPanel from '../../components/MetaPanel';
import ChatView from '../ChatView';
import { zkchat } from '../../ducks/chats';
import { fetchUnreads, zkchat } from '../../ducks/chats';
import {
generateECDHKeyPairFromhex,
generateZkIdentityFromHex,
@@ -32,6 +32,9 @@ import ThemeContext from '../../components/ThemeContext';
import classNames from 'classnames';
import { Identity } from '@semaphore-protocol/identity';
import TazModal from '../../components/TazModal';
import NotificationView from '../NotificationView';
import { updateNotifications } from '../../ducks/app';
import SearchResultsView from '../SearchResultsView';
export default function App(): ReactElement {
const dispatch = useDispatch();
@@ -94,6 +97,8 @@ export default function App(): ReactElement {
zk: zkIdentity,
ecdh: keyPair,
});
await dispatch(fetchUnreads());
await dispatch(updateNotifications());
})();
} else if (selected?.type === 'interrep') {
(async () => {
@@ -111,6 +116,7 @@ export default function App(): ReactElement {
zk: zkIdentity,
ecdh: keyPair,
});
await dispatch(fetchUnreads());
})();
} else if (selected?.type === 'taz') {
(async () => {
@@ -123,6 +129,7 @@ export default function App(): ReactElement {
zk: zkIdentity,
ecdh: keyPair,
});
await dispatch(fetchUnreads());
})();
}
}, [selected]);
@@ -167,7 +174,9 @@ export default function App(): ReactElement {
<AuthRoute path="/home">
<HomeFeed />
</AuthRoute>
<Route path="/notifications" />
<Route path="/notifications">
<NotificationView />
</Route>
<Route path="/create-local-backup">
<SignupView viewType={ViewType.localBackup} />
</Route>
@@ -192,6 +201,9 @@ export default function App(): ReactElement {
<Route path="/taz">
<GlobalFeed />
</Route>
<Route path="/search">
<SearchResultsView />
</Route>
<Route path="/:name">
<ProfileView />
</Route>

View File

@@ -0,0 +1,188 @@
import React, { ReactElement, useCallback, useEffect, useState } from 'react';
import classNames from 'classnames';
import { useSelectedLocalId } from '../../ducks/worker';
import { useHistory } from 'react-router';
import { parseMessageId, PostMessageSubType } from '../../util/message';
import { getUser, useUser } from '../../ducks/users';
import { useThemeContext } from '../../components/ThemeContext';
import { useDispatch } from 'react-redux';
import './notification.scss';
import Avatar from '../../components/Avatar';
import { getName } from '../../util/user';
import Icon from '../../components/Icon';
import Post from '../../components/Post';
import {
fetchMeta,
fetchNotifications,
fetchPost,
fetchPosts,
useGoToPost,
usePost,
usePostContent,
} from '../../ducks/posts';
import InfiniteScrollable from '../../components/InfiniteScrollable';
import { updateLastReadTimestamp } from '../../ducks/app';
import { NotificationType } from '../../util/notifications';
export default function NotificationView(): ReactElement {
const [limit, setLimit] = useState(20);
const [offset, setOffset] = useState(0);
const selected = useSelectedLocalId();
const selectedUser = useUser(selected?.address);
const dispatch = useDispatch();
const [notifications, setNotifications] = useState([]);
const theme = useThemeContext();
const fetchMore = useCallback(
async (reset = false) => {
if (!selected?.address) return;
if (reset) {
const payload: any = await dispatch(fetchNotifications(selected.address, 20, 0));
setOffset(20);
setNotifications(payload);
} else {
if (notifications.length % limit) return;
const payload: any = await dispatch(fetchNotifications(selected.address, limit, offset));
setOffset(offset + limit);
setNotifications(notifications.concat(payload));
}
},
[limit, offset, selected?.address, notifications]
);
useEffect(() => {
(async function () {
if (selected?.type === 'gun') {
fetchMore(true);
dispatch(updateLastReadTimestamp());
} else {
setNotifications([]);
setOffset(0);
}
})();
}, [selected]);
useEffect(() => {
if (selected?.address && !selectedUser?.ecdh) {
dispatch(getUser(selected.address));
}
}, [selectedUser?.ecdh, selected?.address]);
return (
<InfiniteScrollable
className={classNames('notifications', 'border-l border-r', 'mx-4 py-2', {
'border-gray-100': theme !== 'dark',
'border-gray-800': theme === 'dark',
})}
bottomOffset={128}
onScrolledToBottom={fetchMore}>
{notifications.map((data: any) => {
const { message_id, type } = data;
switch (type) {
case NotificationType.DIRECT:
return null;
case NotificationType.LIKE:
case NotificationType.REPOST:
case NotificationType.MEMBER_INVITE:
case NotificationType.MEMBER_ACCEPT:
return <IncomingReactionRow type={type} messageId={message_id} />;
case NotificationType.REPLY:
case NotificationType.MENTION:
return <IncomingReplyRow messageId={message_id} />;
default:
return null;
}
})}
</InfiniteScrollable>
);
}
function IncomingReactionRow(props: {
messageId: string;
type: 'LIKE' | 'REPOST' | 'MEMBER_INVITE' | 'MEMBER_ACCEPT';
}): ReactElement {
const { creator, hash } = parseMessageId(props.messageId);
const user = useUser(creator);
const history = useHistory();
const originalPost = usePost(props.messageId);
const referencedPost = usePost(originalPost?.payload.reference);
const messageId =
originalPost?.subtype === PostMessageSubType.Repost
? originalPost.payload.reference
: props.messageId;
const post = originalPost?.subtype === PostMessageSubType.Repost ? referencedPost : originalPost;
const dispatch = useDispatch();
const content = usePostContent(messageId);
const goto = useCallback(() => {
if (props.type === 'MEMBER_INVITE' || props.type === 'MEMBER_ACCEPT') {
history.push(`/${creator}`);
} else {
history.push(`/${creator}/status/${hash}`);
}
}, [creator, hash]);
useEffect(() => {
if (!post) {
dispatch(fetchPost(messageId));
dispatch(fetchMeta(messageId));
}
}, [messageId, post]);
return (
<div className="flex flex-row cursor-pointer notification-row px-4 py-3" onClick={goto}>
<Icon
className="flex flex-row mr-4 notification-row__icon"
fa={classNames({
'fas fa-heart text-red-500 ': props.type === 'LIKE',
'fas fa-retweet text-green-500': props.type === 'REPOST',
'fas fa-user-plus text-blue-500': props.type === 'MEMBER_INVITE',
'fas fa-user-check text-blue-500': props.type === 'MEMBER_ACCEPT',
})}
size={props.type === 'MEMBER_INVITE' || props.type === 'MEMBER_ACCEPT' ? 1.6125 : 2}
/>
<div className="flex flex-col items-start">
<Avatar className="w-8 h-8" address={creator} incognito={!creator} />
{props.type === 'MEMBER_INVITE' && (
<div className="mt-2 text-sm">
<span className="mr-1">You received an invitation to join</span>
<span className="font-semibold">{getName(user)}</span>
</div>
)}
{props.type === 'MEMBER_ACCEPT' && (
<div className="mt-2 text-sm">
<span className="font-semibold">{getName(user)}</span>
<span className="ml-1">accepted your invitation</span>
</div>
)}
{props.type === 'LIKE' && (
<div className="mt-2 text-sm">
<span className="font-semibold">{creator ? getName(user) : 'Someone'}</span>
<span className="ml-1">liked your post</span>
</div>
)}
{props.type === 'REPOST' && (
<div className="mt-2 text-sm">
<span className="font-semibold">{creator ? getName(user) : 'Someone'}</span>
<span className="ml-1">shared your post</span>
</div>
)}
<div className="text-sm text-gray-500 mt-2">{content}</div>
</div>
</div>
);
}
function IncomingReplyRow(props: { messageId: string }): ReactElement {
const gotoPost = useGoToPost();
return (
<Post
className="notification-row cursor-pointer"
messageId={props.messageId}
onClick={() => gotoPost(props.messageId)}
/>
);
}

View File

@@ -0,0 +1,35 @@
@import '../../util/variable';
.notifications {
width: 36.25rem;
overflow-y: auto;
&::-webkit-scrollbar {
width: 0; /* Remove scrollbar space */
background: transparent; /* Optional: just make scrollbar invisible */
}
}
.notification-row {
//padding: 1rem 2rem;
&__icon {
align-items: flex-start !important;
}
}
.notification-row {
border-bottom: 1px solid $gray-100;
}
.dark {
.notification-row {
border-bottom: 1px solid $gray-800;
}
}
@media only screen and (max-width: 768px) {
.notifications {
margin: 0;
}
}

View File

@@ -0,0 +1,72 @@
import React, { ReactElement, useCallback, useEffect, useState } from 'react';
import classNames from 'classnames';
import InfiniteScrollable from '../../components/InfiniteScrollable';
import { useThemeContext } from '../../components/ThemeContext';
import { useLocation } from 'react-router';
import './search-view.scss';
import { searchPosts, useGoToPost } from '../../ducks/posts';
import { useDispatch } from 'react-redux';
import Post from '../../components/Post';
export default function SearchResultsView(): ReactElement {
const [limit, setLimit] = useState(20);
const [offset, setOffset] = useState(0);
const [order, setOrder] = useState<string[]>([]);
const theme = useThemeContext();
const location = useLocation();
const dispatch = useDispatch();
const gotoPost = useGoToPost();
const params = new URLSearchParams(location.search);
const query = params.get('q');
const fetchMore = useCallback(
async (reset = false) => {
if (!query) return;
if (reset) {
const messageIds: any = await dispatch(searchPosts(query, 20, 0));
console.log({ messageIds });
setOffset(20);
setOrder(messageIds);
} else {
if (order.length % limit) return;
const messageIds: any = await dispatch(searchPosts(query, limit, offset));
setOffset(offset + limit);
setOrder(order.concat(messageIds));
}
},
[limit, offset, order, query]
);
useEffect(() => {
fetchMore(true);
}, [query]);
return (
<InfiniteScrollable
className={classNames('search-view', 'mx-4 py-2', {
'border-gray-100': theme !== 'dark',
'border-gray-800': theme === 'dark',
})}
bottomOffset={128}
onScrolledToBottom={fetchMore}>
{!order.length && (
<div className="text-sm text-gray-500 text-center mt-2">No matching results</div>
)}
{order.map((messageId, i) => {
return (
<Post
key={messageId}
// key={i}
className={classNames('rounded-xl transition-colors mb-1 cursor-pointer border', {
'hover:border-gray-300 border-gray-200': theme !== 'dark',
'hover:border-gray-700 border-gray-800': theme === 'dark',
})}
messageId={messageId}
onClick={() => gotoPost(messageId)}
/>
);
})}
</InfiniteScrollable>
);
}

View File

@@ -0,0 +1,17 @@
@import '../../util/variable';
.search-view {
width: 36.25rem;
overflow-y: auto;
&::-webkit-scrollbar {
width: 0; /* Remove scrollbar space */
background: transparent; /* Optional: just make scrollbar invisible */
}
}
@media only screen and (max-width: 768px) {
.search-view {
margin: 0;
}
}

View File

@@ -32,6 +32,7 @@ test('store - should initialize', async () => {
mods: { posts: {} },
app: {
theme: 'dark',
notifications: 0,
},
chats: {
chats: {
@@ -39,6 +40,7 @@ test('store - should initialize', async () => {
order: [],
},
messages: {},
unreads: {},
},
});
});

View File

@@ -0,0 +1,9 @@
export enum NotificationType {
DIRECT = 'DIRECT',
LIKE = 'LIKE',
REPOST = 'REPOST',
MEMBER_INVITE = 'MEMBER_INVITE',
MEMBER_ACCEPT = 'MEMBER_ACCEPT',
REPLY = 'REPLY',
MENTION = 'MENTION',
}

View File

@@ -47,16 +47,16 @@ class SSE extends EventEmitter2 {
}
});
this.updateTopics(topics);
this.updateTopics([...new Set(topics)]);
for (const chatId in zkchat.activeChats) {
const chat = zkchat.activeChats[chatId];
if (chat.type === 'DIRECT') {
if (chat.senderHash && chat.senderECDH) {
await this.updateTopics([`ecdh:${chat.senderECDH}`]);
}
}
}
// for (const chatId in zkchat.activeChats) {
// const chat = zkchat.activeChats[chatId];
// if (chat.type === 'DIRECT') {
// if (chat.senderHash && chat.senderECDH) {
// await this.updateTopics([`ecdh:${chat.senderECDH}`]);
// }
// }
// }
if (this.pulse) {
clearInterval(this.pulse);
@@ -79,6 +79,9 @@ class SSE extends EventEmitter2 {
case 'NEW_CHAT_MESSAGE':
this.emit(data.type, data.message);
return;
case 'UPDATE_UNREAD':
this.emit(data.type, data.message);
return;
default:
return;
}

View File

@@ -15,6 +15,8 @@ export const defaultENS = new ENS({
});
const cachedName: any = {};
const fetchPromises: any = {};
export const fetchNameByAddress = async (address: string) => {
if (typeof cachedName[address] !== 'undefined') {
return cachedName[address];
@@ -33,11 +35,21 @@ export const fetchAddressByName = async (ens: string) => {
if (typeof cachedName[ens] !== 'undefined') {
return cachedName[ens];
}
const address = await defaultENS.name(ens).getAddress();
if (address) {
cachedName[ens] = address || null;
if (fetchPromises[ens]) {
return fetchPromises[ens];
}
return address;
const fetchPromise = new Promise(async resolve => {
const address = await defaultENS.name(ens).getAddress();
if (address) {
cachedName[ens] = address || null;
}
delete fetchPromises[ens];
resolve(address);
});
fetchPromises[ens] = fetchPromise;
return fetchPromise;
};

View File

@@ -94,6 +94,7 @@ export type Chat =
| {
type: 'PUBLIC_ROOM';
receiver: string;
receiverECDH?: string;
senderECDH: string;
senderHash?: string;
};
@@ -241,6 +242,7 @@ export class ZKChatClient extends EventEmitter2 {
MESSAGE_PREPENDED: 'MESSAGE_PREPENDED',
MESSAGE_APPENDED: 'MESSAGE_APPENDED',
CHAT_CREATED: 'CHAT_CREATED',
IDENTITY_CHANGED: 'IDENTITY_CHANGED',
};
activeChats: {
@@ -375,11 +377,13 @@ export class ZKChatClient extends EventEmitter2 {
const validChats = await this._validateConvos(bucket.activeChats);
this.activeChats = validChats;
await this.fetchActiveChats(this.identity!.ecdh.pub);
}
async importIdentity(identity: ZKChatIdentity) {
this.identity = identity;
await this._load();
this.emit(ZKChatClient.EVENTS.IDENTITY_CHANGED);
}
createDM = async (receiver: string, receiverECDH: string, isAnon?: boolean): Promise<Chat> => {

View File

@@ -79,6 +79,10 @@ module.exports = [
app: path.join(__dirname, 'src', 'app.tsx'),
serviceWorker: path.join(__dirname, 'src', 'serviceWorkers', 'index.ts'),
},
// ignoreWarnings: [
// { module: /node_modules\/gun\/gun.js/ },
// { module: /node_modules\/gun\/sea.js/ },
// ],
// [
// ...(isProd ? [] : devServerEntries),
// `./src/app.tsx`,
@@ -100,6 +104,7 @@ module.exports = [
os: require.resolve('os-browserify/browser'),
http: require.resolve('stream-http'),
https: require.resolve('https-browserify'),
assert: require.resolve('assert/'),
constants: false,
fs: false,
},
@@ -137,6 +142,7 @@ module.exports = [
title: process.env.APP_TITLE || 'Zkitter',
inject: true,
}),
new webpack.ContextReplacementPlugin(/gun/),
],
stats: 'minimal',
devServer: {