Compare commits

...

172 commits

Author SHA1 Message Date
semantic-release-bot
ebd4e997bd chore(release): 1.7.1 [skip ci]
## [1.7.1](https://github.com/molvqingtai/WebChat/compare/v1.7.0...v1.7.1) (2024-11-15)

### Bug Fixes

* parse icon url error ([7763f34](7763f34d5d))

### Performance Improvements

* add number animation ([eb37dd2](eb37dd2833))
* compatible with rectangular icons ([b860b16](b860b16e90))
* optimize scrollbar ([c5185e4](c5185e419c))
2024-11-15 01:10:44 +00:00
molvqingtai
64f7f37288 Merge branch 'develop' 2024-11-15 09:09:12 +08:00
molvqingtai
c5185e419c perf: optimize scrollbar 2024-11-15 09:07:13 +08:00
molvqingtai
eb37dd2833 perf: add number animation 2024-11-15 08:55:02 +08:00
molvqingtai
b860b16e90 perf: compatible with rectangular icons 2024-11-14 22:55:29 +08:00
molvqingtai
7763f34d5d fix: parse icon url error 2024-11-14 22:44:03 +08:00
semantic-release-bot
efa44f86db chore(release): 1.7.0 [skip ci]
# [1.7.0](https://github.com/molvqingtai/WebChat/compare/v1.6.6...v1.7.0) (2024-11-13)

### Features

* ranking of users supporting online websites Closes [#48](https://github.com/molvqingtai/WebChat/issues/48) ([d0fea9e](d0fea9e42d))
2024-11-13 11:27:13 +00:00
molvqingtai
e05ce9631c Merge branch 'develop' 2024-11-13 19:25:34 +08:00
molvqingtai
d0fea9e42d feat: ranking of users supporting online websites Closes #48 2024-11-13 19:24:16 +08:00
semantic-release-bot
1259b2f178 chore(release): 1.6.6 [skip ci]
## [1.6.6](https://github.com/molvqingtai/WebChat/compare/v1.6.5...v1.6.6) (2024-11-09)

### Bug Fixes

* the number of online users is inaccurate ([c6301a8](c6301a826e))

### Performance Improvements

* optimize taost dark mode ([00f0bd0](00f0bd08b0))
* theme mode is compatible with website themes by default ([6222e3f](6222e3f8af))
2024-11-09 14:45:34 +00:00
molvqingtai
4f9c135fb9 Merge branch 'develop' 2024-11-09 22:44:01 +08:00
molvqingtai
00f0bd08b0 perf: optimize taost dark mode 2024-11-09 22:43:32 +08:00
molvqingtai
6222e3f8af perf: theme mode is compatible with website themes by default 2024-11-09 05:57:00 +08:00
molvqingtai
c6301a826e fix: the number of online users is inaccurate 2024-11-09 05:11:33 +08:00
semantic-release-bot
a42d90fd86 chore(release): 1.6.5 [skip ci]
## [1.6.5](https://github.com/molvqingtai/WebChat/compare/v1.6.4...v1.6.5) (2024-11-07)

### Performance Improvements

* delete setup exit animation ([d325be4](d325be4bec))
2024-11-07 19:12:34 +00:00
molvqingtai
a71eca2913 Merge branch 'master' of github.com:molvqingtai/WebChat 2024-11-08 03:10:49 +08:00
molvqingtai
adc93b8040 Merge branch 'develop' 2024-11-08 03:10:28 +08:00
molvqingtai
d325be4bec perf: delete setup exit animation 2024-11-08 03:09:28 +08:00
semantic-release-bot
43c9fb86b4 chore(release): 1.6.4 [skip ci]
## [1.6.4](https://github.com/molvqingtai/WebChat/compare/v1.6.3...v1.6.4) (2024-11-07)

### Performance Improvements

* check message format ([f6864e0](f6864e06be))
2024-11-07 18:27:40 +00:00
molvqingtai
47d863b8a0 Merge branch 'develop' 2024-11-08 02:25:59 +08:00
molvqingtai
f6864e06be perf: check message format 2024-11-08 02:24:48 +08:00
semantic-release-bot
de8940398e chore(release): 1.6.3 [skip ci]
## [1.6.3](https://github.com/molvqingtai/WebChat/compare/v1.6.2...v1.6.3) (2024-11-06)

### Performance Improvements

* optimize image processing ([9438a31](9438a3169d))
2024-11-06 10:27:03 +00:00
molvqingtai
5eba7700c8 Merge branch 'develop' 2024-11-06 18:25:17 +08:00
molvqingtai
9438a3169d perf: optimize image processing 2024-11-06 18:24:43 +08:00
molvqingtai
7b1663fb9c Merge branch 'develop' 2024-11-06 18:18:32 +08:00
molvqingtai
7681682445 chore: storage delete JSONR 2024-11-06 18:17:33 +08:00
molvqingtai
893342a317 chore: optimize some logic 2024-11-05 17:12:32 +08:00
molvqingtai
0f08860288 Merge branch 'master' of github.com:molvqingtai/WebChat 2024-11-04 21:36:16 +08:00
molvqingtai
0418e75ece Merge branch 'develop' 2024-11-04 21:35:53 +08:00
molvqingtai
539508d1ab chore: add comments 2024-11-04 21:35:08 +08:00
semantic-release-bot
0c670219ff chore(release): 1.6.2 [skip ci]
## [1.6.2](https://github.com/molvqingtai/WebChat/compare/v1.6.1...v1.6.2) (2024-11-04)

### Bug Fixes

* incompatible with old data of userInfo, causing crash ([d5ced07](d5ced0718f))
2024-11-04 13:34:10 +00:00
molvqingtai
fc32cc283a Merge branch 'develop' 2024-11-04 21:32:27 +08:00
molvqingtai
d5ced0718f fix: incompatible with old data of userInfo, causing crash 2024-11-04 21:31:45 +08:00
semantic-release-bot
daa55d7f58 chore(release): 1.6.1 [skip ci]
## [1.6.1](https://github.com/molvqingtai/WebChat/compare/v1.6.0...v1.6.1) (2024-11-03)

### Bug Fixes

* sooner style ([7e49ec2](7e49ec210e))
2024-11-03 22:08:36 +00:00
molvqingtai
dfc0aaa8c2 Merge branch 'develop' 2024-11-04 06:07:02 +08:00
molvqingtai
7e49ec210e fix: sooner style 2024-11-04 06:01:25 +08:00
semantic-release-bot
ff8e2c980d chore(release): 1.6.0 [skip ci]
# [1.6.0](https://github.com/molvqingtai/WebChat/compare/v1.5.4...v1.6.0) (2024-11-03)

### Features

* support offline message sync [#45](https://github.com/molvqingtai/WebChat/issues/45) ([7c4f655](7c4f65573c))
2024-11-03 01:00:55 +00:00
molvqingtai
75b52d4003 Merge branch 'develop' 2024-11-03 08:59:11 +08:00
molvqingtai
44f395663f chore: update deps 2024-11-03 08:54:03 +08:00
molvqingtai
99aed36c00 chore: delete world time 2024-11-03 08:52:21 +08:00
molvqingtai
7c4f65573c feat: support offline message sync #45 2024-11-03 08:01:52 +08:00
molvqingtai
331d5dd11d Merge branch 'develop' 2024-11-01 07:10:23 +08:00
molvqingtai
96b6cd564c docs: update readme 2024-11-01 07:09:44 +08:00
semantic-release-bot
abdb818cf2 chore(release): 1.5.4 [skip ci]
## [1.5.4](https://github.com/molvqingtai/WebChat/compare/v1.5.3...v1.5.4) (2024-10-31)

### Performance Improvements

* support reading image from the clipboard ([362d7db](362d7db738))
2024-10-31 06:01:38 +00:00
molvqingtai
94c927c37f Merge branch 'develop' 2024-10-31 14:00:12 +08:00
John Wu
ebd22bc6f2 docs: update README.md 2024-10-31 13:59:34 +08:00
John Wu
78e1cd7361
docs: update README.md 2024-10-31 13:58:05 +08:00
molvqingtai
362d7db738 perf: support reading image from the clipboard 2024-10-31 11:38:13 +08:00
semantic-release-bot
46134e0b37 chore(release): 1.5.3 [skip ci]
## [1.5.3](https://github.com/molvqingtai/WebChat/compare/v1.5.2...v1.5.3) (2024-10-30)

### Bug Fixes

* insertion cursor position is incorrect ([2987c2d](2987c2d85d))
2024-10-30 16:17:07 +00:00
molvqingtai
2a8b2fa05b Merge branch 'develop' 2024-10-31 00:15:27 +08:00
molvqingtai
2987c2d85d fix: insertion cursor position is incorrect 2024-10-31 00:14:59 +08:00
semantic-release-bot
14cf6a3996 chore(release): 1.5.2 [skip ci]
## [1.5.2](https://github.com/molvqingtai/WebChat/compare/v1.5.1...v1.5.2) (2024-10-30)

### Performance Improvements

* optimize theme style ([7b91944](7b91944fbf))
2024-10-30 14:36:02 +00:00
molvqingtai
2bd5dde44e Merge branch 'develop' 2024-10-30 22:34:25 +08:00
molvqingtai
7b91944fbf perf: optimize theme style 2024-10-30 22:33:09 +08:00
molvqingtai
2b78d506de Merge branch 'develop' 2024-10-30 10:26:07 +08:00
molvqingtai
7fd2a3596b docs: add firefox link 2024-10-30 10:25:45 +08:00
semantic-release-bot
36750b7f17 chore(release): 1.5.1 [skip ci]
## [1.5.1](https://github.com/molvqingtai/WebChat/compare/v1.5.0...v1.5.1) (2024-10-29)

### Bug Fixes

* incompatibility with old data causes app to crash ([bd07bdc](bd07bdc2c3))
2024-10-29 14:21:06 +00:00
molvqingtai
41591e8d05 Merge branch 'develop' 2024-10-29 22:19:20 +08:00
molvqingtai
bd07bdc2c3 fix: incompatibility with old data causes app to crash 2024-10-29 22:17:16 +08:00
semantic-release-bot
e4d0a93016 chore(release): 1.5.0 [skip ci]
# [1.5.0](https://github.com/molvqingtai/WebChat/compare/v1.4.0...v1.5.0) (2024-10-29)

### Features

* support send image button ([a01a93f](a01a93f260))
2024-10-29 02:26:15 +00:00
molvqingtai
859a8f4a01 Merge branch 'develop' 2024-10-29 10:24:34 +08:00
molvqingtai
d44bceb7a8 chore: update deps 2024-10-29 10:22:53 +08:00
molvqingtai
a01a93f260 feat: support send image button 2024-10-29 10:22:21 +08:00
molvqingtai
4cb74c9358 Merge branch 'develop' 2024-10-28 10:01:08 +08:00
molvqingtai
ca97c5e976 chore: fix build command 2024-10-28 10:00:47 +08:00
semantic-release-bot
27d66e0c1f chore(release): 1.4.0 [skip ci]
# [1.4.0](https://github.com/molvqingtai/WebChat/compare/v1.3.1...v1.4.0) (2024-10-28)

### Bug Fixes

* delete bad z-index ([bcdd435](bcdd435e45))

### Features

* app button support drag ([4eba638](4eba638a36))
* support [@user](https://github.com/user) syntax ([bef576a](bef576a77b))
* support dark mode ([010aa2f](010aa2f45e))

### Performance Improvements

* optimize danmuku theme styles ([4f6eb56](4f6eb560fe))
* optimize header theme styles ([025166e](025166ead5))
* optimize theme styles ([2d051fe](2d051fedd7))
* reset app position when window resize ([eee1735](eee1735654))
2024-10-28 01:50:14 +00:00
molvqingtai
44eac1c84f Merge branch 'develop' 2024-10-28 09:48:46 +08:00
molvqingtai
13833ff8b0 ci: delete submit store flow 2024-10-28 09:48:27 +08:00
molvqingtai
b869efe6aa Merge branch 'develop' 2024-10-28 09:39:36 +08:00
molvqingtai
66ba14e330 chore: rename file 2024-10-28 09:38:57 +08:00
molvqingtai
165176b9a4 Merge branch 'develop' 2024-10-28 09:32:20 +08:00
molvqingtai
025166ead5 perf: optimize header theme styles 2024-10-28 09:31:38 +08:00
molvqingtai
4f6eb560fe perf: optimize danmuku theme styles 2024-10-28 09:22:14 +08:00
molvqingtai
2d051fedd7 perf: optimize theme styles 2024-10-28 09:03:57 +08:00
molvqingtai
bcdd435e45 fix: delete bad z-index 2024-10-27 12:27:12 +08:00
molvqingtai
a2eb8c2915 Merge branch 'develop-theme' into develop 2024-10-27 12:06:24 +08:00
molvqingtai
eee1735654 perf: reset app position when window resize 2024-10-27 10:16:00 +08:00
molvqingtai
bef576a77b feat: support @user syntax 2024-10-27 09:02:56 +08:00
guiboji
2456f87c18 chore: 'update danmaku' 2024-10-21 19:13:43 +08:00
guiboji
22c3261d27 chore: 'update gitignore' 2024-10-21 18:41:40 +08:00
guiboji
010aa2f45e feat: support dark mode 2024-10-21 18:31:16 +08:00
molvqingtai
4eba638a36 feat: app button support drag 2024-10-19 01:18:07 +08:00
molvqingtai
f7cdf212bc chore: update deps 2024-10-17 01:22:39 +08:00
semantic-release-bot
71856fed87 chore(release): 1.3.1 [skip ci]
## [1.3.1](https://github.com/molvqingtai/WebChat/compare/v1.3.0...v1.3.1) (2024-10-16)

### Bug Fixes

* missing tabs permission ([3cfc16c](3cfc16c9ee))
2024-10-16 09:01:01 +00:00
molvqingtai
544f58ecde Merge branch 'develop' 2024-10-16 16:41:01 +08:00
molvqingtai
3cfc16c9ee fix: missing tabs permission 2024-10-16 02:00:24 +08:00
molvqingtai
bdf3ec8fce docs: update readme 2024-10-14 21:57:23 +08:00
molvqingtai
e12f5e3d97 docs: update readme 2024-10-14 21:53:27 +08:00
semantic-release-bot
be56c1bf85 chore(release): 1.3.0 [skip ci]
# [1.3.0](https://github.com/molvqingtai/WebChat/compare/v1.2.2...v1.3.0) (2024-10-12)

### Bug Fixes

* p2p use artico ([a0a8462](a0a8462f5f))

### Features

* support notification ([9898718](9898718b1a))

### Performance Improvements

* notification supports clicking to open the source website ([653229c](653229c8fa))
2024-10-12 22:45:44 +00:00
molvqingtai
a3ac1092f9 Merge branch 'develop' 2024-10-13 06:38:09 +08:00
molvqingtai
561052a3f4 chore: optimize the style of online card 2024-10-13 06:37:24 +08:00
molvqingtai
645e728178 chore: change danmuku durationRange 2024-10-13 06:23:24 +08:00
molvqingtai
1d3c92763b chore: additional information 2024-10-13 06:16:57 +08:00
molvqingtai
653229c8fa perf: notification supports clicking to open the source website 2024-10-13 06:13:45 +08:00
molvqingtai
aa3c0703dc chore: github link style adjustment 2024-10-13 04:39:21 +08:00
molvqingtai
9898718b1a feat: support notification 2024-10-13 04:11:37 +08:00
molvqingtai
f66b2326eb docs: update readme 2024-10-12 12:13:38 +08:00
molvqingtai
898d266ea7 docs: update readme 2024-10-12 12:08:07 +08:00
molvqingtai
a6104635d5 chore: block csdn 2024-10-12 11:58:07 +08:00
molvqingtai
a0a8462f5f fix: p2p use artico 2024-10-11 21:32:02 +08:00
semantic-release-bot
d5ad87dc36 chore(release): 1.2.2 [skip ci]
## [1.2.2](https://github.com/molvqingtai/WebChat/compare/v1.2.1...v1.2.2) (2024-10-11)

### Bug Fixes

* danmuku message ellipsis ([e8e243e](e8e243ee09))
* online text overflow ([d4e42c6](d4e42c68ca))
2024-10-11 07:52:34 +00:00
molvqingtai
509f18e737 Merge branch 'develop' 2024-10-11 15:50:57 +08:00
molvqingtai
d4e42c68ca fix: online text overflow 2024-10-11 15:50:15 +08:00
molvqingtai
e8e243ee09 fix: danmuku message ellipsis 2024-10-11 15:22:22 +08:00
semantic-release-bot
08b23aade9 chore(release): 1.2.1 [skip ci]
## [1.2.1](https://github.com/molvqingtai/WebChat/compare/v1.2.0...v1.2.1) (2024-10-10)

### Bug Fixes

* avatar is not displayed completely ([de97d05](de97d05528))
* the text in the button is not visible in dark mode ([d6652cb](d6652cb2a4))
* the text in the textarea is not visible in dark mode ([d75a191](d75a191ded))

### Performance Improvements

* change https://github.com/weizhenye/Danmaku to https://github.com/imtaotao/danmu ([05ee49e](05ee49e7c4))
* submit store flow ([5235a6e](5235a6ee87))
2024-10-10 08:47:25 +00:00
molvqingtai
62b96dcf10 Merge branch 'develop' 2024-10-10 16:45:46 +08:00
molvqingtai
ee829af279 chore: adjust danmuku click logic 2024-10-10 16:45:13 +08:00
molvqingtai
619ebc70c4 chore: update deps 2024-10-10 05:35:00 +08:00
molvqingtai
d6652cb2a4 fix: the text in the button is not visible in dark mode 2024-10-10 05:30:55 +08:00
molvqingtai
d75a191ded fix: the text in the textarea is not visible in dark mode 2024-10-10 05:19:43 +08:00
molvqingtai
de97d05528 fix: avatar is not displayed completely 2024-10-10 05:13:01 +08:00
molvqingtai
05ee49e7c4 perf: change https://github.com/weizhenye/Danmaku to https://github.com/imtaotao/danmu 2024-10-10 04:54:33 +08:00
molvqingtai
3dde23eb15 Merge branch 'develop' 2024-10-09 19:28:06 +08:00
molvqingtai
5235a6ee87 perf: submit store flow 2024-10-09 19:27:45 +08:00
molvqingtai
43df901280 Merge branch 'develop' 2024-10-09 19:25:05 +08:00
molvqingtai
de81de92a9 ci: submit store test 2024-10-09 19:22:24 +08:00
molvqingtai
a4431c43c8 Merge branch 'develop' 2024-10-09 18:58:48 +08:00
molvqingtai
614295b7b2 docs: update readme 2024-10-09 18:57:13 +08:00
molvqingtai
70a7be0791 docs: update readme 2024-10-09 18:54:46 +08:00
molvqingtai
182ccc31dd ci: add submit store flow 2024-10-09 17:52:47 +08:00
semantic-release-bot
f3a9500f63 chore(release): 1.2.0 [skip ci]
# [1.2.0](https://github.com/molvqingtai/WebChat/compare/v1.1.6...v1.2.0) (2024-10-08)

### Features

* support display of online user list ([4c7137d](4c7137d045))
2024-10-08 11:40:22 +00:00
molvqingtai
d286188a3f Merge branch 'develop' 2024-10-08 19:38:51 +08:00
molvqingtai
4c7137d045 feat: support display of online user list 2024-10-08 19:38:03 +08:00
semantic-release-bot
d171f509ce chore(release): 1.1.6 [skip ci]
## [1.1.6](https://github.com/molvqingtai/WebChat/compare/v1.1.5...v1.1.6) (2024-10-04)

### Bug Fixes

* it should not be sent when composing ([8ee9ed6](8ee9ed6259))
2024-10-04 18:46:56 +00:00
molvqingtai
56afb1f663 Merge branch 'develop' 2024-10-05 02:45:15 +08:00
molvqingtai
8ee9ed6259 fix: it should not be sent when composing 2024-10-05 02:43:16 +08:00
semantic-release-bot
3cfef16e17 chore(release): 1.1.5 [skip ci]
## [1.1.5](https://github.com/molvqingtai/WebChat/compare/v1.1.4...v1.1.5) (2024-10-02)

### Bug Fixes

* multiple tabs display duplicate online users ([8b843ac](8b843ac45c))
2024-10-02 15:42:23 +00:00
molvqingtai
c5bf19ba00 Merge branch 'master' of github.com:molvqingtai/WebChat 2024-10-02 23:40:47 +08:00
molvqingtai
c05f5d987e Merge branch 'develop' 2024-10-02 23:40:32 +08:00
molvqingtai
8b843ac45c fix: multiple tabs display duplicate online users 2024-10-02 23:40:12 +08:00
semantic-release-bot
90600794eb chore(release): 1.1.4 [skip ci]
## [1.1.4](https://github.com/molvqingtai/WebChat/compare/v1.1.3...v1.1.4) (2024-10-02)

### Bug Fixes

* firfox requestAnimationFrame error ([65bf9b2](65bf9b2419))
2024-10-02 11:52:09 +00:00
molvqingtai
74aa452df7 Merge branch 'master' of github.com:molvqingtai/WebChat 2024-10-02 19:50:37 +08:00
molvqingtai
8be6bd67bf Merge branch 'develop' 2024-10-02 19:50:21 +08:00
molvqingtai
65bf9b2419 fix: firfox requestAnimationFrame error 2024-10-02 19:49:54 +08:00
semantic-release-bot
9b55e52439 chore(release): 1.1.3 [skip ci]
## [1.1.3](https://github.com/molvqingtai/WebChat/compare/v1.1.2...v1.1.3) (2024-10-02)

### Performance Improvements

* add version link ([4551ad2](4551ad2964))
2024-10-02 10:26:20 +00:00
molvqingtai
0ac691a5c8 Merge branch 'develop' 2024-10-02 18:24:38 +08:00
molvqingtai
4551ad2964 perf: add version link 2024-10-02 18:21:42 +08:00
semantic-release-bot
3a0a208c99 chore(release): 1.1.2 [skip ci]
## [1.1.2](https://github.com/molvqingtai/WebChat/compare/v1.1.1...v1.1.2) (2024-10-02)

### Performance Improvements

* support unread status ([1f44af8](1f44af873c))
2024-10-02 09:59:30 +00:00
molvqingtai
7a9fcad960 Merge branch 'develop' 2024-10-02 17:57:57 +08:00
molvqingtai
1f44af873c perf: support unread status 2024-10-02 17:57:28 +08:00
molvqingtai
078f5eae83 Merge branch 'master' of github.com:molvqingtai/WebChat 2024-10-02 00:32:27 +08:00
molvqingtai
8ada523124 Merge branch 'develop' 2024-10-02 00:32:16 +08:00
molvqingtai
65a320ab35 docs: update readme 2024-10-02 00:30:24 +08:00
semantic-release-bot
3001b1f49c chore(release): 1.1.1 [skip ci]
## [1.1.1](https://github.com/molvqingtai/WebChat/compare/v1.1.0...v1.1.1) (2024-10-01)

### Performance Improvements

* a tag use Link component ([fce64b7](fce64b744c))
2024-10-01 16:20:35 +00:00
molvqingtai
7ba434c71d Merge branch 'develop' 2024-10-02 00:18:58 +08:00
molvqingtai
fce64b744c perf: a tag use Link component 2024-10-02 00:18:31 +08:00
semantic-release-bot
323c9efbf5 chore(release): 1.1.0 [skip ci]
# [1.1.0](https://github.com/molvqingtai/WebChat/compare/v1.0.29...v1.1.0) (2024-09-30)

### Features

* support danmaku ([999a55c](999a55c65f))
2024-09-30 13:51:47 +00:00
molvqingtai
13fc98c66d Merge branch 'develop' 2024-09-30 21:50:08 +08:00
molvqingtai
ab5e34b16a chore: rename file name 2024-09-30 21:49:38 +08:00
molvqingtai
3f67d5ac90 Merge branch 'develop' 2024-09-30 21:43:42 +08:00
molvqingtai
8409e0a0c3 docs: update readme 2024-09-30 21:43:02 +08:00
molvqingtai
2e2a4a7f90 Merge branch 'master' of github.com:molvqingtai/WebChat 2024-09-30 21:40:30 +08:00
molvqingtai
999a55c65f feat: support danmaku 2024-09-30 21:39:47 +08:00
semantic-release-bot
6413f2fa8e chore(release): 1.0.29 [skip ci]
## [1.0.29](https://github.com/molvqingtai/WebChat/compare/v1.0.28...v1.0.29) (2024-09-29)

### Bug Fixes

* compile by environment ([52cd203](52cd203a53))
* error when leaving the room without joining ([8476595](8476595011))
2024-09-29 07:45:54 +00:00
molvqingtai
52cd203a53 fix: compile by environment 2024-09-29 15:34:10 +08:00
molvqingtai
8476595011 fix: error when leaving the room without joining 2024-09-29 15:33:12 +08:00
semantic-release-bot
a473a40807 chore(release): 1.0.28 [skip ci]
## [1.0.28](https://github.com/molvqingtai/WebChat/compare/v1.0.27...v1.0.28) (2024-09-28)

### Bug Fixes

* svg icon size ([089d69a](089d69a095))
2024-09-28 21:25:23 +00:00
molvqingtai
089d69a095 fix: svg icon size 2024-09-29 05:23:48 +08:00
molvqingtai
e764f334d1 Merge branch 'develop' 2024-09-28 19:22:07 +08:00
molvqingtai
a215d6c06a docs: update readme 2024-09-28 19:21:36 +08:00
molvqingtai
4fd7e10f50 Merge branch 'develop' of github.com:molvqingtai/WebChat into develop 2024-09-28 19:20:45 +08:00
molvqingtai
7da33e608e docs: update readme 2024-09-28 19:20:21 +08:00
semantic-release-bot
fc3004a954 chore(release): 1.0.27 [skip ci]
## [1.0.27](https://github.com/molvqingtai/WebChat/compare/v1.0.26...v1.0.27) (2024-09-28)

### Bug Fixes

* uniformly resizable size ([3bb2b55](3bb2b55f21))

### Performance Improvements

* add isolate events ([8fd5f04](8fd5f04ecd))
2024-09-28 10:49:50 +00:00
molvqingtai
25f0abfe5d Merge branch 'develop' 2024-09-28 18:48:24 +08:00
John Wu
eca6802188
docs: Update README.md 2024-09-28 18:47:51 +08:00
molvqingtai
b0a223896d docs: update 2024-09-28 18:41:31 +08:00
molvqingtai
b2ccd5f4c1 docs: update 2024-09-28 18:39:39 +08:00
molvqingtai
364c2c8990 docs: update 2024-09-28 18:35:43 +08:00
molvqingtai
4e9c91ef0d docs: readme add video 2024-09-28 18:28:46 +08:00
molvqingtai
8fd5f04ecd perf: add isolate events 2024-09-28 18:15:18 +08:00
molvqingtai
684304db9b docs: update readme 2024-09-28 16:23:44 +08:00
molvqingtai
3bb2b55f21 fix: uniformly resizable size 2024-09-28 14:21:35 +08:00
109 changed files with 5706 additions and 4959 deletions

View file

@ -21,7 +21,7 @@ jobs:
- run: pnpm install --ignore-scripts
- run: pnpm wxt prepare
- run: pnpm run lint
- run: pnpm run tsc
- run: pnpm run check
release:
needs: linter

1
.gitignore vendored
View file

@ -14,4 +14,5 @@ web-ext.config.ts
*.pem
*.xpi
*.zip
.idea

View file

@ -1,2 +1,2 @@
npx commitlint --edit "$1"
pnpm commitlint --edit "$1"

View file

@ -1,2 +1,2 @@
pnpm lint-staged && pnpm tsc
pnpm lint-staged && pnpm check

View file

@ -21,7 +21,7 @@ export default {
[
'@semantic-release/exec',
{
prepareCmd: 'pnpm run pack'
prepareCmd: `pnpm run pack`
}
],
/**

View file

@ -1,3 +1,266 @@
## [1.7.1](https://github.com/molvqingtai/WebChat/compare/v1.7.0...v1.7.1) (2024-11-15)
### Bug Fixes
* parse icon url error ([7763f34](https://github.com/molvqingtai/WebChat/commit/7763f34d5d07a104f8a66e53b05a7f87a4e0da28))
### Performance Improvements
* add number animation ([eb37dd2](https://github.com/molvqingtai/WebChat/commit/eb37dd28338d9e5420c91fb3d25c318411bdfd31))
* compatible with rectangular icons ([b860b16](https://github.com/molvqingtai/WebChat/commit/b860b16e908a744f615c8cea35a3dcd4ca008f1a))
* optimize scrollbar ([c5185e4](https://github.com/molvqingtai/WebChat/commit/c5185e419c5e175b8bc30e3f2b2207c18b9503b2))
# [1.7.0](https://github.com/molvqingtai/WebChat/compare/v1.6.6...v1.7.0) (2024-11-13)
### Features
* ranking of users supporting online websites Closes [#48](https://github.com/molvqingtai/WebChat/issues/48) ([d0fea9e](https://github.com/molvqingtai/WebChat/commit/d0fea9e42d52d0e56171c08ed780066d66ebe3f1))
## [1.6.6](https://github.com/molvqingtai/WebChat/compare/v1.6.5...v1.6.6) (2024-11-09)
### Bug Fixes
* the number of online users is inaccurate ([c6301a8](https://github.com/molvqingtai/WebChat/commit/c6301a826ebcf38a34b93a02c8013dd1ef9e7abc))
### Performance Improvements
* optimize taost dark mode ([00f0bd0](https://github.com/molvqingtai/WebChat/commit/00f0bd08b04e49f83cee60bb5767acd460a1b5d0))
* theme mode is compatible with website themes by default ([6222e3f](https://github.com/molvqingtai/WebChat/commit/6222e3f8af1bf4fad2466a9bf88c3b3159478a86))
## [1.6.5](https://github.com/molvqingtai/WebChat/compare/v1.6.4...v1.6.5) (2024-11-07)
### Performance Improvements
* delete setup exit animation ([d325be4](https://github.com/molvqingtai/WebChat/commit/d325be4becf562d2232a1a1e9a4e1582e44869a2))
## [1.6.4](https://github.com/molvqingtai/WebChat/compare/v1.6.3...v1.6.4) (2024-11-07)
### Performance Improvements
* check message format ([f6864e0](https://github.com/molvqingtai/WebChat/commit/f6864e06be01fd434136901ae85278ed4eab4c03))
## [1.6.3](https://github.com/molvqingtai/WebChat/compare/v1.6.2...v1.6.3) (2024-11-06)
### Performance Improvements
* optimize image processing ([9438a31](https://github.com/molvqingtai/WebChat/commit/9438a3169dfda166776610ba6aac1ac168231636))
## [1.6.2](https://github.com/molvqingtai/WebChat/compare/v1.6.1...v1.6.2) (2024-11-04)
### Bug Fixes
* incompatible with old data of userInfo, causing crash ([d5ced07](https://github.com/molvqingtai/WebChat/commit/d5ced0718f586ca156e80c56078ae1f3de4ee917))
## [1.6.1](https://github.com/molvqingtai/WebChat/compare/v1.6.0...v1.6.1) (2024-11-03)
### Bug Fixes
* sooner style ([7e49ec2](https://github.com/molvqingtai/WebChat/commit/7e49ec210ed706a0ee94b3c2b7b17af719b604e1))
# [1.6.0](https://github.com/molvqingtai/WebChat/compare/v1.5.4...v1.6.0) (2024-11-03)
### Features
* support offline message sync [#45](https://github.com/molvqingtai/WebChat/issues/45) ([7c4f655](https://github.com/molvqingtai/WebChat/commit/7c4f65573c591da2a8c8938e14066cee96d15b40))
## [1.5.4](https://github.com/molvqingtai/WebChat/compare/v1.5.3...v1.5.4) (2024-10-31)
### Performance Improvements
* support reading image from the clipboard ([362d7db](https://github.com/molvqingtai/WebChat/commit/362d7db7386d978c6d053a3e7262adf844e24f55))
## [1.5.3](https://github.com/molvqingtai/WebChat/compare/v1.5.2...v1.5.3) (2024-10-30)
### Bug Fixes
* insertion cursor position is incorrect ([2987c2d](https://github.com/molvqingtai/WebChat/commit/2987c2d85dd84639c06848ddc5cd4dc0b3288538))
## [1.5.2](https://github.com/molvqingtai/WebChat/compare/v1.5.1...v1.5.2) (2024-10-30)
### Performance Improvements
* optimize theme style ([7b91944](https://github.com/molvqingtai/WebChat/commit/7b91944fbf60c27d21274ddb7f28f97344c89ef5))
## [1.5.1](https://github.com/molvqingtai/WebChat/compare/v1.5.0...v1.5.1) (2024-10-29)
### Bug Fixes
* incompatibility with old data causes app to crash ([bd07bdc](https://github.com/molvqingtai/WebChat/commit/bd07bdc2c3df031d5a04d3eebade5d7fc7672600))
# [1.5.0](https://github.com/molvqingtai/WebChat/compare/v1.4.0...v1.5.0) (2024-10-29)
### Features
* support send image button ([a01a93f](https://github.com/molvqingtai/WebChat/commit/a01a93f260c3fefadb1ad1ce0369af3ea8c6b3f0))
# [1.4.0](https://github.com/molvqingtai/WebChat/compare/v1.3.1...v1.4.0) (2024-10-28)
### Bug Fixes
* delete bad z-index ([bcdd435](https://github.com/molvqingtai/WebChat/commit/bcdd435e45e0b39d2c3ac45fbe594609165bacd8))
### Features
* app button support drag ([4eba638](https://github.com/molvqingtai/WebChat/commit/4eba638a367d4be2dc3d0b3e378298fd98a9ff5d))
* support [@user](https://github.com/user) syntax ([bef576a](https://github.com/molvqingtai/WebChat/commit/bef576a77bc995e8eaf57de212a233081be34727))
* support dark mode ([010aa2f](https://github.com/molvqingtai/WebChat/commit/010aa2f45e8cf864ac54fed44668369b5ff8fd9e))
### Performance Improvements
* optimize danmuku theme styles ([4f6eb56](https://github.com/molvqingtai/WebChat/commit/4f6eb560fe88e5e7e5d5b920666ed5e19b952fe9))
* optimize header theme styles ([025166e](https://github.com/molvqingtai/WebChat/commit/025166ead5529f66c26810e6b7ab6ba07dd874aa))
* optimize theme styles ([2d051fe](https://github.com/molvqingtai/WebChat/commit/2d051fedd763427d10ac2c0c1a0bd74fe7788501))
* reset app position when window resize ([eee1735](https://github.com/molvqingtai/WebChat/commit/eee17356545515905813f5937b4dbe183fb081ed))
## [1.3.1](https://github.com/molvqingtai/WebChat/compare/v1.3.0...v1.3.1) (2024-10-16)
### Bug Fixes
* missing tabs permission ([3cfc16c](https://github.com/molvqingtai/WebChat/commit/3cfc16c9ee0f3f46c8b5692c02e5c569f40744c9))
# [1.3.0](https://github.com/molvqingtai/WebChat/compare/v1.2.2...v1.3.0) (2024-10-12)
### Bug Fixes
* p2p use artico ([a0a8462](https://github.com/molvqingtai/WebChat/commit/a0a8462f5ff55a50511e335f70f5b814f2713358))
### Features
* support notification ([9898718](https://github.com/molvqingtai/WebChat/commit/9898718b1a14605d140852faca74b8af12f9b2a2))
### Performance Improvements
* notification supports clicking to open the source website ([653229c](https://github.com/molvqingtai/WebChat/commit/653229c8fa1ef748c84c4a5cec756a42f51933ab))
## [1.2.2](https://github.com/molvqingtai/WebChat/compare/v1.2.1...v1.2.2) (2024-10-11)
### Bug Fixes
* danmuku message ellipsis ([e8e243e](https://github.com/molvqingtai/WebChat/commit/e8e243ee096a0fb22183170ef3c0005291b72870))
* online text overflow ([d4e42c6](https://github.com/molvqingtai/WebChat/commit/d4e42c68caf8e2e080854f244328c1e519ed6338))
## [1.2.1](https://github.com/molvqingtai/WebChat/compare/v1.2.0...v1.2.1) (2024-10-10)
### Bug Fixes
* avatar is not displayed completely ([de97d05](https://github.com/molvqingtai/WebChat/commit/de97d0552894a33f2b15dd232598c40335d941a4))
* the text in the button is not visible in dark mode ([d6652cb](https://github.com/molvqingtai/WebChat/commit/d6652cb2a43116016af32697b52d5bba276e6d2c))
* the text in the textarea is not visible in dark mode ([d75a191](https://github.com/molvqingtai/WebChat/commit/d75a191dedd40a02fc58707ac60cccd9ff020c5f))
### Performance Improvements
* change https://github.com/weizhenye/Danmaku to https://github.com/imtaotao/danmu ([05ee49e](https://github.com/molvqingtai/WebChat/commit/05ee49e7c4019f32c654f2f935b734ec2383bebc))
* submit store flow ([5235a6e](https://github.com/molvqingtai/WebChat/commit/5235a6ee8703597df227942208b4075bff880c2d))
# [1.2.0](https://github.com/molvqingtai/WebChat/compare/v1.1.6...v1.2.0) (2024-10-08)
### Features
* support display of online user list ([4c7137d](https://github.com/molvqingtai/WebChat/commit/4c7137d045a127bef6e8a3afe319f15a480b149c))
## [1.1.6](https://github.com/molvqingtai/WebChat/compare/v1.1.5...v1.1.6) (2024-10-04)
### Bug Fixes
* it should not be sent when composing ([8ee9ed6](https://github.com/molvqingtai/WebChat/commit/8ee9ed6259f731fa43ef0d458a7e040ad1618d12))
## [1.1.5](https://github.com/molvqingtai/WebChat/compare/v1.1.4...v1.1.5) (2024-10-02)
### Bug Fixes
* multiple tabs display duplicate online users ([8b843ac](https://github.com/molvqingtai/WebChat/commit/8b843ac45cc415676641b66dbfb21329c3f7c962))
## [1.1.4](https://github.com/molvqingtai/WebChat/compare/v1.1.3...v1.1.4) (2024-10-02)
### Bug Fixes
* firfox requestAnimationFrame error ([65bf9b2](https://github.com/molvqingtai/WebChat/commit/65bf9b2419ec65b6c53355986df9a0e2eb593d6f))
## [1.1.3](https://github.com/molvqingtai/WebChat/compare/v1.1.2...v1.1.3) (2024-10-02)
### Performance Improvements
* add version link ([4551ad2](https://github.com/molvqingtai/WebChat/commit/4551ad2964e21e1bf85866b79acd25bf556aa26d))
## [1.1.2](https://github.com/molvqingtai/WebChat/compare/v1.1.1...v1.1.2) (2024-10-02)
### Performance Improvements
* support unread status ([1f44af8](https://github.com/molvqingtai/WebChat/commit/1f44af873c57aaed2eb3d845342ad427ce1d8a4f))
## [1.1.1](https://github.com/molvqingtai/WebChat/compare/v1.1.0...v1.1.1) (2024-10-01)
### Performance Improvements
* a tag use Link component ([fce64b7](https://github.com/molvqingtai/WebChat/commit/fce64b744c2ada3532ff3d4b78d08559c718ca1a))
# [1.1.0](https://github.com/molvqingtai/WebChat/compare/v1.0.29...v1.1.0) (2024-09-30)
### Features
* support danmaku ([999a55c](https://github.com/molvqingtai/WebChat/commit/999a55c65f78d0a1a0938c354a8453f2aa39fcd0))
## [1.0.29](https://github.com/molvqingtai/WebChat/compare/v1.0.28...v1.0.29) (2024-09-29)
### Bug Fixes
* compile by environment ([52cd203](https://github.com/molvqingtai/WebChat/commit/52cd203a53ec10dda48572659d0e9959667575be))
* error when leaving the room without joining ([8476595](https://github.com/molvqingtai/WebChat/commit/8476595011c0e38929e6ebaa44ab7d8d5292a8e3))
## [1.0.28](https://github.com/molvqingtai/WebChat/compare/v1.0.27...v1.0.28) (2024-09-28)
### Bug Fixes
* svg icon size ([089d69a](https://github.com/molvqingtai/WebChat/commit/089d69a095c22ea24bd2e8960799d7f2acb0b1ac))
## [1.0.27](https://github.com/molvqingtai/WebChat/compare/v1.0.26...v1.0.27) (2024-09-28)
### Bug Fixes
* uniformly resizable size ([3bb2b55](https://github.com/molvqingtai/WebChat/commit/3bb2b55f21e2ead16be4f7c4d9aa40cee87cca93))
### Performance Improvements
* add isolate events ([8fd5f04](https://github.com/molvqingtai/WebChat/commit/8fd5f04ecd730bf4bc73fe72c1ce9281a572ca4c))
## [1.0.26](https://github.com/molvqingtai/WebChat/compare/v1.0.25...v1.0.26) (2024-09-28)

View file

@ -2,42 +2,55 @@
<img src="https://github.com/molvqingtai/WebChat/blob/master/src/public/logo.png" width="200px"/>
</p>
# WebChat
[![GitHub License](https://img.shields.io/github/license/molvqingtai/WebChat)](https://github.com/molvqingtai/WebChat/blob/master/LICENSE) [![Chrome Web Store Version](https://img.shields.io/chrome-web-store/v/cpaedhbidlpnbdfegakhiamfpndhjpgf)](https://chromewebstore.google.com/detail/webchat/cpaedhbidlpnbdfegakhiamfpndhjpgf) [![GitHub Release](https://img.shields.io/github/v/release/molvqingtai/WebChat)](https://github.com/molvqingtai/WebChat/releases)
> Chat with anyone on any website
This is an anonymous chat browser extension that is decentralized and serverless, utilizing WebRTC for end-to-end encrypted communication. It prioritizes privacy, with all data stored locally.
The goal is to address the issue of delayed replies on websites by adding instant messaging capabilities to any site, allowing for chatting anytime, anywhere.
The aim is to add chat room functionality to any website, you'll never feel alone again.
### Install
**Install from Store**
### Example
- [Chrome Web Store](https://chromewebstore.google.com/detail/webchat/cpaedhbidlpnbdfegakhiamfpndhjpgf)
- [Edge Web Store](https://microsoftedge.microsoft.com/addons/detail/mmfdplbomjjlgdffecapcpgjmhfhmiob)
- [Firefox Addons](https://addons.mozilla.org/firefox/addon/webchat/)
![Example](https://github.com/molvqingtai/WebChat/blob/master/src/public/Example.png)
**Manual Installation**
1. Go to the GitHub repository ([Releases](https://github.com/molvqingtai/WebChat/releases))
2. Click on the "Assets" button and select "web-chat-\*.zip"
3. Extract the ZIP file to a folder on your computer
4. Open the extension management page in your browser (usually chrome://extensions/)
- Enable "Developer mode"
- Click "Load unpacked" and select the folder you just extracted
### Use
After installing the extension, you'll see a ghost icon in the bottom-right corner of any website. Click it, and you'll be able to chat happily with others on the same site!
### Video
https://github.com/user-attachments/assets/e7ac9b8e-1b6c-43fb-8469-7a0a2c09d450
### Standing on the Shoulders of Giants
In addition to the great idea of decentralized chat, it also leverages some fantastic technologies.
In addition to the good idea of decentralized chat, it also leverages some fantastic technologies.
* **[remesh](https://github.com/remesh-js/remesh)**: A frontend framework that implements DDD principles in code, achieving true separation of UI and logic. This provides a robust structure that can be easily migrated to other frontend frameworks like Vue.
- **[remesh](https://github.com/remesh-js/remesh)**: A framework in JavaScript that implements DDD principles, achieving true separation of UI and logic, allowing for easy implementation of the UI part, such as rewriting it in Vue, due to its independence from the UI.
* **[shadcn/ui](https://ui.shadcn.com/)**: A beautiful UI library and a pioneer of no-install options, with unmatched ease of customizing styles.
- **[shadcn/ui](https://ui.shadcn.com/)**: A beautiful UI library and a pioneer of the no-install concept, offering unmatched convenience in customizing styles.
* **[wxt](https://wxt.dev/)**: This is the best framework Ive used for building browser extensions, bar none.
* **[trystero](https://github.com/dmotz/trystero)**: The core dependency for implementing decentralized communication, enabling connections to decentralized networks like IPFS, torrent, Nostr, etc.
- **[wxt](https://wxt.dev/)**: This is the best framework Ive used for building browser extensions, bar none.
- ~~**[trystero](https://github.com/dmotz/trystero)**: The core dependency for implementing decentralized communication, enabling connections to decentralized networks like IPFS, torrent, Nostr, etc.~~
- **[Artico](https://github.com/matallui/artico)**: A flexible set of libraries that help you create your own WebRTC-based solutions
- **[ugly-avatar](https://github.com/txstc55/ugly-avatar)**: Use it to create stunning random avatars.
### License

View file

@ -34,8 +34,9 @@ export default [
'@typescript-eslint/no-unused-expressions': 'off',
'@eslint-react/no-array-index-key': 'off',
'@eslint-react/hooks-extra/no-redundant-custom-hook': 'off',
'@eslint-react/dom/no-missing-button-type': 'off'
'@eslint-react/dom/no-missing-button-type': 'off',
'@eslint-react/hooks-extra/prefer-use-state-lazy-initialization': 'off'
}
}
]
// satisfies Linter.Config[]
// satisfies Linter.Config[]

View file

@ -1,20 +1,21 @@
{
"name": "web-chat",
"displayName": "WebChat",
"version": "1.0.26",
"version": "1.7.1",
"description": "Chat with anyone on any website.",
"type": "module",
"scripts": {
"dev": "wxt",
"dev:firefox": "wxt -b firefox",
"build": "wxt build",
"build": "cross-env NODE_ENV=production run-p build:*",
"build:chrome": "wxt build -b chrome",
"build:firefox": "wxt build -b firefox",
"pack": "cross-env NODE_ENV=production run-p pack:*",
"pack:chrome": "wxt zip -b chrome",
"pack:firefox": "wxt zip -b firefox",
"lint": "eslint --fix --flag unstable_ts_config",
"clear": "rimraf .output",
"tsc": "tsc --noEmit",
"check": "tsc --noEmit",
"prepare": "husky",
"postinstall": "wxt prepare"
},
@ -43,93 +44,94 @@
},
"homepage": "https://github.com/molvqingtai/WebChat",
"dependencies": {
"@hookform/resolvers": "^3.9.0",
"@lottiefiles/dotlottie-react": "^0.9.0",
"@hookform/resolvers": "^3.9.1",
"@number-flow/react": "^0.3.2",
"@perfsee/jsonr": "^1.13.0",
"@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-hover-card": "^1.1.1",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-avatar": "^1.1.1",
"@radix-ui/react-checkbox": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-hover-card": "^1.1.2",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-popover": "^1.1.1",
"@radix-ui/react-radio-group": "^1.2.0",
"@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-popover": "^1.1.2",
"@radix-ui/react-portal": "^1.1.2",
"@radix-ui/react-presence": "^1.1.1",
"@radix-ui/react-radio-group": "^1.2.1",
"@radix-ui/react-scroll-area": "^1.2.1",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.1.0",
"@radix-ui/react-switch": "^1.1.1",
"@resreq/event-hub": "^1.6.0",
"@resreq/timer": "^1.1.6",
"@rtco/client": "^0.2.17",
"@tailwindcss/typography": "^0.5.15",
"@webcomponents/custom-elements": "^1.6.0",
"@webext-core/messaging": "^2.1.0",
"@webext-core/proxy-service": "^1.2.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"danmu": "^0.14.0",
"date-fns": "^4.1.0",
"framer-motion": "^11.7.0",
"framer-motion": "^11.11.17",
"idb-keyval": "^6.2.1",
"lucide-react": "^0.446.0",
"nanoid": "^5.0.7",
"next-themes": "^0.3.0",
"lucide-react": "^0.456.0",
"nanoid": "^5.0.8",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.53.0",
"react-hook-form": "^7.53.2",
"react-markdown": "^9.0.1",
"react-use": "^17.5.1",
"react-virtuoso": "^4.10.4",
"react-virtuoso": "^4.12.0",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.0",
"remesh": "^4.2.2",
"remesh-logger": "^4.1.0",
"remesh-react": "^4.1.2",
"rxjs": "^7.8.1",
"sonner": "^1.5.0",
"tailwind-merge": "^2.5.2",
"trystero": "^0.20.0",
"sonner": "^1.7.0",
"tailwind-merge": "^2.5.4",
"type-fest": "^4.26.1",
"unstorage": "^1.12.0",
"valibot": "^0.42.1",
"webextension-polyfill": "^0.12.0"
"unstorage": "^1.13.1",
"valibot": "1.0.0-beta.0"
},
"devDependencies": {
"@commitlint/cli": "^19.5.0",
"@commitlint/config-conventional": "^19.5.0",
"@eslint-react/eslint-plugin": "^1.14.2",
"@eslint/js": "^9.11.1",
"@eslint-react/eslint-plugin": "^1.16.1",
"@eslint/js": "^9.14.0",
"@semantic-release/changelog": "^6.0.3",
"@semantic-release/exec": "^6.0.3",
"@semantic-release/git": "^10.0.1",
"@types/eslint": "^9.6.1",
"@types/eslint-plugin-tailwindcss": "^3.17.0",
"@types/eslint__js": "^8.42.3",
"@types/node": "^22.7.2",
"@types/react": "^18.3.9",
"@types/react-dom": "^18.3.0",
"@types/webextension-polyfill": "^0.12.1",
"@typescript-eslint/parser": "^8.7.0",
"@vitejs/plugin-react": "^4.3.1",
"@types/eslint-plugin-tailwindcss": "^3.17.0",
"@types/node": "^22.9.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@typescript-eslint/parser": "^8.14.0",
"@vitejs/plugin-react": "^4.3.3",
"autoprefixer": "^10.4.20",
"cross-env": "^7.0.3",
"eslint": "^9.11.1",
"eslint": "^9.14.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1",
"eslint-plugin-tailwindcss": "^3.17.4",
"globals": "^15.9.0",
"eslint-plugin-tailwindcss": "^3.17.5",
"globals": "^15.12.0",
"husky": "^9.1.6",
"jiti": "^2.0.0",
"jiti": "^2.4.0",
"lint-staged": "^15.2.10",
"npm-run-all": "^4.1.5",
"package-up": "^5.0.0",
"postcss": "^8.4.47",
"postcss": "^8.4.49",
"postcss-rem-to-responsive-pixel": "^6.0.2",
"prettier": "^3.3.3",
"rimraf": "^5.0.10",
"semantic-release": "^24.1.1",
"tailwindcss": "^3.4.13",
"rimraf": "^6.0.1",
"semantic-release": "^24.2.0",
"tailwindcss": "^3.4.15",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.6.2",
"typescript-eslint": "^8.7.0",
"vite-plugin-svgr": "^4.2.0",
"typescript": "^5.6.3",
"typescript-eslint": "^8.14.0",
"vite-plugin-svgr": "^4.3.0",
"webext-bridge": "^6.0.1",
"wxt": "^0.19.10"
"wxt": "^0.19.15"
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": "eslint --fix --flag unstable_ts_config"

4887
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -1,17 +1,64 @@
import { EVENT } from '@/constants/event'
import { browser } from 'wxt/browser'
import { messenger } from '@/messenger'
import { browser, Tabs } from 'wxt/browser'
import { defineBackground } from 'wxt/sandbox'
export default defineBackground({
// Set manifest options
persistent: true,
type: 'module',
main() {
browser.runtime.onMessage.addListener(async (event: EVENT) => {
if (event === EVENT.OPEN_OPTIONS_PAGE) {
browser.runtime.openOptionsPage()
const historyNotificationTabs = new Map<string, Tabs.Tab>()
messenger.onMessage(EVENT.OPTIONS_PAGE_OPEN, () => {
browser.runtime.openOptionsPage()
})
messenger.onMessage(EVENT.NOTIFICATION_PUSH, async ({ data: message, sender }) => {
// Check if there is an active tab on the same site
const tabs = await browser.tabs.query({ active: true })
const hasActiveSomeSiteTab = tabs.some((tab) => {
return new URL(tab.url!).origin === new URL(sender.tab!.url!).origin
})
if (hasActiveSomeSiteTab) return
browser.notifications.create(message.id, {
type: 'basic',
iconUrl: message.userAvatar,
title: message.username,
message: message.body,
contextMessage: sender.tab!.url!
})
historyNotificationTabs.set(message.id, sender.tab!)
})
messenger.onMessage(EVENT.NOTIFICATION_CLEAR, async ({ data: id }) => {
browser.notifications.clear(id)
})
browser.notifications.onButtonClicked.addListener(async (id) => {
const fromTab = historyNotificationTabs.get(id)
if (fromTab?.id) {
try {
const tab = await browser.tabs.get(fromTab.id)
browser.tabs.update(tab.id, { active: true })
} catch {
browser.tabs.create({ url: fromTab.url })
}
}
})
browser.notifications.onClicked.addListener(async (id) => {
const fromTab = historyNotificationTabs.get(id)
if (fromTab?.id) {
try {
const tab = await browser.tabs.get(fromTab.id)
browser.tabs.update(tab.id, { active: true })
} catch {
browser.tabs.create({ url: fromTab.url })
}
}
})
browser.notifications.onClosed.addListener(async (id) => {
historyNotificationTabs.delete(id)
})
}
})

View file

@ -1,79 +1,122 @@
import '@webcomponents/custom-elements'
import Header from '@/app/content/views/Header'
import Footer from '@/app/content/views/Footer'
import Main from '@/app/content/views/Main'
import AppButton from '@/app/content/views/AppButton'
import AppContainer from '@/app/content/views/AppContainer'
import AppMain from '@/app/content/views/AppMain'
import { useRemeshDomain, useRemeshQuery, useRemeshSend } from 'remesh-react'
import RoomDomain from '@/domain/Room'
import ChatRoomDomain from '@/domain/ChatRoom'
import UserInfoDomain from '@/domain/UserInfo'
import Setup from '@/app/content/views/Setup'
import MessageListDomain from '@/domain/MessageList'
import { useEffect, useState } from 'react'
import { useEffect, useRef } from 'react'
import { Toaster } from 'sonner'
import { indexDBStorage } from '@/domain/impls/Storage'
import { APP_OPEN_STATUS_STORAGE_KEY } from '@/constants/config'
import LogoIcon0 from '@/assets/images/logo-0.svg'
import LogoIcon1 from '@/assets/images/logo-1.svg'
import LogoIcon2 from '@/assets/images/logo-2.svg'
import LogoIcon3 from '@/assets/images/logo-3.svg'
import LogoIcon4 from '@/assets/images/logo-4.svg'
import LogoIcon5 from '@/assets/images/logo-5.svg'
import LogoIcon6 from '@/assets/images/logo-6.svg'
import { getDay } from 'date-fns'
import DanmakuContainer from './components/DanmakuContainer'
import DanmakuDomain from '@/domain/Danmaku'
import AppStatusDomain from '@/domain/AppStatus'
import { checkDarkMode, cn } from '@/utils'
import VirtualRoomDomain from '@/domain/VirtualRoom'
/**
* Fix requestAnimationFrame error in jest
* @see https://github.com/facebook/react/issues/16606
* @see https://bugzilla.mozilla.org/show_bug.cgi?id=1469304
*/
if (import.meta.env.FIREFOX) {
window.requestAnimationFrame = window.requestAnimationFrame.bind(window)
}
export default function App() {
const send = useRemeshSend()
const roomDomain = useRemeshDomain(RoomDomain())
const chatRoomDomain = useRemeshDomain(ChatRoomDomain())
const virtualRoomDomain = useRemeshDomain(VirtualRoomDomain())
const userInfoDomain = useRemeshDomain(UserInfoDomain())
const messageListDomain = useRemeshDomain(MessageListDomain())
const danmakuDomain = useRemeshDomain(DanmakuDomain())
const danmakuIsEnabled = useRemeshQuery(danmakuDomain.query.IsEnabledQuery())
const userInfoSetFinished = useRemeshQuery(userInfoDomain.query.UserInfoSetIsFinishedQuery())
const messageListLoadFinished = useRemeshQuery(messageListDomain.query.LoadIsFinishedQuery())
const userInfoLoadFinished = useRemeshQuery(userInfoDomain.query.UserInfoLoadIsFinishedQuery())
const messageListLoadFinished = useRemeshQuery(messageListDomain.query.MessageListLoadIsFinishedQuery())
const appStatusDomain = useRemeshDomain(AppStatusDomain())
const appStatusLoadIsFinished = useRemeshQuery(appStatusDomain.query.StatusLoadIsFinishedQuery())
const chatRoomJoinIsFinished = useRemeshQuery(chatRoomDomain.query.JoinIsFinishedQuery())
const virtualRoomJoinIsFinished = useRemeshQuery(virtualRoomDomain.query.JoinIsFinishedQuery())
const userInfo = useRemeshQuery(userInfoDomain.query.UserInfoQuery())
const notUserInfo = userInfoLoadFinished && !userInfoSetFinished
const DayLogo = [LogoIcon0, LogoIcon1, LogoIcon2, LogoIcon3, LogoIcon4, LogoIcon5, LogoIcon6][getDay(Date())]
const joinRoom = () => {
send(chatRoomDomain.command.JoinRoomCommand())
send(virtualRoomDomain.command.JoinRoomCommand())
}
const leaveRoom = () => {
chatRoomJoinIsFinished && send(chatRoomDomain.command.LeaveRoomCommand())
virtualRoomJoinIsFinished && send(virtualRoomDomain.command.LeaveRoomCommand())
}
useEffect(() => {
if (messageListLoadFinished) {
if (userInfoSetFinished) {
send(roomDomain.command.JoinRoomCommand())
joinRoom()
} else {
// Clear simulated data when refreshing on the setup page
send(messageListDomain.command.ClearListCommand())
}
}
return () => leaveRoom()
}, [userInfoSetFinished, messageListLoadFinished])
const [appOpen, setAppOpen] = useState(false)
const handleToggleApp = async () => {
const value = !appOpen
setAppOpen(value)
await indexDBStorage.setItem<boolean>(APP_OPEN_STATUS_STORAGE_KEY, value)
}
const getAppOpenStatus = async () => {
const value = await indexDBStorage.getItem<boolean>(APP_OPEN_STATUS_STORAGE_KEY)
setAppOpen(!!value)
}
useEffect(() => {
danmakuIsEnabled && send(danmakuDomain.command.MountCommand(danmakuContainerRef.current!))
return () => {
danmakuIsEnabled && send(danmakuDomain.command.UnmountCommand())
}
}, [danmakuIsEnabled])
useEffect(() => {
getAppOpenStatus()
window.addEventListener('beforeunload', leaveRoom)
return () => {
window.removeEventListener('beforeunload', leaveRoom)
}
}, [])
const themeMode =
userInfo?.themeMode === 'system'
? checkDarkMode()
? 'dark'
: 'light'
: (userInfo?.themeMode ?? (checkDarkMode() ? 'dark' : 'light'))
const danmakuContainerRef = useRef<HTMLDivElement>(null)
return (
<>
<AppContainer open={appOpen}>
<Header />
<Main />
<Footer />
{notUserInfo && <Setup />}
<Toaster richColors offset="70px" visibleToasts={1} position="top-center"></Toaster>
</AppContainer>
<AppButton onClick={handleToggleApp}>
<DayLogo></DayLogo>
</AppButton>
</>
<div id="app" className={cn('contents', themeMode)}>
{appStatusLoadIsFinished && (
<>
<AppMain>
<Header />
<Main />
<Footer />
{notUserInfo && <Setup></Setup>}
<Toaster
richColors
theme={themeMode}
offset="70px"
visibleToasts={1}
toastOptions={{
classNames: {
toast: 'dark:bg-slate-950 border dark:border-slate-600'
}
}}
position="top-center"
></Toaster>
</AppMain>
<AppButton></AppButton>
</>
)}
<DanmakuContainer ref={danmakuContainerRef} />
</div>
)
}

View file

@ -0,0 +1,19 @@
import { cn } from '@/utils'
import { forwardRef } from 'react'
export interface DanmakuContainerProps {
className?: string
}
const DanmakuContainer = forwardRef<HTMLDivElement, DanmakuContainerProps>(({ className }, ref) => {
return (
<div
className={cn('fixed left-0 top-0 z-infinity w-full h-full invisible pointer-events-none shadow-md', className)}
ref={ref}
></div>
)
})
DanmakuContainer.displayName = 'DanmakuContainer'
export default DanmakuContainer

View file

@ -0,0 +1,38 @@
import { Avatar, AvatarFallback } from '@/components/ui/Avatar'
import { Button } from '@/components/ui/Button'
import { TextMessage } from '@/domain/ChatRoom'
import { cn } from '@/utils'
import { AvatarImage } from '@radix-ui/react-avatar'
import { FC, MouseEvent } from 'react'
export interface PromptItemProps {
data: TextMessage
className?: string
onClick?: (e: MouseEvent<HTMLButtonElement>) => void
onMouseEnter?: (e: MouseEvent<HTMLButtonElement>) => void
onMouseLeave?: (e: MouseEvent<HTMLButtonElement>) => void
}
const DanmakuMessage: FC<PromptItemProps> = ({ data, className, onClick, onMouseEnter, onMouseLeave }) => {
return (
<Button
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onClick={onClick}
className={cn(
'flex justify-center pointer-events-auto visible gap-x-2 border border-slate-50 px-2.5 py-0.5 rounded-full bg-primary/30 text-base font-medium text-white backdrop-blur-md',
className
)}
>
<Avatar className="size-5">
<AvatarImage src={data.userAvatar} className="size-full" alt="avatar" />
<AvatarFallback>{data.username.at(0)}</AvatarFallback>
</Avatar>
<div className="max-w-44 truncate">{data.body}</div>
</Button>
)
}
DanmakuMessage.displayName = 'DanmakuMessage'
export default DanmakuMessage

View file

@ -10,7 +10,7 @@ export interface EmojiButtonProps {
onSelect?: (value: string) => void
}
const emojiGroups = chunk([...EMOJI_LIST], 8)
const emojiGroups = chunk([...EMOJI_LIST], 6)
// BUG: https://github.com/radix-ui/primitives/pull/2433
// BUG https://github.com/radix-ui/primitives/issues/1666
@ -30,20 +30,23 @@ const EmojiButton: FC<EmojiButtonProps> = ({ onSelect }) => {
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon">
<Button variant="ghost" size="icon" className="dark:text-white">
<SmileIcon size={20} />
</Button>
</PopoverTrigger>
<PopoverContent className="z-infinity w-72 px-0" onCloseAutoFocus={handleCloseAutoFocus}>
<ScrollArea className="size-72 px-3">
<PopoverContent
className="z-infinity w-64 overflow-hidden rounded-xl p-0 dark:bg-slate-900"
onCloseAutoFocus={handleCloseAutoFocus}
>
<ScrollArea className="size-64 p-1">
{emojiGroups.map((group, index) => {
return (
<div key={index} className="grid grid-cols-8">
<div key={index} className="grid grid-cols-6">
{group.map((emoji, index) => (
<Button
key={index}
size="sm"
className="text-base"
size="icon"
className="text-xl"
variant="ghost"
onClick={() => handleSelect(emoji)}
>

View file

@ -0,0 +1,34 @@
import { Button } from '@/components/ui/Button'
import { createElement } from '@/utils'
import { ImageIcon } from 'lucide-react'
export interface ImageButtonProps {
onSelect?: (file: File) => void
disabled?: boolean
}
const ImageButton = ({ onSelect, disabled }: ImageButtonProps) => {
const handleClick = () => {
const input = createElement<HTMLInputElement>(`<input type="file" accept="image/png,image/jpeg,image/webp" />`)
input.addEventListener(
'change',
async (e: Event) => {
onSelect?.((e.target as HTMLInputElement).files![0])
},
{ once: true }
)
input.click()
}
return (
<Button disabled={disabled} onClick={handleClick} variant="ghost" size="icon" className="dark:text-white">
<ImageIcon size={20} />
</Button>
)
}
ImageButton.displayName = 'ImageButton'
export default ImageButton

View file

@ -1,6 +1,7 @@
import { type MouseEvent, type FC, type ReactElement } from 'react'
import { Button } from '@/components/ui/Button'
import { cn } from '@/utils'
import NumberFlow from '@number-flow/react'
export interface LikeButtonIconProps {
children: JSX.Element
@ -33,14 +34,18 @@ const LikeButton: FC<LikeButtonProps> & { Icon: FC<LikeButtonIconProps> } = ({
onClick={handleClick}
variant="secondary"
className={cn(
'grid items-center overflow-hidden rounded-full leading-none transition-all select-none',
checked ? 'text-orange-500' : 'text-slate-500',
'grid items-center overflow-hidden rounded-full leading-none transition-all select-none dark:bg-slate-600',
checked ? 'text-orange-500' : 'text-slate-500 dark:text-slate-100',
count ? 'grid-cols-[auto_1fr] gap-x-1' : 'grid-cols-[auto_0fr] gap-x-0'
)}
size="xs"
>
{children}
{!!count && <span className="min-w-0 text-xs">{count}</span>}
{!!count && (
<span className="min-w-0 text-xs">
{import.meta.env.FIREFOX ? <span className="tabular-nums">{count}</span> : <NumberFlow value={count} />}
</span>
)}
</Button>
)
}

View file

@ -1,10 +1,9 @@
import { type ChangeEvent, type KeyboardEvent } from 'react'
import { forwardRef, type ChangeEvent, CompositionEvent, type KeyboardEvent, ClipboardEvent } from 'react'
import React from 'react'
import { Textarea } from '@/components/ui/Textarea'
import { Markdown } from '@/components/ui/Markdown'
import { cn } from '@/utils'
import { Textarea } from '@/components/ui/Textarea'
import { ScrollArea } from '@/components/ui/ScrollArea'
import LoadingIcon from '@/assets/images/loading.svg'
export interface MessageInputProps {
value?: string
@ -13,45 +12,73 @@ export interface MessageInputProps {
preview?: boolean
autoFocus?: boolean
disabled?: boolean
onInput?: (value: string) => void
onEnter?: (value: string) => void
loading?: boolean
onInput?: (e: ChangeEvent<HTMLTextAreaElement>) => void
onPaste?: (e: ClipboardEvent<HTMLTextAreaElement>) => void
onKeyDown?: (e: KeyboardEvent<HTMLTextAreaElement>) => void
onCompositionStart?: (e: CompositionEvent<HTMLTextAreaElement>) => void
onCompositionEnd?: (e: CompositionEvent<HTMLTextAreaElement>) => void
}
const MessageInput = React.forwardRef<HTMLTextAreaElement, MessageInputProps>(
({ value = '', className, maxLength = 500, onInput, onEnter, preview, autoFocus, disabled }, ref) => {
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !(e.shiftKey || e.ctrlKey || e.altKey || e.metaKey)) {
e.preventDefault()
onEnter?.(value)
}
}
const handleInput = (e: ChangeEvent<HTMLTextAreaElement>) => {
onInput?.(e.target.value)
}
/**
* Need @ syntax highlighting? Waiting for textarea to support Highlight API
*
* @see https://github.com/w3c/csswg-drafts/issues/4603
*/
const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(
(
{
value = '',
className,
maxLength = 500,
onInput,
onPaste,
onKeyDown,
onCompositionStart,
onCompositionEnd,
autoFocus,
disabled,
loading
},
ref
) => {
return (
<div className={cn('relative', className)}>
{preview ? (
<Markdown className="max-h-28 rounded-lg border border-input bg-gray-50 2xl:max-h-40">{value}</Markdown>
) : (
<ScrollArea className="box-border max-h-28 w-full rounded-lg border border-input bg-background ring-offset-background focus-within:ring-1 focus-within:ring-ring 2xl:max-h-40">
<Textarea
ref={ref}
onKeyDown={handleKeyDown}
autoFocus={autoFocus}
maxLength={maxLength}
className="box-border resize-none whitespace-pre-wrap break-words border-none bg-gray-50 pb-5 [field-sizing:content] focus:ring-0 focus:ring-offset-0"
rows={2}
value={value}
placeholder="Type your message here."
onInput={handleInput}
disabled={disabled}
/>
</ScrollArea>
)}
<div className="absolute bottom-1 right-3 rounded-lg text-xs text-slate-400">
<ScrollArea className="box-border max-h-28 w-full rounded-lg border border-input bg-background ring-offset-background focus-within:ring-1 focus-within:ring-ring 2xl:max-h-40">
<Textarea
ref={ref}
onPaste={onPaste}
onKeyDown={onKeyDown}
autoFocus={autoFocus}
maxLength={maxLength}
className={cn(
'box-border resize-none whitespace-pre-wrap break-words border-none bg-slate-100 pb-5 [field-sizing:content] [word-break:break-word] focus:ring-0 focus:ring-offset-0 dark:bg-slate-800',
{
'disabled:opacity-100': loading
}
)}
rows={2}
value={value}
spellCheck={false}
onCompositionStart={onCompositionStart}
onCompositionEnd={onCompositionEnd}
placeholder="Type your message here."
onInput={onInput}
disabled={disabled || loading}
/>
</ScrollArea>
<div
className={cn('absolute bottom-1 right-3 rounded-lg text-xs text-slate-400', {
'opacity-50': disabled || loading
})}
>
{value?.length ?? 0}/{maxLength}
</div>
{loading && (
<div className="absolute inset-0 flex items-center justify-center text-slate-800 after:absolute after:inset-0 after:backdrop-blur-xs dark:text-slate-100">
<LoadingIcon className="relative z-10 size-10"></LoadingIcon>
</div>
)}
</div>
)
}

View file

@ -1,11 +1,10 @@
import { type FC } from 'react'
import { FrownIcon, ThumbsUpIcon } from 'lucide-react'
import { Badge } from '@/components/ui/Badge'
import { FrownIcon, HeartIcon } from 'lucide-react'
import LikeButton from './LikeButton'
import FormatDate from './FormatDate'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/Avatar'
import { Markdown } from '@/components/ui/Markdown'
import { Markdown } from '@/components/Markdown'
import { type NormalMessage } from '@/domain/MessageList'
import { cn } from '@/utils'
@ -26,34 +25,53 @@ const MessageItem: FC<MessageItemProps> = (props) => {
const handleHateChange = (checked: boolean) => {
props.onHateChange?.(checked)
}
let content = props.data.body
// Check if the field exists, compatible with old data
if (props.data.atUsers) {
const atUserPositions = props.data.atUsers.flatMap((user) =>
user.positions.map((position) => ({ username: user.username, userId: user.userId, position }))
)
// Replace from back to front according to position to avoid affecting previous indices
atUserPositions
.sort((a, b) => b.position[0] - a.position[0])
.forEach(({ position, username }) => {
const [start, end] = position
content = `${content.slice(0, start)} **@${username}** ${content.slice(end + 1)}`
})
}
return (
<div
data-index={props.index}
className={cn('box-border grid grid-cols-[auto_1fr] gap-x-2 px-4 first:pt-4 last:pb-4', props.className)}
className={cn(
'box-border grid grid-cols-[auto_1fr] gap-x-2 px-4 first:pt-4 last:pb-4 dark:text-slate-50',
props.className
)}
>
<Avatar>
<AvatarImage src={props.data.userAvatar} alt="avatar" />
<AvatarImage src={props.data.userAvatar} className="size-full" alt="avatar" />
<AvatarFallback>{props.data.username.at(0)}</AvatarFallback>
</Avatar>
<div className="overflow-hidden">
<div className="grid grid-cols-[1fr_auto] items-center gap-x-2 leading-none">
<div className="overflow-hidden text-ellipsis text-sm font-semibold text-slate-600">
{props.data.username}
</div>
<FormatDate className="text-xs text-slate-400" date={props.data.date}></FormatDate>
<div className="truncate text-sm font-semibold text-slate-600 dark:text-slate-50">{props.data.username}</div>
<FormatDate className="text-xs text-slate-400 dark:text-slate-100" date={props.data.sendTime}></FormatDate>
</div>
<div>
<div className="pb-2">
<Markdown>{props.data.body}</Markdown>
<Markdown>{content}</Markdown>
</div>
<div className="grid grid-flow-col justify-end gap-x-2 leading-none">
<div className="grid grid-flow-col justify-end gap-x-2 leading-none dark:text-slate-600">
<LikeButton
checked={props.like}
onChange={(checked) => handleLikeChange(checked)}
count={props.data.likeUsers.length}
>
<LikeButton.Icon>
<ThumbsUpIcon size={14}></ThumbsUpIcon>
<HeartIcon size={14}></HeartIcon>
</LikeButton.Icon>
</LikeButton>
<LikeButton

View file

@ -12,8 +12,9 @@ const MessageList: FC<MessageListProps> = ({ children }) => {
const [scrollParentRef, setScrollParentRef] = useState<HTMLDivElement | null>(null)
return (
<ScrollArea ref={setScrollParentRef}>
<ScrollArea ref={setScrollParentRef} className="dark:bg-slate-900">
<Virtuoso
defaultItemHeight={108}
followOutput={(isAtBottom: boolean) => (isAtBottom ? 'smooth' : 'auto')}
initialTopMostItemIndex={{ index: 'LAST', align: 'end' }}
data={children}

View file

@ -12,10 +12,10 @@ export interface PromptItemProps {
const PromptItem: FC<PromptItemProps> = ({ data, className }) => {
return (
<div className={cn('flex justify-center py-1 px-4', className)}>
<Badge variant="secondary" className="gap-x-2 rounded-full font-medium text-slate-400">
<div className={cn('flex justify-center py-1 px-4 ', className)}>
<Badge variant="secondary" className="gap-x-2 rounded-full px-2 font-medium text-slate-400 dark:bg-slate-800">
<Avatar className="size-4">
<AvatarImage src={data.userAvatar} alt="avatar" />
<AvatarImage src={data.userAvatar} className="size-full" alt="avatar" />
<AvatarFallback>{data.username.at(0)}</AvatarFallback>
</Avatar>
{data.body}

View file

@ -1,29 +1,50 @@
import React from 'react'
import { createRoot } from 'react-dom/client'
import { Remesh } from 'remesh'
import { RemeshRoot } from 'remesh-react'
import { RemeshLogger } from 'remesh-logger'
import { RemeshRoot, RemeshScope } from 'remesh-react'
// import { RemeshLogger } from 'remesh-logger'
import { defineContentScript } from 'wxt/sandbox'
import { createShadowRootUi } from 'wxt/client'
import App from './App'
import { IndexDBStorageImpl, BrowserSyncStorageImpl } from '@/domain/impls/Storage'
import { PeerRoomImpl } from '@/domain/impls/PeerRoom'
// import { PeerRoomImpl } from '@/domain/impls/PeerRoom2'
import '@/assets/styles/tailwind.css'
import '@/assets/styles/sonner.css'
import { createElement } from '@/utils'
import { LocalStorageImpl, IndexDBStorageImpl, BrowserSyncStorageImpl } from '@/domain/impls/Storage'
import { DanmakuImpl } from '@/domain/impls/Danmaku'
import { NotificationImpl } from '@/domain/impls/Notification'
import { ToastImpl } from '@/domain/impls/Toast'
import { ChatRoomImpl } from '@/domain/impls/ChatRoom'
import { VirtualRoomImpl } from '@/domain/impls/VirtualRoom'
// Remove import after merging: https://github.com/emilkowalski/sonner/pull/508
import '@/assets/styles/sonner.css'
import '@/assets/styles/overlay.css'
import '@/assets/styles/tailwind.css'
import NotificationDomain from '@/domain/Notification'
import { createElement } from '@/utils'
export default defineContentScript({
cssInjectionMode: 'ui',
runAt: 'document_end',
matches: ['https://*/*'],
excludeMatches: ['*://localhost/*', '*://127.0.0.1/*'],
excludeMatches: ['*://localhost/*', '*://127.0.0.1/*', '*://*.csdn.net/*', '*://*.csdn.com/*'],
async main(ctx) {
window.CSS.registerProperty({
name: '--shimmer-angle',
syntax: '<angle>',
inherits: false,
initialValue: '0deg'
})
const store = Remesh.store({
externs: [IndexDBStorageImpl, BrowserSyncStorageImpl, PeerRoomImpl, ToastImpl],
inspectors: __DEV__ ? [RemeshLogger()] : []
externs: [
LocalStorageImpl,
IndexDBStorageImpl,
BrowserSyncStorageImpl,
ChatRoomImpl,
VirtualRoomImpl,
ToastImpl,
DanmakuImpl,
NotificationImpl
]
// inspectors: __DEV__ ? [RemeshLogger()] : []
})
const ui = await createShadowRootUi(ctx, {
@ -32,15 +53,17 @@ export default defineContentScript({
anchor: 'body',
append: 'last',
mode: 'open',
isolateEvents: ['keyup', 'keydown', 'keypress'],
onMount: (container) => {
const app = createElement('<div id="app"></div>')
const app = createElement('<div id="root"></div>')
container.append(app)
const root = createRoot(app)
root.render(
<React.StrictMode>
<RemeshRoot store={store}>
<App />
<RemeshScope domains={[NotificationDomain()]}>
<App />
</RemeshScope>
</RemeshRoot>
</React.StrictMode>
)

View file

@ -1,37 +1,67 @@
import { type ReactNode, type FC, useState, type MouseEvent, useRef, useEffect } from 'react'
import { SettingsIcon, MoonIcon, SunIcon } from 'lucide-react'
import { type FC, useState, type MouseEvent, useEffect } from 'react'
import { SettingsIcon, MoonIcon, SunIcon, HandIcon } from 'lucide-react'
import { motion, AnimatePresence } from 'framer-motion'
import { browser } from 'wxt/browser'
import { useRemeshDomain, useRemeshQuery, useRemeshSend } from 'remesh-react'
import { Button } from '@/components/ui/Button'
import { EVENT } from '@/constants/event'
import UserInfoDomain from '@/domain/UserInfo'
import useClickAway from '@/hooks/useClickAway'
import { checkSystemDarkMode, cn } from '@/utils'
import ToastDomain from '@/domain/Toast'
import useTriggerAway from '@/hooks/useTriggerAway'
import { checkDarkMode, cn } from '@/utils'
import LogoIcon0 from '@/assets/images/logo-0.svg'
import LogoIcon1 from '@/assets/images/logo-1.svg'
import LogoIcon2 from '@/assets/images/logo-2.svg'
import LogoIcon3 from '@/assets/images/logo-3.svg'
import LogoIcon4 from '@/assets/images/logo-4.svg'
import LogoIcon5 from '@/assets/images/logo-5.svg'
import LogoIcon6 from '@/assets/images/logo-6.svg'
import AppStatusDomain from '@/domain/AppStatus'
import { getDay } from 'date-fns'
import { messenger } from '@/messenger'
import useDraggable from '@/hooks/useDraggable'
import useWindowResize from '@/hooks/useWindowResize'
export interface AppButtonProps {
children?: ReactNode
onClick?: (e: MouseEvent<HTMLButtonElement>) => void
className?: string
}
const AppButton: FC<AppButtonProps> = ({ children, onClick }) => {
const AppButton: FC<AppButtonProps> = ({ className }) => {
const send = useRemeshSend()
const appStatusDomain = useRemeshDomain(AppStatusDomain())
const appOpenStatus = useRemeshQuery(appStatusDomain.query.OpenQuery())
const hasUnreadQuery = useRemeshQuery(appStatusDomain.query.HasUnreadQuery())
const userInfoDomain = useRemeshDomain(UserInfoDomain())
const userInfo = useRemeshQuery(userInfoDomain.query.UserInfoQuery())
const toastDomain = useRemeshDomain(ToastDomain())
const appPosition = useRemeshQuery(appStatusDomain.query.PositionQuery())
const isDarkMode =
userInfo?.themeMode === 'dark' ? true : userInfo?.themeMode === 'light' ? false : checkSystemDarkMode()
const DayLogo = [LogoIcon0, LogoIcon1, LogoIcon2, LogoIcon3, LogoIcon4, LogoIcon5, LogoIcon6][getDay(Date())]
const isDarkMode = userInfo?.themeMode === 'dark' ? true : userInfo?.themeMode === 'light' ? false : checkDarkMode()
const [menuOpen, setMenuOpen] = useState(false)
const menuRef = useRef<HTMLDivElement>(null)
const {
x,
y,
setRef: appButtonRef
} = useDraggable({
initX: appPosition.x,
initY: appPosition.y,
minX: 50,
maxX: window.innerWidth - 50,
maxY: window.innerHeight - 22,
minY: 750
})
useClickAway(menuRef, () => {
setMenuOpen(false)
}, ['click'])
useWindowResize(({ width, height }) => {
send(appStatusDomain.command.UpdatePositionCommand({ x: width - 50, y: height - 22 }))
})
useEffect(() => {
send(appStatusDomain.command.UpdatePositionCommand({ x, y }))
}, [x, y])
const { setRef: appMenuRef } = useTriggerAway(['click'], () => setMenuOpen(false))
const handleToggleMenu = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault()
@ -40,7 +70,6 @@ const AppButton: FC<AppButtonProps> = ({ children, onClick }) => {
const handleSwitchTheme = () => {
if (userInfo) {
send(toastDomain.command.WarningCommand('Developer is too lazy~'))
send(userInfoDomain.command.UpdateUserInfoCommand({ ...userInfo, themeMode: isDarkMode ? 'light' : 'dark' }))
} else {
handleOpenOptionsPage()
@ -48,15 +77,27 @@ const AppButton: FC<AppButtonProps> = ({ children, onClick }) => {
}
const handleOpenOptionsPage = () => {
browser.runtime.sendMessage(EVENT.OPEN_OPTIONS_PAGE)
messenger.sendMessage(EVENT.OPTIONS_PAGE_OPEN, undefined)
}
const handleToggleApp = () => {
send(appStatusDomain.command.UpdateOpenCommand(!appOpenStatus))
}
return (
<div ref={menuRef} className="fixed bottom-5 right-5 z-infinity grid select-none justify-center gap-y-3">
<div
ref={appMenuRef}
className={cn('fixed bottom-5 right-5 z-infinity grid w-min select-none justify-center gap-y-3', className)}
style={{
left: `calc(${appPosition.x}px)`,
bottom: `calc(100vh - ${appPosition.y}px)`,
transform: 'translateX(-50%)'
}}
>
<AnimatePresence>
{menuOpen && (
<motion.div
className="z-infinity grid gap-y-3"
className="z-10 grid gap-y-3"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 12 }}
@ -65,13 +106,13 @@ const AppButton: FC<AppButtonProps> = ({ children, onClick }) => {
<Button
onClick={handleSwitchTheme}
variant="outline"
className="relative size-10 overflow-hidden rounded-full p-0 shadow"
className="relative size-10 overflow-hidden rounded-full p-0 shadow dark:border-slate-600"
>
<div
className={cn(
'absolute grid grid-rows-[repeat(2,minmax(0,2.5rem))] w-full justify-center items-center transition-all duration-500',
'absolute grid grid-rows-[repeat(2,minmax(0,2.5rem))] w-full justify-center items-center transition-all duration-300',
isDarkMode ? 'top-0' : '-top-10',
isDarkMode ? 'bg-slate-800 text-white' : 'bg-white text-orange-400'
isDarkMode ? 'bg-slate-950 text-white' : 'bg-white text-orange-400'
)}
>
<MoonIcon size={20} />
@ -82,19 +123,43 @@ const AppButton: FC<AppButtonProps> = ({ children, onClick }) => {
<Button
onClick={handleOpenOptionsPage}
variant="outline"
className="pointer-events-auto size-10 rounded-full p-0 shadow"
className="size-10 rounded-full p-0 shadow dark:border-slate-600"
>
<SettingsIcon size={20} />
</Button>
<Button
ref={appButtonRef}
variant="outline"
className="size-10 cursor-grab rounded-full p-0 shadow dark:border-slate-600"
>
<HandIcon size={20} />
</Button>
</motion.div>
)}
</AnimatePresence>
<Button
onClick={onClick}
onClick={handleToggleApp}
onContextMenu={handleToggleMenu}
className="relative z-10 size-11 rounded-full p-0 text-xs shadow-lg shadow-slate-500/50"
className="relative z-20 size-11 rounded-full p-0 text-xs shadow-lg shadow-slate-500/50 after:absolute after:-inset-0.5 after:z-10 after:animate-[shimmer_2s_linear_infinite] after:rounded-full after:bg-[conic-gradient(from_var(--shimmer-angle),theme(colors.slate.500)_0%,theme(colors.white)_10%,theme(colors.slate.500)_20%)]"
>
{children}
<AnimatePresence>
{hasUnreadQuery && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.1 }}
className="absolute -right-1 -top-1 z-30 flex size-5 items-center justify-center"
>
<span
className={cn('absolute inline-flex size-full animate-ping rounded-full opacity-75', 'bg-orange-400')}
></span>
<span className={cn('relative inline-flex size-3 rounded-full', 'bg-orange-500')}></span>
</motion.div>
)}
</AnimatePresence>
<DayLogo className="relative z-20 max-h-full max-w-full overflow-hidden"></DayLogo>
</Button>
</div>
)

View file

@ -1,44 +0,0 @@
import { type ReactNode, type FC } from 'react'
import useResizable from '@/hooks/useResizable'
import { motion, AnimatePresence } from 'framer-motion'
export interface AppContainerProps {
children?: ReactNode
open?: boolean
}
const AppContainer: FC<AppContainerProps> = ({ children, open }) => {
const { size, ref } = useResizable({
initSize: Math.max(375, window.innerWidth / 6),
maxSize: Math.min(750, window.innerWidth / 3),
minSize: Math.max(375, window.innerWidth / 5),
direction: 'left'
})
return (
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0, y: 10, x: 10 }}
animate={{ opacity: 1, y: 0, x: 0 }}
exit={{ opacity: 0, y: 10, x: 10 }}
transition={{ duration: 0.3 }}
style={{
width: `${size}px`
}}
className="fixed bottom-10 right-10 z-infinity box-border grid h-screen max-h-[min(calc(100vh_-60px),_1200px)] grid-flow-col grid-rows-[auto_1fr_auto] rounded-xl bg-slate-50 font-sans shadow-2xl"
>
{children}
<div
ref={ref}
className="absolute inset-y-3 -left-0.5 z-20 w-1 cursor-ew-resize rounded-xl bg-slate-100 opacity-0 shadow transition-opacity duration-200 ease-in hover:opacity-100"
></div>
</motion.div>
)}
</AnimatePresence>
)
}
AppContainer.displayName = 'AppContainer'
export default AppContainer

View file

@ -0,0 +1,69 @@
import { type ReactNode, type FC, useState } from 'react'
import useResizable from '@/hooks/useResizable'
import { motion, AnimatePresence } from 'framer-motion'
import AppStatusDomain from '@/domain/AppStatus'
import { useRemeshDomain, useRemeshQuery } from 'remesh-react'
import { cn } from '@/utils'
import useWindowResize from '@/hooks/useWindowResize'
export interface AppMainProps {
children?: ReactNode
className?: string
}
const AppMain: FC<AppMainProps> = ({ children, className }) => {
const appStatusDomain = useRemeshDomain(AppStatusDomain())
const appOpenStatus = useRemeshQuery(appStatusDomain.query.OpenQuery())
const { x, y } = useRemeshQuery(appStatusDomain.query.PositionQuery())
const { width } = useWindowResize()
const isOnRightSide = x >= width / 2 + 50
const { size, setRef } = useResizable({
initSize: Math.max(375, width / 6),
maxSize: Math.max(Math.min(750, width / 3), 375),
minSize: Math.max(375, width / 6),
direction: isOnRightSide ? 'left' : 'right'
})
const [isAnimationComplete, setAnimationComplete] = useState(false)
return (
<AnimatePresence>
{appOpenStatus && (
<motion.div
initial={{ opacity: 0, y: 10, x: isOnRightSide ? '-100%' : '0' }}
animate={{ opacity: 1, y: 0, x: isOnRightSide ? '-100%' : '0' }}
exit={{ opacity: 0, y: 10 }}
transition={{ duration: 0.3, ease: 'linear' }}
onAnimationEnd={() => setAnimationComplete(true)}
onAnimationStart={() => setAnimationComplete(false)}
style={{
width: `${size}px`,
left: `${x}px`,
bottom: `calc(100vh - ${y}px + 22px)`
}}
className={cn(
`fixed inset-y-10 right-10 z-infinity mb-0 mt-auto box-border grid max-h-[min(calc(100vh_-60px),_1000px)] grid-flow-col grid-rows-[auto_1fr_auto] rounded-xl bg-slate-50 dark:bg-slate-950 font-sans shadow-2xl`,
className,
{ 'transition-transform': isAnimationComplete }
)}
>
{children}
<div
ref={setRef}
className={cn(
'absolute inset-y-3 z-infinity w-1 dark:bg-slate-600 cursor-ew-resize rounded-xl bg-slate-100 opacity-0 shadow transition-opacity duration-200 ease-in hover:opacity-100',
isOnRightSide ? '-left-0.5' : '-right-0.5'
)}
></div>
</motion.div>
)}
</AnimatePresence>
)
}
AppMain.displayName = 'AppMain'
export default AppMain

View file

@ -1,50 +1,367 @@
import { useRef, type FC } from 'react'
import { ChangeEvent, useMemo, useRef, useState, KeyboardEvent, type FC, ClipboardEvent } from 'react'
import { CornerDownLeftIcon } from 'lucide-react'
import { useRemeshDomain, useRemeshQuery, useRemeshSend } from 'remesh-react'
import MessageInput from '../../components/MessageInput'
import EmojiButton from '../../components/EmojiButton'
import { Button } from '@/components/ui/Button'
import MessageInputDomain from '@/domain/MessageInput'
import { MESSAGE_MAX_LENGTH } from '@/constants/config'
import RoomDomain from '@/domain/Room'
import { MESSAGE_MAX_LENGTH, WEB_RTC_MAX_MESSAGE_SIZE } from '@/constants/config'
import ChatRoomDomain from '@/domain/ChatRoom'
import useCursorPosition from '@/hooks/useCursorPosition'
import useShareRef from '@/hooks/useShareRef'
import { Presence } from '@radix-ui/react-presence'
import { Portal } from '@radix-ui/react-portal'
import useTriggerAway from '@/hooks/useTriggerAway'
import { ScrollArea } from '@/components/ui/ScrollArea'
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso'
import UserInfoDomain from '@/domain/UserInfo'
import { blobToBase64, cn, compressImage, getRootNode, getTextByteSize, getTextSimilarity } from '@/utils'
import { Avatar, AvatarFallback } from '@/components/ui/Avatar'
import { AvatarImage } from '@radix-ui/react-avatar'
import ToastDomain from '@/domain/Toast'
import ImageButton from '../../components/ImageButton'
import { nanoid } from 'nanoid'
const Footer: FC = () => {
const send = useRemeshSend()
const roomDomain = useRemeshDomain(RoomDomain())
const toastDomain = useRemeshDomain(ToastDomain())
const chatRoomDomain = useRemeshDomain(ChatRoomDomain())
const messageInputDomain = useRemeshDomain(MessageInputDomain())
const message = useRemeshQuery(messageInputDomain.query.MessageQuery())
const userInfoDomain = useRemeshDomain(UserInfoDomain())
const userInfo = useRemeshQuery(userInfoDomain.query.UserInfoQuery())
const userList = useRemeshQuery(chatRoomDomain.query.UserListQuery())
const inputRef = useRef<HTMLTextAreaElement>(null)
const { x, y, selectionStart, selectionEnd, setRef } = useCursorPosition()
const handleInput = (value: string) => {
send(messageInputDomain.command.InputCommand(value))
const [autoCompleteListShow, setAutoCompleteListShow] = useState(false)
const [scrollParentRef, setScrollParentRef] = useState<HTMLDivElement | null>(null)
const autoCompleteListRef = useRef<HTMLDivElement>(null)
const { setRef: setAutoCompleteListRef } = useTriggerAway(['click'], () => setAutoCompleteListShow(false))
const shareAutoCompleteListRef = useShareRef(setAutoCompleteListRef, autoCompleteListRef)
const isComposing = useRef(false)
const virtuosoRef = useRef<VirtuosoHandle>(null)
const [inputLoading, setInputLoading] = useState(false)
const shareRef = useShareRef(inputRef, setRef)
/**
* When inserting a username using the @ syntax, record the username's position information and the mapping relationship between the position information and userId to distinguish between users with the same name.
*/
const atUserRecord = useRef<Map<string, Set<[number, number]>>>(new Map())
const imageRecord = useRef<Map<string, string>>(new Map())
const updateAtUserAtRecord = useMemo(
() => (message: string, start: number, end: number, offset: number, atUserId?: string) => {
const positions: [number, number] = [start, end]
// If the editing position is before the end position of @user, update the editing position.
// "@user" => "E@user"
// "@user" => "@useEr"
// "@user" => "@user @user"
atUserRecord.current.forEach((item, userId) => {
const positionList = [...item].map<[number, number]>((item) => {
const inBefore = Math.min(start, end) <= item[1]
return inBefore ? [item[0] + offset + (end - start), item[1] + offset + (end - start)] : item
})
atUserRecord.current.set(userId, new Set(positionList))
})
// Insert a new @user record
if (atUserId) {
atUserRecord.current.set(atUserId, atUserRecord.current.get(atUserId)?.add(positions) ?? new Set([positions]))
}
// After moving, check if the @user in the message matches the saved position record. If not, it means the @user has been edited, so delete that record.
// Filter out records where the stored position does not match the actual position.
atUserRecord.current.forEach((item, userId) => {
// Pre-calculate the offset after InputCommand
const positionList = [...item].filter((item) => {
const username = message.slice(item[0], item[1] + 1)
return username === `@${userList.find((user) => user.userId === userId)?.username}`
})
if (positionList.length) {
atUserRecord.current.set(userId, new Set(positionList))
} else {
atUserRecord.current.delete(userId)
}
})
},
[userList]
)
const [selectedUserIndex, setSelectedUserIndex] = useState(0)
const [searchNameKeyword, setSearchNameKeyword] = useState('')
const autoCompleteList = useMemo(() => {
return userList
.filter((user) => user.userId !== userInfo?.id)
.map((item) => ({
...item,
similarity: getTextSimilarity(searchNameKeyword.toLowerCase(), item.username.toLowerCase())
}))
.toSorted((a, b) => b.similarity - a.similarity)
}, [searchNameKeyword, userList, userInfo])
const selectedUser = autoCompleteList.find((_, index) => index === selectedUserIndex)!
// Replace the hash URL in ![Image](hash:${hash}) with base64 and update the atUserRecord.
const transformMessage = async (message: string) => {
let newMessage = message
const matchList = [...message.matchAll(/!\[Image\]\(hash:([^\s)]+)\)/g)]
matchList?.forEach((match) => {
const base64 = imageRecord.current.get(match[1])
if (base64) {
const base64Syntax = `![Image](${base64})`
const hashSyntax = match[0]
const startIndex = match.index
const endIndex = startIndex + base64Syntax.length - hashSyntax.length
newMessage = newMessage.replace(hashSyntax, base64Syntax)
updateAtUserAtRecord(newMessage, startIndex, endIndex, 0)
}
})
return newMessage
}
const handleSend = () => {
if (!message.trim()) return
send(roomDomain.command.SendTextMessageCommand(message.trim()))
const handleSend = async () => {
if (!`${message}`.trim()) {
return send(toastDomain.command.WarningCommand('Message cannot be empty.'))
}
const transformedMessage = await transformMessage(message)
const atUsers = [...atUserRecord.current]
.map(([userId, positions]) => {
const user = userList.find((user) => user.userId === userId)
return (user ? { ...user, positions: [...positions] } : undefined)!
})
.filter(Boolean)
const newMessage = { body: transformedMessage, atUsers }
const byteSize = getTextByteSize(JSON.stringify(newMessage))
if (byteSize > WEB_RTC_MAX_MESSAGE_SIZE) {
return send(toastDomain.command.WarningCommand('Message size cannot exceed 256KiB.'))
}
send(chatRoomDomain.command.SendTextMessageCommand({ body: transformedMessage, atUsers }))
send(messageInputDomain.command.ClearCommand())
}
const handleEmojiSelect = (emoji: string) => {
send(messageInputDomain.command.InputCommand(`${message}${emoji}`))
inputRef.current?.focus()
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (autoCompleteListShow && autoCompleteList.length) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
const length = autoCompleteList.length
const prevIndex = selectedUserIndex
if (e.key === 'ArrowDown') {
const index = (prevIndex + 1) % length
setSelectedUserIndex(index)
virtuosoRef.current?.scrollIntoView({ index })
e.preventDefault()
}
if (e.key === 'ArrowUp') {
const index = (prevIndex - 1 + length) % length
setSelectedUserIndex(index)
virtuosoRef.current?.scrollIntoView({ index })
e.preventDefault()
}
}
if (['Escape', 'Backspace', 'Delete', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
if (e.key === 'Delete' || e.key === 'Backspace') {
const isDeleteAt = message.at(selectionStart - 1) === '@'
setAutoCompleteListShow(!isDeleteAt)
} else {
setAutoCompleteListShow(false)
}
setSelectedUserIndex(0)
}
}
if (e.key === 'Enter' && !(e.shiftKey || e.ctrlKey || e.altKey || e.metaKey)) {
if (isComposing.current) return
if (autoCompleteListShow && autoCompleteList.length) {
handleInjectAtSyntax(selectedUser.username)
} else {
handleSend()
}
e.preventDefault()
}
}
const handleInput = (e: ChangeEvent<HTMLTextAreaElement>) => {
const currentMessage = e.target.value
if (autoCompleteListShow) {
const target = e.target as HTMLTextAreaElement
if (target.value) {
const atIndex = target.value.lastIndexOf('@', selectionEnd - 1)
if (atIndex !== -1) {
const keyword = target.value.slice(atIndex + 1, selectionEnd)
setSearchNameKeyword(keyword)
setSelectedUserIndex(0)
virtuosoRef.current?.scrollIntoView({ index: 0 })
}
} else {
setAutoCompleteListShow(false)
}
}
const event = e.nativeEvent as InputEvent
if (event.data === '@' && autoCompleteList.length) {
setAutoCompleteListShow(true)
}
// Pre-calculate the offset after InputCommand
const start = selectionStart
const end = selectionStart + currentMessage.length - message.length
updateAtUserAtRecord(currentMessage, start, end, 0)
send(messageInputDomain.command.InputCommand(currentMessage))
}
const handlePaste = async (e: ClipboardEvent<HTMLTextAreaElement>) => {
const file = e.nativeEvent.clipboardData?.files[0]
if (['image/png', 'image/jpeg', 'image/webp'].includes(file?.type ?? '')) {
handleInjectImage(file!)
}
}
const handleInjectEmoji = (emoji: string) => {
const newMessage = `${message.slice(0, selectionEnd)}${emoji}${message.slice(selectionEnd)}`
// Pre-calculate the offset after InputCommand
const start = selectionStart
const end = selectionEnd + newMessage.length - message.length
updateAtUserAtRecord(newMessage, start, end, 0)
send(messageInputDomain.command.InputCommand(newMessage))
requestIdleCallback(() => {
inputRef.current?.setSelectionRange(end, end)
inputRef.current?.focus()
})
}
const handleInjectImage = async (file: File) => {
try {
setInputLoading(true)
const blob = await compressImage({
input: file,
targetSize: 30 * 1024,
outputType: file.size > 30 * 1024 ? 'image/webp' : undefined
})
const base64 = await blobToBase64(blob)
const hash = nanoid()
const newMessage = `${message.slice(0, selectionEnd)}![Image](hash:${hash})${message.slice(selectionEnd)}`
const start = selectionStart
const end = selectionEnd + newMessage.length - message.length
updateAtUserAtRecord(newMessage, start, end, 0)
send(messageInputDomain.command.InputCommand(newMessage))
imageRecord.current.set(hash, base64)
requestIdleCallback(() => {
inputRef.current?.setSelectionRange(end, end)
inputRef.current?.focus()
})
} catch (error) {
send(toastDomain.command.ErrorCommand((error as Error).message))
} finally {
setInputLoading(false)
}
}
const handleInjectAtSyntax = (username: string) => {
const atIndex = message.lastIndexOf('@', selectionEnd - 1)
// Determine if there is a space before @
const hasBeforeSpace = message.slice(atIndex - 1, atIndex) === ' '
const hasAfterSpace = message.slice(selectionEnd, selectionEnd + 1) === ' '
const atText = `${hasBeforeSpace ? '' : ' '}@${username}${hasAfterSpace ? '' : ' '}`
const newMessage = message.slice(0, atIndex) + `${atText}` + message.slice(selectionEnd)
setAutoCompleteListShow(false)
// Pre-calculate the offset after InputCommand
const start = atIndex
const end = selectionStart + newMessage.length - message.length
const atUserPosition: [number, number] = [start + (hasBeforeSpace ? 0 : +1), end - 1 + (hasAfterSpace ? 0 : -1)]
// Calculate the difference after replacing @text with @user
const offset = newMessage.length - message.length - (atUserPosition[1] - atUserPosition[0])
updateAtUserAtRecord(newMessage, ...atUserPosition, offset, selectedUser.userId)
send(messageInputDomain.command.InputCommand(newMessage))
requestIdleCallback(() => {
inputRef.current!.setSelectionRange(end, end)
inputRef.current!.focus()
})
}
const root = getRootNode()
return (
<div className="relative z-10 grid gap-y-2 px-4 pb-4 pt-2 before:pointer-events-none before:absolute before:inset-x-4 before:-top-2 before:h-2 before:bg-gradient-to-t before:from-slate-50 before:from-30% before:to-transparent">
<div className="relative z-10 grid gap-y-2 rounded-b-xl px-4 pb-4 pt-2 before:pointer-events-none before:absolute before:inset-x-4 before:-top-2 before:h-2 before:bg-gradient-to-t before:from-slate-50 before:from-30% before:to-transparent dark:bg-slate-900 before:dark:from-slate-900">
<Presence present={autoCompleteListShow}>
<Portal
container={root}
ref={shareAutoCompleteListRef}
className="fixed z-infinity w-36 -translate-y-full overflow-hidden rounded-lg border bg-popover text-popover-foreground shadow-md duration-300 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95"
style={{ left: `min(${x}px, 100vw - 160px)`, top: `${y}px` }}
>
<ScrollArea className="max-h-[204px] min-h-9 p-1" ref={setScrollParentRef}>
<Virtuoso
ref={virtuosoRef}
data={autoCompleteList}
defaultItemHeight={28}
context={{ currentItemIndex: selectedUserIndex }}
customScrollParent={scrollParentRef!}
itemContent={(index, user) => (
<div
key={user.userId}
onClick={() => handleInjectAtSyntax(user.username)}
onMouseEnter={() => setSelectedUserIndex(index)}
className={cn(
'flex cursor-pointer select-none items-center gap-x-2 rounded-md px-2 py-1.5 outline-none',
{
'bg-accent text-accent-foreground': index === selectedUserIndex
}
)}
>
<Avatar className="size-4 shrink-0">
<AvatarImage className="size-full" src={user.userAvatar} alt="avatar" />
<AvatarFallback>{user.username.at(0)}</AvatarFallback>
</Avatar>
<div className="flex-1 truncate text-xs text-slate-500 dark:text-slate-50">{user.username}</div>
</div>
)}
></Virtuoso>
</ScrollArea>
</Portal>
</Presence>
<MessageInput
ref={inputRef}
ref={shareRef}
value={message}
onEnter={handleSend}
onInput={handleInput}
loading={inputLoading}
onPaste={handlePaste}
onKeyDown={handleKeyDown}
maxLength={MESSAGE_MAX_LENGTH}
></MessageInput>
<div className="flex items-center">
<EmojiButton onSelect={handleEmojiSelect}></EmojiButton>
{/* <Button variant="ghost" size="icon">
<ImageIcon size={20} />
</Button> */}
<EmojiButton onSelect={handleInjectEmoji}></EmojiButton>
<ImageButton disabled={inputLoading} onSelect={handleInjectImage}></ImageButton>
<Button className="ml-auto" size="sm" onClick={handleSend}>
<span className="mr-2">Send</span>
<CornerDownLeftIcon className="text-slate-400" size={12}></CornerDownLeftIcon>

View file

@ -1,22 +1,48 @@
import { type FC } from 'react'
import { useState, type FC } from 'react'
import { Globe2Icon } from 'lucide-react'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/Avatar'
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/HoverCard'
import { Button } from '@/components/ui/Button'
import { cn, getSiteInfo } from '@/utils'
import { useRemeshDomain, useRemeshQuery } from 'remesh-react'
import RoomDomain from '@/domain/Room'
import ChatRoomDomain from '@/domain/ChatRoom'
import VirtualRoomDomain, { FromInfo, RoomUser } from '@/domain/VirtualRoom'
import { ScrollArea } from '@/components/ui/ScrollArea'
import { Virtuoso } from 'react-virtuoso'
import AvatarCircles from '@/components/magicui/AvatarCircles'
import Link from '@/components/Link'
import NumberFlow from '@number-flow/react'
const Header: FC = () => {
const siteInfo = getSiteInfo()
const roomDomain = useRemeshDomain(RoomDomain())
const userList = useRemeshQuery(roomDomain.query.UserListQuery())
const peerId = useRemeshQuery(roomDomain.query.PeerIdQuery())
const onlineCount = userList.length
const chatRoomDomain = useRemeshDomain(ChatRoomDomain())
const virtualRoomDomain = useRemeshDomain(VirtualRoomDomain())
const chatUserList = useRemeshQuery(chatRoomDomain.query.UserListQuery())
const virtualUserList = useRemeshQuery(virtualRoomDomain.query.UserListQuery())
const chatOnlineCount = chatUserList.length
const virtualOnlineGroup = virtualUserList
.flatMap((user) => user.fromInfos.map((from) => ({ from, user })))
.reduce<(FromInfo & { users: RoomUser[] })[]>((acc, item) => {
const existSite = acc.find((group) => group.origin === item.from.origin)
if (existSite) {
const existUser = existSite.users.find((user) => user.userId === item.user.userId)
!existUser && existSite.users.push(item.user)
} else {
acc.push({ ...item.from, users: [item.user] })
}
return acc
}, [])
.sort((a, b) => b.users.length - a.users.length)
const [chatUserListScrollParentRef, setChatUserListScrollParentRef] = useState<HTMLDivElement | null>(null)
const [virtualOnlineGroupScrollParentRef, setVirtualOnlineGroupScrollParentRef] = useState<HTMLDivElement | null>(
null
)
return (
<div className="z-10 grid h-12 grid-flow-col grid-cols-[theme('spacing.20')_auto_theme('spacing.20')] items-center justify-between rounded-t-xl bg-white px-4 backdrop-blur-lg">
<Avatar className="size-8">
<div className="z-10 grid h-12 grid-flow-col grid-cols-[theme('spacing.20')_auto_theme('spacing.20')] items-center justify-between rounded-t-xl bg-white px-4 backdrop-blur-lg dark:bg-slate-950">
<Avatar className="size-8 rounded-sm">
<AvatarImage src={siteInfo.icon} alt="favicon" />
<AvatarFallback>
<Globe2Icon size="100%" className="text-gray-400" />
@ -24,47 +50,123 @@ const Header: FC = () => {
</Avatar>
<HoverCard>
<HoverCardTrigger asChild>
<Button className="overflow-hidden" variant="link">
<span className="truncate text-lg font-semibold text-slate-600">
<Button className="overflow-hidden rounded-md p-2" variant="link">
<span className="truncate text-lg font-semibold text-slate-600 dark:text-slate-50">
{siteInfo.hostname.replace(/^www\./i, '')}
{/* {peerId} */}
</span>
</Button>
</HoverCardTrigger>
<HoverCardContent className="w-80">
<div className="grid grid-cols-[auto_1fr] gap-x-4">
<Avatar className="size-14">
<AvatarImage src={siteInfo.icon} alt="favicon" />
<AvatarFallback>
<Globe2Icon size="100%" className="text-gray-400" />
</AvatarFallback>
</Avatar>
<div className="grid items-center">
<h4 className="truncate text-sm font-semibold">{siteInfo.title}</h4>
{siteInfo.description && (
<p className="line-clamp-2 max-h-8 text-xs text-slate-500">{siteInfo.description}</p>
<HoverCardContent className="w-80 rounded-lg p-0">
<ScrollArea type="scroll" className="max-h-96 min-h-[72px] p-2" ref={setVirtualOnlineGroupScrollParentRef}>
<Virtuoso
data={virtualOnlineGroup}
defaultItemHeight={56}
customScrollParent={virtualOnlineGroupScrollParentRef!}
itemContent={(_index, site) => (
<Link
underline={false}
href={site.origin}
className="grid cursor-pointer grid-cols-[auto_1fr] items-center gap-x-2 rounded-lg px-2 py-1.5 hover:bg-accent hover:text-accent-foreground"
>
<Avatar className="size-10 rounded-sm">
<AvatarImage src={site.icon} alt="favicon" />
<AvatarFallback>
<Globe2Icon size="100%" className="text-gray-400" />
</AvatarFallback>
</Avatar>
<div className="grid items-center">
<div className="flex items-center gap-x-1 overflow-hidden">
<h4 className="flex-1 truncate text-sm font-semibold">{site.hostname.replace(/^www\./i, '')}</h4>
<div className="shrink-0 text-sm">
<div className="flex items-center gap-x-1 text-nowrap text-xs text-slate-500">
<div className="flex items-center gap-x-1 pt-px">
<span className="relative flex size-2">
<span
className={cn(
'absolute inline-flex size-full animate-ping rounded-full opacity-75',
site.users.length > 1 ? 'bg-green-400' : 'bg-orange-400'
)}
></span>
<span
className={cn(
'relative inline-flex size-full rounded-full',
site.users.length > 1 ? 'bg-green-500' : 'bg-orange-500'
)}
></span>
</span>
<span className="flex items-center leading-none dark:text-slate-50">
<span className="py-[0.25em]">ONLINE</span>
</span>
</div>
{import.meta.env.FIREFOX ? (
<span className="tabular-nums">{site.users.length}</span>
) : (
<NumberFlow className="tabular-nums" willChange value={site.users.length} />
)}
</div>
</div>
</div>
<AvatarCircles max={9} size="xs" avatarUrls={site.users.map((user) => user.userAvatar)} />
</div>
</Link>
)}
</div>
</div>
></Virtuoso>
</ScrollArea>
</HoverCardContent>
</HoverCard>
<HoverCard>
<HoverCardTrigger asChild>
<Button className=" rounded-md p-0 hover:no-underline" variant="link">
<div className="relative flex items-center gap-x-1 text-nowrap text-xs text-slate-500 hover:after:absolute hover:after:bottom-0 hover:after:left-0 hover:after:h-px hover:after:w-full hover:after:bg-black">
<div className="flex items-center gap-x-1 pt-px">
<span className="relative flex size-2">
<span
className={cn(
'absolute inline-flex size-full animate-ping rounded-full opacity-75',
chatOnlineCount > 1 ? 'bg-green-400' : 'bg-orange-400'
)}
></span>
<span
className={cn(
'relative inline-flex size-full rounded-full',
chatOnlineCount > 1 ? 'bg-green-500' : 'bg-orange-500'
)}
></span>
</span>
<span className="flex items-center leading-none dark:text-slate-50">
<span className="py-[0.25em]">ONLINE</span>
</span>
</div>
{import.meta.env.FIREFOX ? (
<span className="tabular-nums">{Math.min(chatUserList.length, 99)}</span>
) : (
<span className="tabular-nums">
<NumberFlow className="tabular-nums" willChange value={Math.min(chatUserList.length, 99)} />
{chatUserList.length > 99 && <span className="text-xs">+</span>}
</span>
)}
</div>
</Button>
</HoverCardTrigger>
<HoverCardContent className="w-36 rounded-lg p-0">
<ScrollArea type="scroll" className="max-h-[204px] min-h-9 p-1" ref={setChatUserListScrollParentRef}>
<Virtuoso
data={chatUserList}
defaultItemHeight={28}
customScrollParent={chatUserListScrollParentRef!}
itemContent={(_index, user) => (
<div className={cn('flex items-center gap-x-2 rounded-md px-2 py-1.5 outline-none')}>
<Avatar className="size-4 shrink-0">
<AvatarImage className="size-full" src={user.userAvatar} alt="avatar" />
<AvatarFallback>{user.username.at(0)}</AvatarFallback>
</Avatar>
<div className="flex-1 truncate text-xs text-slate-500 dark:text-slate-50">{user.username}</div>
</div>
)}
></Virtuoso>
</ScrollArea>
</HoverCardContent>
</HoverCard>
<div className="flex items-center gap-x-1 text-sm text-slate-500">
<span className="relative flex size-2">
<span
className={cn(
'absolute inline-flex size-full animate-ping rounded-full opacity-75',
onlineCount > 1 ? 'bg-green-400' : 'bg-orange-400'
)}
></span>
<span
className={cn(
'relative inline-flex size-2 rounded-full',
onlineCount > 1 ? 'bg-green-500' : 'bg-orange-500'
)}
></span>
</span>
<span>ONLINE {onlineCount > 99 ? '99+' : onlineCount}</span>
</div>
</div>
)
}

View file

@ -5,58 +5,56 @@ import MessageList from '../../components/MessageList'
import MessageItem from '../../components/MessageItem'
import PromptItem from '../../components/PromptItem'
import UserInfoDomain from '@/domain/UserInfo'
import RoomDomain, { MessageType } from '@/domain/Room'
import ChatRoomDomain, { MessageType } from '@/domain/ChatRoom'
import MessageListDomain from '@/domain/MessageList'
import BlurFade from '@/components/magicui/blur-fade'
const Main: FC = () => {
const send = useRemeshSend()
const messageListDomain = useRemeshDomain(MessageListDomain())
const roomDomain = useRemeshDomain(RoomDomain())
const chatRoomDomain = useRemeshDomain(ChatRoomDomain())
const userInfoDomain = useRemeshDomain(UserInfoDomain())
const userInfo = useRemeshQuery(userInfoDomain.query.UserInfoQuery())
const _messageList = useRemeshQuery(messageListDomain.query.ListQuery())
const messageList = _messageList.map((message) => {
if (message.type === MessageType.Normal) {
return {
...message,
like: message.likeUsers.some((likeUser) => likeUser.userId === userInfo?.id),
hate: message.hateUsers.some((hateUser) => hateUser.userId === userInfo?.id)
const messageList = _messageList
.map((message) => {
if (message.type === MessageType.Normal) {
return {
...message,
like: message.likeUsers.some((likeUser) => likeUser.userId === userInfo?.id),
hate: message.hateUsers.some((hateUser) => hateUser.userId === userInfo?.id)
}
}
}
return message
})
return message
})
.toSorted((a, b) => a.sendTime - b.sendTime)
const handleLikeChange = (messageId: string) => {
send(roomDomain.command.SendLikeMessageCommand(messageId))
send(chatRoomDomain.command.SendLikeMessageCommand(messageId))
}
const handleHateChange = (messageId: string) => {
send(roomDomain.command.SendHateMessageCommand(messageId))
send(chatRoomDomain.command.SendHateMessageCommand(messageId))
}
return (
<MessageList>
{messageList.map((message, index) =>
message.type === MessageType.Normal ? (
<BlurFade key={message.id} duration={0.1} yOffset={0}>
<MessageItem
key={message.id}
data={message}
like={message.like}
hate={message.hate}
onLikeChange={() => handleLikeChange(message.id)}
onHateChange={() => handleHateChange(message.id)}
></MessageItem>
</BlurFade>
<MessageItem
key={message.id}
data={message}
like={message.like}
hate={message.hate}
onLikeChange={() => handleLikeChange(message.id)}
onHateChange={() => handleHateChange(message.id)}
className="duration-300 animate-in fade-in-0"
></MessageItem>
) : (
<BlurFade key={message.id} duration={0.1} yOffset={0}>
<PromptItem
key={message.id}
data={message}
className={`${index === 0 ? 'pt-4' : ''} ${index === messageList.length - 1 ? 'pb-4' : ''}`}
></PromptItem>
</BlurFade>
<PromptItem
key={message.id}
data={message}
className={`${index === 0 ? 'pt-4' : ''} ${index === messageList.length - 1 ? 'pb-4' : ''}`}
></PromptItem>
)
)}
</MessageList>

View file

@ -2,16 +2,16 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/Avatar'
import { MAX_AVATAR_SIZE } from '@/constants/config'
import MessageListDomain, { Message, MessageType } from '@/domain/MessageList'
import UserInfoDomain, { UserInfo } from '@/domain/UserInfo'
import { checkSystemDarkMode, generateRandomAvatar, generateRandomName } from '@/utils'
import { generateRandomAvatar, generateRandomName } from '@/utils'
import { UserIcon } from 'lucide-react'
import { nanoid } from 'nanoid'
import { FC, useEffect, useState } from 'react'
import { useRemeshDomain, useRemeshSend } from 'remesh-react'
import Timer from '@resreq/timer'
import ExampleImage from '@/assets/images/example.jpg'
import PulsatingButton from '@/components/magicui/pulsating-button'
import BlurFade from '@/components/magicui/blur-fade'
import WordPullUp from '@/components/magicui/word-pull-up'
import PulsatingButton from '@/components/magicui/PulsatingButton'
import BlurFade from '@/components/magicui/BlurFade'
import WordPullUp from '@/components/magicui/WordPullUp'
import { motion } from 'framer-motion'
const mockTextList = [
@ -33,15 +33,16 @@ const mockTextList = [
`![ExampleImage](${ExampleImage})`
]
let printTextList = [...mockTextList]
const generateUserInfo = async (): Promise<UserInfo> => {
return {
id: nanoid(),
name: generateRandomName(),
avatar: await generateRandomAvatar(MAX_AVATAR_SIZE),
createTime: Date.now(),
themeMode: checkSystemDarkMode() ? 'dark' : 'system'
themeMode: 'system',
danmakuEnabled: true,
notificationEnabled: true,
notificationType: 'all'
}
}
@ -49,14 +50,16 @@ const generateMessage = async (userInfo: UserInfo): Promise<Message> => {
const { name: username, avatar: userAvatar, id: userId } = userInfo
return {
id: nanoid(),
body: printTextList.shift()!,
date: Date.now(),
body: mockTextList.shift()!,
sendTime: Date.now(),
receiveTime: Date.now(),
type: MessageType.Normal,
userId,
username,
userAvatar,
likeUsers: mockTextList.length ? [] : [{ userId, username, userAvatar }],
hateUsers: []
hateUsers: [],
atUsers: []
}
}
@ -66,9 +69,10 @@ const Setup: FC = () => {
const messageListDomain = useRemeshDomain(MessageListDomain())
const [userInfo, setUserInfo] = useState<UserInfo>()
const handleSetup = () => {
send(userInfoDomain.command.UpdateUserInfoCommand(userInfo!))
send(messageListDomain.command.ClearListCommand())
send(userInfoDomain.command.UpdateUserInfoCommand(userInfo!))
}
const refreshUserInfo = async () => {
@ -82,18 +86,16 @@ const Setup: FC = () => {
}
useEffect(() => {
printTextList.length === 0 && (printTextList = [...mockTextList])
const timer = new Timer(
async () => {
await createMessage(await refreshUserInfo())
},
{ delay: 2000, immediate: true, limit: printTextList.length }
{ delay: 2000, immediate: true, limit: mockTextList.length }
)
timer.start()
return () => {
timer.stop()
printTextList.length === 0 && send(messageListDomain.command.ClearListCommand())
send(messageListDomain.command.ClearListCommand())
}
}, [])
@ -102,7 +104,7 @@ const Setup: FC = () => {
<div className="m-auto flex flex-col items-center justify-center gap-y-8 pb-40 drop-shadow-lg">
<BlurFade key={userInfo?.avatar} inView>
<Avatar className="size-24 cursor-pointer border-4 border-white ">
<AvatarImage src={userInfo?.avatar} alt="avatar" />
<AvatarImage src={userInfo?.avatar} className="size-full" alt="avatar" />
<AvatarFallback>
<UserIcon size={30} className="text-slate-400" />
</AvatarFallback>

View file

@ -3,16 +3,32 @@ import Main from './components/Main'
import ProfileForm from './components/ProfileForm'
import BadgeList from './components/BadgeList'
import Layout from './components/Layout'
import VersionLink from './components/VersionLink'
import { useRemeshDomain, useRemeshQuery } from 'remesh-react'
import UserInfoDomain from '@/domain/UserInfo'
function App() {
const userInfoDomain = useRemeshDomain(UserInfoDomain())
const userInfo = useRemeshQuery(userInfoDomain.query.UserInfoQuery())
return (
<Layout>
<Main>
<ProfileForm></ProfileForm>
<Toaster richColors position="top-center" />
</Main>
<BadgeList></BadgeList>
</Layout>
<div className={userInfo?.themeMode}>
<Layout>
<VersionLink></VersionLink>
<Main>
<ProfileForm></ProfileForm>
<Toaster
richColors
position="top-center"
toastOptions={{
classNames: {
toast: 'dark:bg-slate-950 border dark:border-slate-600'
}
}}
/>
</Main>
<BadgeList></BadgeList>
</Layout>
</div>
)
}

View file

@ -3,7 +3,7 @@ import { type ChangeEvent } from 'react'
import { ImagePlusIcon } from 'lucide-react'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/Avatar'
import { Label } from '@/components/ui/Label'
import { cn, compressImage } from '@/utils'
import { blobToBase64, cn, compressImage } from '@/utils'
export interface AvatarSelectProps {
value?: string
@ -31,15 +31,10 @@ const AvatarSelect = React.forwardRef<HTMLInputElement, AvatarSelectProps>(
* In chrome storage.sync, each key-value pair supports a maximum storage of 8kb
* and all key-value pairs support a maximum storage of 100kb.
*/
const blob = await compressImage({ input: file, targetSize: compressSize })
const reader = new FileReader()
reader.onload = (e) => {
const base64 = e.target?.result as string
onSuccess?.(base64)
onChange?.(base64)
}
reader.onerror = () => onError?.(new Error('Failed to read image file.'))
reader.readAsDataURL(blob)
const blob = await compressImage({ input: file, targetSize: compressSize, outputType: 'image/webp' })
const base64 = await blobToBase64(blob)
onSuccess?.(base64)
onChange?.(base64)
} catch (error) {
onError?.(error as Error)
}
@ -58,12 +53,19 @@ const AvatarSelect = React.forwardRef<HTMLInputElement, AvatarSelectProps>(
className
)}
>
<AvatarImage src={value} alt="avatar" />
<AvatarImage src={value} className="size-full" alt="avatar" />
<AvatarFallback>
<ImagePlusIcon size={30} className="text-slate-400 group-hover:text-slate-500" />
</AvatarFallback>
</Avatar>
<input ref={ref} hidden disabled={disabled} type="file" accept="image/png,image/jpeg" onChange={handleChange} />
<input
ref={ref}
hidden
disabled={disabled}
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={handleChange}
/>
</Label>
)
}

View file

@ -1,15 +1,16 @@
import { FC } from 'react'
import { Button } from '@/components/ui/Button'
import { GitHubLogoIcon } from '@radix-ui/react-icons'
import Link from '@/components/Link'
const BadgeList: FC = () => {
return (
<div className="fixed inset-x-1 bottom-6 mx-auto flex w-fit">
<div className="fixed inset-x-1 bottom-4 mx-auto flex w-fit">
<Button asChild size="lg" variant="ghost" className="rounded-full px-3 text-xl font-semibold text-primary">
<a href="https://github.com/molvqingtai/WebChat" target="https://github.com/molvqingtai/WebChat">
<Link href="https://github.com/molvqingtai/WebChat">
<GitHubLogoIcon className="mr-1 size-6"></GitHubLogoIcon>
Github
</a>
</Link>
</Button>
</div>
)

View file

@ -1,4 +1,4 @@
import Meteors from '@/components/magicui/meteors'
import Meteors from '@/components/magicui/Meteors'
import { FC, ReactNode } from 'react'
export interface LayoutProps {
@ -7,7 +7,7 @@ export interface LayoutProps {
const Layout: FC<LayoutProps> = ({ children }) => {
return (
<div className="h-screen w-screen bg-gray-50 bg-[url(@/assets/images/texture.png)] font-sans">
<div className={`h-screen w-screen bg-gray-50 bg-[url(@/assets/images/texture.png)] font-sans dark:bg-slate-950`}>
<div className="fixed left-0 top-0 h-full w-screen overflow-hidden">
<Meteors number={30} />
</div>

View file

@ -7,7 +7,7 @@ export interface MainProps {
const Main: FC<MainProps> = ({ children }) => {
return (
<main className="grid min-h-screen min-w-screen items-center justify-center">
<div className="relative rounded-xl bg-slate-50 shadow-lg">{children}</div>
<div className="relative rounded-xl bg-slate-50 shadow-lg dark:bg-slate-900 dark:text-slate-50">{children}</div>
</main>
)
}

View file

@ -3,41 +3,41 @@ import { useForm } from 'react-hook-form'
import { valibotResolver } from '@hookform/resolvers/valibot'
import { useRemeshDomain, useRemeshQuery, useRemeshSend } from 'remesh-react'
import { nanoid } from 'nanoid'
import { useEffect } from 'react'
import { useEffect, type FC } from 'react'
import AvatarSelect from './AvatarSelect'
import { Button } from '@/components/ui/Button'
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/Form'
import { Input } from '@/components/ui/Input'
import UserInfoDomain, { type UserInfo } from '@/domain/UserInfo'
import { checkSystemDarkMode, generateRandomAvatar } from '@/utils'
import { cn, generateRandomAvatar } from '@/utils'
import { RadioGroup, RadioGroupItem } from '@/components/ui/RadioGroup'
import { Label } from '@/components/ui/Label'
import { RefreshCcwIcon } from 'lucide-react'
import { MAX_AVATAR_SIZE } from '@/constants/config'
import ToastDomain from '@/domain/Toast'
import BlurFade from '@/components/magicui/blur-fade'
import debounce from './../../../utils/debounce'
import { ToastImpl } from '@/domain/impls/Toast'
import BlurFade from '@/components/magicui/BlurFade'
import { Checkbox } from '@/components/ui/Checkbox'
import Link from '@/components/Link'
const defaultUserInfo: UserInfo = {
id: nanoid(),
name: '',
avatar: '',
createTime: Date.now(),
themeMode: checkSystemDarkMode() ? 'dark' : 'system'
themeMode: 'system',
danmakuEnabled: true,
notificationEnabled: true,
notificationType: 'all'
}
const formSchema = v.object({
id: v.string(),
createTime: v.number(),
// Pure numeric strings will be converted to number
// Issues: https://github.com/unjs/unstorage/issues/277
// name: v.string([
// // toTrimmed(),
// v.minBytes(1, 'Please enter your username.'),
// v.maxBytes(20, 'Your username cannot exceed 20 bytes.')
// ]),
createTime: v.number(),
name: v.pipe(
v.string(),
v.trim(),
v.minBytes(1, 'Please enter your username.'),
v.maxBytes(20, 'Your username cannot exceed 20 bytes.')
),
@ -49,12 +49,14 @@ const formSchema = v.object({
themeMode: v.pipe(
v.string(),
v.union([v.literal('system'), v.literal('light'), v.literal('dark')], 'Please select extension theme mode.')
)
),
danmakuEnabled: v.boolean(),
notificationEnabled: v.boolean(),
notificationType: v.pipe(v.string(), v.union([v.literal('all'), v.literal('at')], 'Please select notification type.'))
})
const ProfileForm = () => {
const ProfileForm: FC = () => {
const send = useRemeshSend()
const toastDomain = useRemeshDomain(ToastDomain())
const toast = ToastImpl.value
const userInfoDomain = useRemeshDomain(UserInfoDomain())
const userInfo = useRemeshQuery(userInfoDomain.query.UserInfoQuery())
@ -71,15 +73,15 @@ const ProfileForm = () => {
const handleSubmit = (userInfo: UserInfo) => {
send(userInfoDomain.command.UpdateUserInfoCommand(userInfo))
send(toastDomain.command.SuccessCommand('Saved successfully!'))
toast.success('Saved successfully!')
}
const handleWarning = (error: Error) => {
send(toastDomain.command.WarningCommand(error.message))
toast.warning(error.message)
}
const handleError = (error: Error) => {
send(toastDomain.command.ErrorCommand(error.message))
toast.error(error.message)
}
const handleRefreshAvatar = async () => {
@ -89,37 +91,49 @@ const ProfileForm = () => {
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} autoComplete="off" className="relative w-96 space-y-8 p-10">
<form
onSubmit={form.handleSubmit(handleSubmit)}
autoComplete="off"
className="relative w-[450px] space-y-8 p-14 pt-20"
>
<FormField
control={form.control}
name="avatar"
render={({ field }) => (
<FormItem className="absolute inset-x-1 top-0 mx-auto grid w-fit -translate-y-1/2 justify-items-center">
<FormItem className="absolute inset-x-1 top-0 mx-auto grid w-fit -translate-y-1/3 justify-items-center">
<FormControl>
<BlurFade key={form.getValues().avatar} duration={0.1}>
<AvatarSelect
compressSize={MAX_AVATAR_SIZE}
onError={handleError}
onWarning={handleWarning}
className="shadow-lg"
{...field}
></AvatarSelect>
</BlurFade>
<div className="flex flex-col items-center gap-2">
<BlurFade key={form.getValues().avatar} duration={0.1}>
<AvatarSelect
compressSize={MAX_AVATAR_SIZE}
onError={handleError}
onWarning={handleWarning}
className="shadow-lg"
{...field}
></AvatarSelect>
</BlurFade>
<Button
type="button"
size="xs"
className="mx-auto flex items-center gap-x-2"
onClick={handleRefreshAvatar}
>
<RefreshCcwIcon size={14} />
Ugly Avatar
</Button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="button" size="xs" className="mx-auto flex items-center gap-x-2" onClick={handleRefreshAvatar}>
<RefreshCcwIcon size={14} />
Ugly Avatar
</Button>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormLabel className="font-semibold">Username</FormLabel>
<FormControl>
<Input placeholder="Please enter your username" {...field} />
</FormControl>
@ -128,25 +142,125 @@ const ProfileForm = () => {
</FormItem>
)}
/>
<FormField
control={form.control}
name="danmakuEnabled"
render={({ field }) => (
<FormItem>
<FormControl>
<div className="flex items-center space-x-2">
<Checkbox
defaultChecked={false}
id="enable-danmaku"
onCheckedChange={field.onChange}
checked={field.value}
/>
<FormLabel className="cursor-pointer font-semibold" htmlFor="enable-danmaku">
Enable Danmaku
</FormLabel>
</div>
</FormControl>
<FormDescription>
Enabling this option will display scrolling messages on the website.
<Link className="ml-2 text-primary" href="https://en.wikipedia.org/wiki/Danmaku_subtitling">
Wikipedia
</Link>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="notificationType"
render={({ field }) => (
<FormItem>
<FormField
control={form.control}
name="notificationEnabled"
render={({ field }) => (
<FormItem>
<FormControl>
<div className="flex items-center space-x-2">
<Checkbox
defaultChecked={false}
id="enable-notification"
onCheckedChange={field.onChange}
checked={field.value}
/>
<FormLabel className="cursor-pointer font-semibold" htmlFor="enable-notification">
Enable Notification
</FormLabel>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormControl className="pl-6">
<RadioGroup
disabled={!form.getValues('notificationEnabled')}
className="flex gap-x-4"
onValueChange={field.onChange}
value={field.value}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="all" id="all" />
<Label
className={cn(
'cursor-pointer',
!form.getValues('notificationEnabled') && 'cursor-not-allowed opacity-50'
)}
htmlFor="all"
>
All message
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="at" id="at" />
<Label
className={cn(
'cursor-pointer',
!form.getValues('notificationEnabled') && 'cursor-not-allowed opacity-50'
)}
htmlFor="at"
>
Only @self
</Label>
</div>
</RadioGroup>
</FormControl>
<FormDescription>Enabling this option will display desktop notifications for messages.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="themeMode"
render={({ field }) => (
<FormItem>
<FormLabel>Theme Mode</FormLabel>
<FormLabel className="font-semibold">Theme Mode</FormLabel>
<FormControl>
<RadioGroup className="flex gap-x-4" onValueChange={field.onChange} value={field.value}>
<div className="flex items-center space-x-2">
<RadioGroupItem value="system" id="r1" />
<Label htmlFor="r1">System</Label>
<RadioGroupItem value="system" id="system" />
<Label className="cursor-pointer" htmlFor="system">
System
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="light" id="r2" />
<Label htmlFor="r2">Light</Label>
<RadioGroupItem value="light" id="light" />
<Label className="cursor-pointer" htmlFor="light">
Light
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="dark" id="r3" />
<Label htmlFor="r3">Dark</Label>
<RadioGroupItem value="dark" id="dark" />
<Label className="cursor-pointer" htmlFor="dark">
Dark
</Label>
</div>
</RadioGroup>
</FormControl>

View file

@ -0,0 +1,20 @@
import { FC } from 'react'
import { Button } from '@/components/ui/Button'
import Link from '@/components/Link'
import { version } from '@/../package.json'
const VersionLink: FC = () => {
return (
<Button
size="lg"
variant="ghost"
className="fixed right-4 top-2 rounded-full px-3 text-base font-medium text-primary"
>
<Link href="https://github.com/molvqingtai/WebChat/releases">Version: v{version}</Link>
</Button>
)
}
VersionLink.displayName = 'VersionLink'
export default VersionLink

View file

@ -2,7 +2,6 @@ import React from 'react'
import ReactDOM from 'react-dom/client'
import { Remesh } from 'remesh'
import { RemeshRoot } from 'remesh-react'
import { RemeshLogger } from 'remesh-logger'
import App from './App'
import { BrowserSyncStorageImpl } from '@/domain/impls/Storage'
import '@/assets/styles/tailwind.css'
@ -10,8 +9,7 @@ import '@/assets/styles/tailwind.css'
import { ToastImpl } from '@/domain/impls/Toast'
const store = Remesh.store({
externs: [BrowserSyncStorageImpl, ToastImpl],
inspectors: [RemeshLogger()]
externs: [BrowserSyncStorageImpl, ToastImpl]
})
ReactDOM.createRoot(document.getElementById('root')!).render(

View file

@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<radialGradient id="a4" cx=".66" fx=".66" cy=".3125" fy=".3125" gradientTransform="scale(1.5)">
<stop offset="0" stop-color="currentColor"></stop><stop offset=".3" stop-color="currentColor" stop-opacity=".9"></stop>
<stop offset=".6" stop-color="currentColor" stop-opacity=".6"></stop>
<stop offset=".8" stop-color="currentColor" stop-opacity=".3"></stop>
<stop offset="1" stop-color="currentColor" stop-opacity="0"></stop>
</radialGradient>
<circle transform-origin="center" fill="none" stroke="url(#a4)" stroke-width="15" stroke-linecap="round" stroke-dasharray="200 1000" stroke-dashoffset="0" cx="100" cy="100" r="70">
<animateTransform type="rotate" attributeName="transform" calcMode="spline" dur="2" values="360;0" keyTimes="0;1" keySplines="0 0 1 1" repeatCount="indefinite"></animateTransform>
</circle>
<circle transform-origin="center" fill="none" opacity=".2" stroke="currentColor" stroke-width="15" stroke-linecap="round" cx="100" cy="100" r="70"></circle>
</svg>

After

Width:  |  Height:  |  Size: 1 KiB

View file

@ -0,0 +1,17 @@
section[aria-live='polite'] {
display: contents;
}
:where([data-sonner-toaster]) {
max-width: 300px;
position: absolute;
display: flex;
justify-content: center;
}
:where([data-sonner-toast][data-styled='true']) {
max-width: 300px;
padding: 6px 12px;
border-radius: 9999px;
width: fit-content;
}

View file

@ -61,6 +61,17 @@
list-style: none;
outline: none;
z-index: 999999999;
transition: transform 400ms ease;
}
:where([data-sonner-toaster][data-lifted='true']) {
transform: translateY(-10px);
}
@media (hover: none) and (pointer: coarse) {
:where([data-sonner-toaster][data-lifted='true']) {
transform: none;
}
}
:where([data-sonner-toaster][data-x-position='right']) {
@ -234,7 +245,6 @@
justify-content: center;
align-items: center;
padding: 0;
background: var(--gray1);
color: var(--gray12);
border: 1px solid var(--gray4);
transform: var(--toast-close-button-transform);
@ -247,6 +257,10 @@
border-color 200ms;
}
[data-sonner-toast] [data-close-button] {
background: var(--gray1);
}
:where([data-sonner-toast]) :where([data-close-button]):focus-visible {
box-shadow:
0px 4px 12px rgba(0, 0, 0, 0.1),
@ -359,6 +373,10 @@
transition: none;
}
[data-sonner-toast][data-swiped='true'] {
user-select: none;
}
[data-sonner-toast][data-swipe-out='true'][data-y-position='bottom'],
[data-sonner-toast][data-swipe-out='true'][data-y-position='top'] {
animation: swipe-out 200ms ease-out forwards;
@ -662,23 +680,7 @@
transform 200ms;
}
section:has([data-sonner-toaster]) {
display: contents;
}
.sonner-loader[data-visible='false'] {
opacity: 0;
transform: scale(0.8) translate(-50%, -50%);
}
/* Custom styles */
:where([data-sonner-toaster]) {
width: 200px;
position: absolute;
}
:where([data-sonner-toast][data-styled='true']) {
width: 200px;
padding: 6px 12px;
border-radius: 9999px;
}

View file

@ -73,6 +73,7 @@
* {
@apply border-border;
}
:host,
:root {
@apply !bg-background !text-foreground !text-base !visible;
@ -81,11 +82,10 @@
all: initial !important;
direction: ltr !important;
}
/**
* Fix: scroll area dispay: table
* @see https://github.com/radix-ui/primitives/issues/3129
*/
[data-radix-scroll-area-viewport] > div {
display: block !important;
}
}
/* @property --shimmer-angle {
syntax: '<angle>';
inherits: false;
initial-value: 0deg;
} */

26
src/components/Link.tsx Normal file
View file

@ -0,0 +1,26 @@
import { cn } from '@/utils'
import { forwardRef, ReactNode } from 'react'
export interface LinkProps {
href: string
className?: string
children: ReactNode
underline?: boolean
}
const Link = forwardRef<HTMLAnchorElement, LinkProps>(({ href, className, children, underline = true }, ref) => {
return (
<a
href={href}
target={href}
rel="noopener noreferrer"
className={cn(underline && 'hover:underline', className)}
ref={ref}
>
{children}
</a>
)
})
Link.displayName = 'Link'
export default Link

View file

@ -3,7 +3,7 @@ import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import { cn } from '@/utils'
import { ScrollArea, ScrollBar } from './ScrollArea'
import { ScrollArea, ScrollBar } from '@/components/ui/ScrollArea'
export interface MarkdownProps {
children?: string
@ -46,12 +46,21 @@ const Markdown: FC<MarkdownProps> = ({ children = '', className }) => {
urlTransform={urlTransform}
components={{
h1: ({ className, ...props }) => (
<h1 className={cn('my-2 mt-0 font-semibold text-2xl', className)} {...props} />
<h1 className={cn('my-2 mt-0 font-semibold text-2xl dark:text-slate-50', className)} {...props} />
),
h2: ({ className, ...props }) => (
<h2 className={cn('mb-2 mt-0 font-semibold dark:text-slate-50', className)} {...props} />
),
h3: ({ className, ...props }) => (
<h3 className={cn('mb-2 mt-0 font-semibold dark:text-slate-50', className)} {...props} />
),
h4: ({ className, ...props }) => (
<h4 className={cn('mb-2 mt-0 font-semibold dark:text-slate-50', className)} {...props} />
),
h2: ({ className, ...props }) => <h2 className={cn('mb-2 mt-0 font-semibold', className)} {...props} />,
img: ({ className, alt, ...props }) => (
<img className={cn('my-2 max-w-[100%] rounded', className)} alt={alt} {...props} />
),
strong: ({ className, ...props }) => <strong className={cn('dark:text-slate-50', className)} {...props} />,
a: ({ className, ...props }) => (
<a
className={cn('text-blue-500', className)}
@ -67,7 +76,7 @@ const Markdown: FC<MarkdownProps> = ({ children = '', className }) => {
input: ({ className, ...props }) => <input className={cn('my-0', className)} {...props} />,
table: ({ className, ...props }) => (
<div className="my-2 w-full">
<ScrollArea>
<ScrollArea scrollLock={false}>
<table className={cn('my-0 w-full rounded-md', className)} {...props} />
<ScrollBar orientation="horizontal" />
</ScrollArea>
@ -106,14 +115,14 @@ const Markdown: FC<MarkdownProps> = ({ children = '', className }) => {
*
*/
code: ({ className, ...props }) => (
<ScrollArea>
<ScrollArea className="overscroll-y-auto" scrollLock={false}>
<code className={cn('text-sm', className)} {...props}></code>
<ScrollBar orientation="horizontal" />
</ScrollArea>
)
}}
remarkPlugins={[remarkGfm, remarkBreaks]}
className={cn(className, 'prose prose-sm prose-slate break-words')}
className={cn(className, 'prose prose-sm prose-slate break-words dark:text-slate-50')}
>
{children}
</ReactMarkdown>

View file

@ -0,0 +1,69 @@
import { cva, type VariantProps } from 'class-variance-authority'
import React from 'react'
import { cn } from '@/utils/index'
interface AvatarCirclesProps {
className?: string
avatarUrls: string[]
size?: VariantProps<typeof SizeVariants>['size']
max?: number
}
const SizeVariants = cva('z-10 flex -space-x-4 rtl:space-x-reverse', {
variants: {
size: {
default: 'h-10 min-w-10',
sm: 'h-8 min-w-8',
xs: 'h-6 min-w-6',
lg: 'h-12 min-w-12'
},
defaultVariants: {
size: 'default'
}
}
})
const spaceVariants = cva('flex -space-x-4 rtl:space-x-reverse', {
variants: {
size: {
default: '-space-x-4',
sm: '-space-x-3',
xs: '-space-x-2',
lg: '-space-x-5'
},
defaultVariants: {
size: 'default'
}
}
})
const AvatarCircles = ({ className, avatarUrls, size, max = 10 }: AvatarCirclesProps) => {
return (
<div className={cn(spaceVariants({ size }), className)}>
{avatarUrls.slice(0, max).map((url, index) => (
<img
key={index}
className={cn(
'rounded-full border-2 border-white dark:border-slate-800 aspect-square',
SizeVariants({ size })
)}
src={url}
alt={`Avatar ${index + 1}`}
/>
))}
<div
className={cn(
'flex items-center justify-center rounded-full border-2 border-white bg-slate-600 text-center text-xs font-medium text-white dark:border-slate-800 p-1',
SizeVariants({ size }),
size === 'xs' && 'text-2xs'
)}
>
+{avatarUrls.length}
</div>
</div>
)
}
export default AvatarCircles

View file

@ -1,26 +1,24 @@
"use client";
'use client'
import { useEffect, useState } from "react";
import { useEffect, useState } from 'react'
import { cn } from "@/utils/index";
import { cn } from '@/utils/index'
interface MeteorsProps {
number?: number;
number?: number
}
export const Meteors = ({ number = 20 }: MeteorsProps) => {
const [meteorStyles, setMeteorStyles] = useState<Array<React.CSSProperties>>(
[],
);
const [meteorStyles, setMeteorStyles] = useState<Array<React.CSSProperties>>([])
useEffect(() => {
const styles = [...new Array(number)].map(() => ({
top: -5,
left: Math.floor(Math.random() * window.innerWidth) + "px",
animationDelay: Math.random() * 1 + 0.2 + "s",
animationDuration: Math.floor(Math.random() * 8 + 2) + "s",
}));
setMeteorStyles(styles);
}, [number]);
left: Math.floor(Math.random() * window.innerWidth) + 'px',
animationDelay: Math.random() * 1 + 0.2 + 's',
animationDuration: Math.floor(Math.random() * 8 + 2) + 's'
}))
setMeteorStyles(styles)
}, [number])
return (
<>
@ -29,7 +27,7 @@ export const Meteors = ({ number = 20 }: MeteorsProps) => {
<span
key={idx}
className={cn(
"pointer-events-none absolute left-1/2 top-1/2 size-0.5 rotate-[215deg] animate-meteor rounded-full bg-slate-500 shadow-[0_0_0_1px_#ffffff10]",
'pointer-events-none absolute left-1/2 top-1/2 size-0.5 rotate-[215deg] animate-meteor rounded-full bg-slate-500 shadow-[0_0_0_1px_#ffffff10]'
)}
style={style}
>
@ -38,7 +36,7 @@ export const Meteors = ({ number = 20 }: MeteorsProps) => {
</span>
))}
</>
);
};
)
}
export default Meteors;
export default Meteors

View file

@ -29,7 +29,7 @@ const AvatarFallback = React.forwardRef<
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn('flex h-full w-full items-center justify-center rounded-full bg-muted', className)}
className={cn('flex h-full w-full items-center justify-center rounded-full bg-muted dark:text-slate-400', className)}
{...props}
/>
))

View file

@ -11,7 +11,8 @@ const buttonVariants = cva(
variant: {
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
outline: 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
outline:
'border border-input text-primary bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline'

View file

@ -0,0 +1,26 @@
import * as React from 'react'
import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
import { CheckIcon } from '@radix-ui/react-icons'
import { cn } from '@/utils/index'
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
'peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
className
)}
{...props}
>
<CheckboxPrimitive.Indicator className={cn('flex items-center justify-center text-current')}>
<CheckIcon className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

View file

@ -1,6 +1,6 @@
import * as React from 'react'
import * as PopoverPrimitive from '@radix-ui/react-popover'
import { cn } from '@/utils/index'
import { cn, getRootNode } from '@/utils'
const Popover = PopoverPrimitive.Root
@ -10,9 +10,9 @@ const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => {
const shadowRoot = document.querySelector(__NAME__)!.shadowRoot! as unknown as HTMLElement
const root = getRootNode()
return (
<PopoverPrimitive.Portal container={shadowRoot}>
<PopoverPrimitive.Portal container={root}>
<PopoverPrimitive.Content
ref={ref}
align={align}

View file

@ -5,10 +5,13 @@ import { cn } from '@/utils/index'
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> & { scrollLock?: boolean }
>(({ className, children, scrollLock = true, ...props }, ref) => (
<ScrollAreaPrimitive.Root className={cn('relative grid grid-rows-[1fr] overflow-hidden', className)} {...props}>
<ScrollAreaPrimitive.Viewport ref={ref} className="size-full overscroll-none rounded-[inherit]">
<ScrollAreaPrimitive.Viewport
ref={ref}
className={cn('size-full rounded-[inherit]', scrollLock ? 'overscroll-none' : 'overscroll-auto')}
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />

View file

@ -1,29 +0,0 @@
import { useTheme } from "next-themes"
import { Toaster as Sonner } from "sonner"
type ToasterProps = React.ComponentProps<typeof Sonner>
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...props}
/>
)
}
export { Toaster }

View file

@ -8,7 +8,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(({ classNa
return (
<textarea
className={cn(
'flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
'flex min-h-[60px] w-full rounded-md border border-input text-primary bg-transparent p-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}

View file

@ -1,8 +1,8 @@
import { version } from '@/../package.json'
// https://www.webfx.com/tools/emoji-cheat-sheet/
export const EMOJI_LIST = [
'😀',
'😃',
'😄',
'😁',
'😆',
@ -112,6 +112,7 @@ export const EMOJI_LIST = [
'👽',
'👾',
'🤖',
'👀',
'😺',
'😸',
'😹',
@ -185,16 +186,26 @@ export const BREAKPOINTS = {
export const MESSAGE_MAX_LENGTH = 500 as const
export const STORAGE_NAME = 'WEB_CHAT' as const
export const MESSAGE_LIST_STORAGE_KEY = 'WEB_CHAT_MESSAGE_LIST' as const
export const STORAGE_NAME = `WEB_CHAT_${version}` as const
export const USER_INFO_STORAGE_KEY = 'WEB_CHAT_USER_INFO' as const
export const APP_OPEN_STATUS_STORAGE_KEY = 'WEB_CHAT_APP_OPEN_STATUS' as const
export const MESSAGE_LIST_STORAGE_KEY = 'WEB_CHAT_MESSAGE_LIST' as const
export const APP_STATUS_STORAGE_KEY = 'WEB_CHAT_APP_STATUS' as const
/**
* In chrome storage.sync, each key-value pair supports a maximum storage of 8kb
* Image is encoded as base64, and the size is increased by about 33%.
* 8kb * (1 - 0.33) = 5488 bytes
*/
export const MAX_AVATAR_SIZE = 5120 as const
export const SYNC_HISTORY_MAX_DAYS = 30 as const
/**
* https://lgrahl.de/articles/demystifying-webrtc-dc-size-limit.html
* Message max size is 256KiB; if the message is too large, it will cause the connection to drop.
*/
export const WEB_RTC_MAX_MESSAGE_SIZE = 262144 as const
export const VIRTUAL_ROOM_ID = 'WEB_CHAT_VIRTUAL_ROOM' as const

View file

@ -1,3 +1,6 @@
export enum EVENT {
OPEN_OPTIONS_PAGE = 'OPEN_OPTIONS_PAGE'
OPTIONS_PAGE_OPEN = `WEB_CHAT_OPTIONS_PAGE_OPEN`,
APP_OPEN = 'WEB_CHAT_APP_OPEN',
NOTIFICATION_PUSH = 'WEB_CHAT_NOTIFICATION_PUSH',
NOTIFICATION_CLEAR = 'WEB_CHAT_NOTIFICATION_CLEAR'
}

167
src/domain/AppStatus.ts Normal file
View file

@ -0,0 +1,167 @@
import { Remesh } from 'remesh'
import StatusModule from './modules/Status'
import { LocalStorageExtern } from './externs/Storage'
import { APP_STATUS_STORAGE_KEY } from '@/constants/config'
import StorageEffect from './modules/StorageEffect'
import ChatRoomDomain, { SendType } from '@/domain/ChatRoom'
import { map } from 'rxjs'
export interface AppStatus {
open: boolean
unread: number
position: { x: number; y: number }
}
export const defaultStatusState = {
open: false,
unread: 0,
position: { x: window.innerWidth - 50, y: window.innerHeight - 22 }
}
const AppStatusDomain = Remesh.domain({
name: 'AppStatusDomain',
impl: (domain) => {
const storageEffect = new StorageEffect({
domain,
extern: LocalStorageExtern,
key: APP_STATUS_STORAGE_KEY
})
const chatRoomDomain = domain.getDomain(ChatRoomDomain())
const StatusLoadModule = StatusModule(domain, {
name: 'AppStatus.LoadStatusModule'
})
const StatusLoadIsFinishedQuery = domain.query({
name: 'AppStatus.StatusLoadIsFinishedQuery',
impl: ({ get }) => {
return get(StatusLoadModule.query.IsFinishedQuery())
}
})
const StatusState = domain.state<AppStatus>({
name: 'AppStatus.StatusState',
default: defaultStatusState
})
const OpenQuery = domain.query({
name: 'AppStatus.IsOpenQuery',
impl: ({ get }) => {
return get(StatusState()).open
}
})
const UnreadQuery = domain.query({
name: 'AppStatus.UnreadQuery',
impl: ({ get }) => {
return get(StatusState()).unread
}
})
const PositionQuery = domain.query({
name: 'AppStatus.PositionQuery',
impl: ({ get }) => {
return get(StatusState()).position
}
})
const HasUnreadQuery = domain.query({
name: 'AppStatus.HasUnreadQuery',
impl: ({ get }) => {
return get(StatusState()).unread > 0
}
})
const UpdateOpenCommand = domain.command({
name: 'AppStatus.UpdateOpenCommand',
impl: ({ get }, value: boolean) => {
const status = get(StatusState())
return UpdateStatusCommand({
...status,
unread: value ? 0 : status.unread,
open: value
})
}
})
const UpdateUnreadCommand = domain.command({
name: 'AppStatus.UpdateUnreadCommand',
impl: ({ get }, value: number) => {
const status = get(StatusState())
return UpdateStatusCommand({
...status,
unread: value
})
}
})
const UpdatePositionCommand = domain.command({
name: 'AppStatus.UpdatePositionCommand',
impl: ({ get }, value: { x: number; y: number }) => {
const status = get(StatusState())
return UpdateStatusCommand({
...status,
position: value
})
}
})
const UpdateStatusCommand = domain.command({
name: 'AppStatus.UpdateStatusCommand',
impl: (_, value: AppStatus) => {
return [StatusState().new(value), SyncToStorageEvent()]
}
})
const SyncToStorageEvent = domain.event({
name: 'UserInfo.SyncToStorageEvent',
impl: ({ get }) => {
return get(StatusState())
}
})
storageEffect
.set(SyncToStorageEvent)
.get<AppStatus>((value) => [
UpdateStatusCommand(value ?? defaultStatusState),
StatusLoadModule.command.SetFinishedCommand()
])
.watch<AppStatus>((value) => [UpdateStatusCommand(value ?? defaultStatusState)])
domain.effect({
name: 'OnMessageEffect',
impl: ({ fromEvent, get }) => {
const onMessage$ = fromEvent(chatRoomDomain.event.OnMessageEvent).pipe(
map((message) => {
const status = get(StatusState())
if (!status.open && message.type === SendType.Text) {
return UpdateUnreadCommand(status.unread + 1)
}
return null
})
)
return onMessage$
}
})
return {
query: {
OpenQuery,
UnreadQuery,
HasUnreadQuery,
PositionQuery,
StatusLoadIsFinishedQuery
},
command: {
UpdateOpenCommand,
UpdateUnreadCommand,
UpdatePositionCommand
},
event: {
SyncToStorageEvent
}
}
}
})
export default AppStatusDomain

690
src/domain/ChatRoom.ts Normal file
View file

@ -0,0 +1,690 @@
import { Remesh } from 'remesh'
import { map, merge, of, EMPTY, mergeMap, fromEventPattern } from 'rxjs'
import { AtUser, NormalMessage, type MessageUser } from './MessageList'
import { ChatRoomExtern } from '@/domain/externs/ChatRoom'
import MessageListDomain, { MessageType } from '@/domain/MessageList'
import UserInfoDomain from '@/domain/UserInfo'
import { desert, getTextByteSize, upsert } from '@/utils'
import { nanoid } from 'nanoid'
import StatusModule from '@/domain/modules/Status'
import { SYNC_HISTORY_MAX_DAYS, WEB_RTC_MAX_MESSAGE_SIZE } from '@/constants/config'
import * as v from 'valibot'
export { MessageType }
export enum SendType {
Like = 'Like',
Hate = 'Hate',
Text = 'Text',
SyncUser = 'SyncUser',
SyncHistory = 'SyncHistory'
}
export interface SyncUserMessage extends MessageUser {
type: SendType.SyncUser
id: string
peerId: string
joinTime: number
sendTime: number
lastMessageTime: number
}
export interface SyncHistoryMessage extends MessageUser {
type: SendType.SyncHistory
sendTime: number
id: string
messages: NormalMessage[]
}
export interface LikeMessage extends MessageUser {
type: SendType.Like
sendTime: number
id: string
}
export interface HateMessage extends MessageUser {
type: SendType.Hate
sendTime: number
id: string
}
export interface TextMessage extends MessageUser {
type: SendType.Text
id: string
body: string
sendTime: number
atUsers: AtUser[]
}
export type RoomMessage = SyncUserMessage | SyncHistoryMessage | LikeMessage | HateMessage | TextMessage
export type RoomUser = MessageUser & { peerIds: string[]; joinTime: number }
const MessageUserSchema = {
userId: v.string(),
username: v.string(),
userAvatar: v.string()
}
const AtUserSchema = {
userId: v.string(),
username: v.string(),
userAvatar: v.string(),
positions: v.array(v.tuple([v.number(), v.number()]))
}
const NormalMessageSchema = {
id: v.string(),
type: v.literal(MessageType.Normal),
body: v.string(),
sendTime: v.number(),
receiveTime: v.number(),
likeUsers: v.array(v.object(MessageUserSchema)),
hateUsers: v.array(v.object(MessageUserSchema)),
atUsers: v.array(v.object(AtUserSchema))
}
const RoomMessageSchema = v.union([
v.object({
type: v.literal(SendType.Text),
id: v.string(),
body: v.string(),
sendTime: v.number(),
atUsers: v.array(v.object(AtUserSchema)),
...MessageUserSchema
}),
v.object({
type: v.literal(SendType.Like),
id: v.string(),
sendTime: v.number(),
...MessageUserSchema
}),
v.object({
type: v.literal(SendType.Hate),
id: v.string(),
sendTime: v.number(),
...MessageUserSchema
}),
v.object({
type: v.literal(SendType.SyncUser),
id: v.string(),
peerId: v.string(),
joinTime: v.number(),
sendTime: v.number(),
lastMessageTime: v.number(),
...MessageUserSchema
}),
v.object({
type: v.literal(SendType.SyncHistory),
id: v.string(),
sendTime: v.number(),
messages: v.array(v.object(NormalMessageSchema)),
...MessageUserSchema
})
])
// Check if the message conforms to the format
const checkMessageFormat = (message: v.InferInput<typeof RoomMessageSchema>) =>
v.safeParse(RoomMessageSchema, message).success
const ChatRoomDomain = Remesh.domain({
name: 'ChatRoomDomain',
impl: (domain) => {
const messageListDomain = domain.getDomain(MessageListDomain())
const userInfoDomain = domain.getDomain(UserInfoDomain())
const chatRoomExtern = domain.getExtern(ChatRoomExtern)
const PeerIdState = domain.state<string>({
name: 'Room.PeerIdState',
default: chatRoomExtern.peerId
})
const PeerIdQuery = domain.query({
name: 'Room.PeerIdQuery',
impl: ({ get }) => {
return get(PeerIdState())
}
})
const JoinStatusModule = StatusModule(domain, {
name: 'Room.JoinStatusModule'
})
const UserListState = domain.state<RoomUser[]>({
name: 'Room.UserListState',
default: []
})
const UserListQuery = domain.query({
name: 'Room.UserListQuery',
impl: ({ get }) => {
return get(UserListState())
}
})
const SelfUserQuery = domain.query({
name: 'Room.SelfUserQuery',
impl: ({ get }) => {
return get(UserListQuery()).find((user) => user.peerIds.includes(chatRoomExtern.peerId))!
}
})
const LastMessageTimeQuery = domain.query({
name: 'Room.LastMessageTimeQuery',
impl: ({ get }) => {
return (
get(messageListDomain.query.ListQuery())
.filter((message) => message.type === MessageType.Normal)
.toSorted((a, b) => b.sendTime - a.sendTime)[0]?.sendTime ?? new Date(1970, 1, 1).getTime()
)
}
})
const JoinIsFinishedQuery = JoinStatusModule.query.IsFinishedQuery
const JoinRoomCommand = domain.command({
name: 'Room.JoinRoomCommand',
impl: ({ get }) => {
const { id: userId, name: username, avatar: userAvatar } = get(userInfoDomain.query.UserInfoQuery())!
return [
UpdateUserListCommand({
type: 'create',
user: { peerId: chatRoomExtern.peerId, joinTime: Date.now(), userId, username, userAvatar }
}),
messageListDomain.command.CreateItemCommand({
id: nanoid(),
userId,
username,
userAvatar,
body: `"${username}" joined the chat`,
type: MessageType.Prompt,
sendTime: Date.now(),
receiveTime: Date.now()
}),
JoinStatusModule.command.SetFinishedCommand(),
JoinRoomEvent(chatRoomExtern.roomId),
SelfJoinRoomEvent(chatRoomExtern.roomId)
]
}
})
JoinRoomCommand.after(() => {
chatRoomExtern.joinRoom()
return null
})
const LeaveRoomCommand = domain.command({
name: 'Room.LeaveRoomCommand',
impl: ({ get }) => {
const { id: userId, name: username, avatar: userAvatar } = get(userInfoDomain.query.UserInfoQuery())!
return [
messageListDomain.command.CreateItemCommand({
id: nanoid(),
userId,
username,
userAvatar,
body: `"${username}" left the chat`,
type: MessageType.Prompt,
sendTime: Date.now(),
receiveTime: Date.now()
}),
UpdateUserListCommand({
type: 'delete',
user: { peerId: chatRoomExtern.peerId, joinTime: Date.now(), userId, username, userAvatar }
}),
JoinStatusModule.command.SetInitialCommand(),
LeaveRoomEvent(chatRoomExtern.roomId),
SelfLeaveRoomEvent(chatRoomExtern.roomId)
]
}
})
LeaveRoomCommand.after(() => {
chatRoomExtern.leaveRoom()
return null
})
const SendTextMessageCommand = domain.command({
name: 'Room.SendTextMessageCommand',
impl: ({ get }, message: string | { body: string; atUsers: AtUser[] }) => {
const self = get(SelfUserQuery())
const textMessage: TextMessage = {
...self,
id: nanoid(),
type: SendType.Text,
sendTime: Date.now(),
body: typeof message === 'string' ? message : message.body,
atUsers: typeof message === 'string' ? [] : message.atUsers
}
const listMessage: NormalMessage = {
...textMessage,
type: MessageType.Normal,
receiveTime: Date.now(),
likeUsers: [],
hateUsers: [],
atUsers: typeof message === 'string' ? [] : message.atUsers
}
chatRoomExtern.sendMessage(textMessage)
return [messageListDomain.command.CreateItemCommand(listMessage), SendTextMessageEvent(textMessage)]
}
})
const SendLikeMessageCommand = domain.command({
name: 'Room.SendLikeMessageCommand',
impl: ({ get }, messageId: string) => {
const self = get(SelfUserQuery())
const localMessage = get(messageListDomain.query.ItemQuery(messageId)) as NormalMessage
const likeMessage: LikeMessage = {
...self,
id: messageId,
sendTime: Date.now(),
type: SendType.Like
}
const listMessage: NormalMessage = {
...localMessage,
likeUsers: desert(localMessage.likeUsers, likeMessage, 'userId')
}
chatRoomExtern.sendMessage(likeMessage)
return [messageListDomain.command.UpdateItemCommand(listMessage), SendLikeMessageEvent(likeMessage)]
}
})
const SendHateMessageCommand = domain.command({
name: 'Room.SendHateMessageCommand',
impl: ({ get }, messageId: string) => {
const self = get(SelfUserQuery())
const localMessage = get(messageListDomain.query.ItemQuery(messageId)) as NormalMessage
const hateMessage: HateMessage = {
...self,
id: messageId,
sendTime: Date.now(),
type: SendType.Hate
}
const listMessage: NormalMessage = {
...localMessage,
hateUsers: desert(localMessage.hateUsers, hateMessage, 'userId')
}
chatRoomExtern.sendMessage(hateMessage)
return [messageListDomain.command.UpdateItemCommand(listMessage), SendHateMessageEvent(hateMessage)]
}
})
const SendSyncUserMessageCommand = domain.command({
name: 'Room.SendSyncUserMessageCommand',
impl: ({ get }, peerId: string) => {
const self = get(SelfUserQuery())
const lastMessageTime = get(LastMessageTimeQuery())
const syncUserMessage: SyncUserMessage = {
...self,
id: nanoid(),
peerId: chatRoomExtern.peerId,
sendTime: Date.now(),
lastMessageTime,
type: SendType.SyncUser
}
chatRoomExtern.sendMessage(syncUserMessage, peerId)
return [SendSyncUserMessageEvent(syncUserMessage)]
}
})
/**
* The maximum sync message is the historical records within 30 days, using the last message as the basis for judgment.
* The number of synced messages may not be all messages within 30 days; if new messages are generated before syncing, they will not be synced.
* Users A, B, C, D, and E: A and B are online, while C, D, and E are offline.
* 1. A and B chat, generating two messages: messageA and messageB.
* 2. A and B go offline.
* 3. C and D come online, generating two messages: messageC and messageD.
* 4. A and B come online, and C and D will push two messages, messageC and messageD, to A and B. However, A and B will not push messageA and messageB to C and D because C and D's latest message timestamps are earlier than A and B's.
* 5. E comes online, and A, B, C, and D will all push messages messageA, messageB, messageC, and messageD to E.
*
* Final results:
* A and B see 4 messages: messageC, messageD, messageA, and messageB.
* C and D see 2 messages: messageA and messageB.
* E sees 4 messages: messageA, messageB, messageC, and messageD.
*
* As shown above, C and D did not sync messages that were earlier than their own.
* On one hand, if we want to fully sync 30 days of messages, we must diff the timestamps of messages within 30 days and then insert them. The current implementation only does incremental additions, and messages will accumulate over time.
* For now, let's keep it this way and see if it's necessary to fully sync the data within 30 days later.
*/
const SendSyncHistoryMessageCommand = domain.command({
name: 'Room.SendSyncHistoryMessageCommand',
impl: ({ get }, { peerId, lastMessageTime }: { peerId: string; lastMessageTime: number }) => {
const self = get(SelfUserQuery())
const historyMessages = get(messageListDomain.query.ListQuery()).filter(
(message) =>
message.type === MessageType.Normal &&
message.sendTime > lastMessageTime &&
message.sendTime - Date.now() <= SYNC_HISTORY_MAX_DAYS * 24 * 60 * 60 * 1000
)
/**
* Message chunking to ensure that each message does not exceed WEB_RTC_MAX_MESSAGE_SIZE
* If the message itself exceeds the size limit, skip syncing that message directly.
*/
const pushHistoryMessageList = historyMessages.reduce<SyncHistoryMessage[]>((acc, cur) => {
const pushHistoryMessage: SyncHistoryMessage = {
...self,
id: nanoid(),
sendTime: Date.now(),
type: SendType.SyncHistory,
messages: [cur as NormalMessage]
}
const pushHistoryMessageByteSize = getTextByteSize(JSON.stringify(pushHistoryMessage))
if (pushHistoryMessageByteSize < WEB_RTC_MAX_MESSAGE_SIZE) {
if (acc.length) {
const mergedSize = getTextByteSize(JSON.stringify(acc[acc.length - 1])) + pushHistoryMessageByteSize
if (mergedSize < WEB_RTC_MAX_MESSAGE_SIZE) {
acc[acc.length - 1].messages.push(cur as NormalMessage)
} else {
acc.push(pushHistoryMessage)
}
} else {
acc.push(pushHistoryMessage)
}
}
return acc
}, [])
return pushHistoryMessageList.map((message) => {
chatRoomExtern.sendMessage(message, peerId)
return SendSyncHistoryMessageEvent(message)
})
}
})
const UpdateUserListCommand = domain.command({
name: 'Room.UpdateUserListCommand',
impl: ({ get }, action: { type: 'create' | 'delete'; user: Omit<RoomUser, 'peerIds'> & { peerId: string } }) => {
const userList = get(UserListState())
const existUser = userList.find((user) => user.userId === action.user.userId)
if (action.type === 'create') {
return [
UserListState().new(
upsert(
userList,
{ ...action.user, peerIds: [...new Set(existUser?.peerIds || []), action.user.peerId] },
'userId'
)
)
]
} else {
return [
UserListState().new(
upsert(
userList,
{
...action.user,
peerIds: existUser?.peerIds?.filter((peerId) => peerId !== action.user.peerId) || []
},
'userId'
).filter((user) => user.peerIds.length)
)
]
}
}
})
const SendSyncHistoryMessageEvent = domain.event<SyncHistoryMessage>({
name: 'Room.SendSyncHistoryMessageEvent'
})
const SendSyncUserMessageEvent = domain.event<SyncUserMessage>({
name: 'Room.SendSyncUserMessageEvent'
})
const SendTextMessageEvent = domain.event<TextMessage>({
name: 'Room.SendTextMessageEvent'
})
const SendLikeMessageEvent = domain.event<LikeMessage>({
name: 'Room.SendLikeMessageEvent'
})
const SendHateMessageEvent = domain.event<HateMessage>({
name: 'Room.SendHateMessageEvent'
})
const JoinRoomEvent = domain.event<string>({
name: 'Room.JoinRoomEvent'
})
const LeaveRoomEvent = domain.event<string>({
name: 'Room.LeaveRoomEvent'
})
const OnMessageEvent = domain.event<RoomMessage>({
name: 'Room.OnMessageEvent'
})
const OnTextMessageEvent = domain.event<TextMessage>({
name: 'Room.OnTextMessageEvent'
})
const OnJoinRoomEvent = domain.event<string>({
name: 'Room.OnJoinRoomEvent'
})
const SelfJoinRoomEvent = domain.event<string>({
name: 'Room.SelfJoinRoomEvent'
})
const OnLeaveRoomEvent = domain.event<string>({
name: 'Room.OnLeaveRoomEvent'
})
const SelfLeaveRoomEvent = domain.event<string>({
name: 'Room.SelfLeaveRoomEvent'
})
const OnErrorEvent = domain.event<Error>({
name: 'Room.OnErrorEvent'
})
domain.effect({
name: 'Room.OnJoinRoomEffect',
impl: () => {
const onJoinRoom$ = fromEventPattern<string>(chatRoomExtern.onJoinRoom).pipe(
mergeMap((peerId) => {
// console.log('onJoinRoom', peerId)
if (chatRoomExtern.peerId === peerId) {
return [OnJoinRoomEvent(peerId)]
} else {
return [SendSyncUserMessageCommand(peerId), OnJoinRoomEvent(peerId)]
}
})
)
return onJoinRoom$
}
})
domain.effect({
name: 'Room.OnMessageEffect',
impl: ({ get }) => {
const onMessage$ = fromEventPattern<RoomMessage>(chatRoomExtern.onMessage).pipe(
mergeMap((message) => {
// Filter out messages that do not conform to the format
if (!checkMessageFormat(message)) {
console.warn('Invalid message format', message)
return EMPTY
}
const messageEvent$ = of(OnMessageEvent(message))
const textMessageEvent$ = of(message.type === SendType.Text ? OnTextMessageEvent(message) : null)
const messageCommand$ = (() => {
switch (message.type) {
case SendType.SyncUser: {
const selfUser = get(SelfUserQuery())
// If a new user joins after the current user has entered the room, a join log message needs to be created.
const existUser = get(UserListQuery()).find((user) => user.userId === message.userId)
const isNewJoinUser = !existUser && message.joinTime > selfUser.joinTime
const lastMessageTime = get(LastMessageTimeQuery())
const needSyncHistory = lastMessageTime > message.lastMessageTime
return of(
UpdateUserListCommand({ type: 'create', user: message }),
isNewJoinUser
? messageListDomain.command.CreateItemCommand({
...message,
id: nanoid(),
body: `"${message.username}" joined the chat`,
type: MessageType.Prompt,
receiveTime: Date.now()
})
: null,
needSyncHistory
? SendSyncHistoryMessageCommand({
peerId: message.peerId,
lastMessageTime: message.lastMessageTime
})
: null
)
}
case SendType.SyncHistory: {
return of(...message.messages.map((message) => messageListDomain.command.UpsertItemCommand(message)))
}
case SendType.Text:
return of(
messageListDomain.command.CreateItemCommand({
...message,
type: MessageType.Normal,
receiveTime: Date.now(),
likeUsers: [],
hateUsers: []
})
)
case SendType.Like:
case SendType.Hate: {
if (!get(messageListDomain.query.HasItemQuery(message.id))) {
return EMPTY
}
const _message = get(messageListDomain.query.ItemQuery(message.id)) as NormalMessage
const type = message.type === 'Like' ? 'likeUsers' : 'hateUsers'
return of(
messageListDomain.command.UpdateItemCommand({
..._message,
receiveTime: Date.now(),
[type]: desert(
_message[type],
{
userId: message.userId,
username: message.username,
userAvatar: message.userAvatar
},
'userId'
)
})
)
}
default:
console.warn('Unsupported message type', message)
return EMPTY
}
})()
return merge(messageEvent$, textMessageEvent$, messageCommand$)
})
)
return onMessage$
}
})
domain.effect({
name: 'Room.OnLeaveRoomEffect',
impl: ({ get }) => {
const onLeaveRoom$ = fromEventPattern<string>(chatRoomExtern.onLeaveRoom).pipe(
map((peerId) => {
if (get(JoinStatusModule.query.IsInitialQuery())) {
return null
}
// console.log('onLeaveRoom', peerId)
const existUser = get(UserListQuery()).find((user) => user.peerIds.includes(peerId))
if (existUser) {
return [
UpdateUserListCommand({ type: 'delete', user: { ...existUser, peerId } }),
existUser.peerIds.length === 1
? messageListDomain.command.CreateItemCommand({
...existUser,
id: nanoid(),
body: `"${existUser.username}" left the chat`,
type: MessageType.Prompt,
sendTime: Date.now(),
receiveTime: Date.now()
})
: null,
OnLeaveRoomEvent(peerId)
]
} else {
return [OnLeaveRoomEvent(peerId)]
}
})
)
return onLeaveRoom$
}
})
domain.effect({
name: 'Room.OnErrorEffect',
impl: () => {
const onRoomError$ = fromEventPattern<Error>(chatRoomExtern.onError).pipe(
map((error) => {
console.error(error)
return OnErrorEvent(error)
})
)
return onRoomError$
}
})
return {
query: {
PeerIdQuery,
UserListQuery,
JoinIsFinishedQuery
},
command: {
JoinRoomCommand,
LeaveRoomCommand,
SendTextMessageCommand,
SendLikeMessageCommand,
SendHateMessageCommand,
SendSyncUserMessageCommand,
SendSyncHistoryMessageCommand
},
event: {
SendTextMessageEvent,
SendLikeMessageEvent,
SendHateMessageEvent,
SendSyncUserMessageEvent,
SendSyncHistoryMessageEvent,
JoinRoomEvent,
SelfJoinRoomEvent,
LeaveRoomEvent,
SelfLeaveRoomEvent,
OnMessageEvent,
OnTextMessageEvent,
OnJoinRoomEvent,
OnLeaveRoomEvent,
OnErrorEvent
}
}
}
})
export default ChatRoomDomain

162
src/domain/Danmaku.ts Normal file
View file

@ -0,0 +1,162 @@
import { Remesh } from 'remesh'
import { DanmakuExtern } from './externs/Danmaku'
import ChatRoomDomain, { TextMessage } from '@/domain/ChatRoom'
import UserInfoDomain from './UserInfo'
import { map, merge } from 'rxjs'
const DanmakuDomain = Remesh.domain({
name: 'DanmakuDomain',
impl: (domain) => {
const danmakuExtern = domain.getExtern(DanmakuExtern)
const userInfoDomain = domain.getDomain(UserInfoDomain())
const chatRoomDomain = domain.getDomain(ChatRoomDomain())
const MountState = domain.state({
name: 'Danmaku.MountState',
default: false
})
const DanmakuEnabledState = domain.state<boolean>({
name: 'Danmaku.EnabledState',
default: false
})
const IsEnabledQuery = domain.query({
name: 'Danmaku.IsOpenQuery',
impl: ({ get }) => {
return get(DanmakuEnabledState())
}
})
const EnableCommand = domain.command({
name: 'Danmaku.EnableCommand',
impl: () => {
return DanmakuEnabledState().new(true)
}
})
const DisableCommand = domain.command({
name: 'Danmaku.DisableCommand',
impl: () => {
return DanmakuEnabledState().new(false)
}
})
const IsMountedQuery = domain.query({
name: 'Danmaku.IsMountedQuery',
impl: ({ get }) => get(MountState())
})
const PushCommand = domain.command({
name: 'Danmaku.PushCommand',
impl: (_, message: TextMessage) => {
danmakuExtern.push(message)
return [PushEvent(message)]
}
})
const UnshiftCommand = domain.command({
name: 'Danmaku.UnshiftCommand',
impl: (_, message: TextMessage) => {
danmakuExtern.unshift(message)
return [UnshiftEvent(message)]
}
})
const ClearCommand = domain.command({
name: 'Danmaku.ClearCommand',
impl: () => {
danmakuExtern.clear()
return [ClearEvent()]
}
})
const MountCommand = domain.command({
name: 'Danmaku.ClearCommand',
impl: (_, container: HTMLElement) => {
danmakuExtern.mount(container)
return [MountEvent(container)]
}
})
const UnmountCommand = domain.command({
name: 'Danmaku.UnmountCommand',
impl: () => {
danmakuExtern.unmount()
return [UnmountEvent()]
}
})
const PushEvent = domain.event<TextMessage>({
name: 'Danmaku.PushEvent'
})
const UnshiftEvent = domain.event<TextMessage>({
name: 'Danmaku.UnshiftEvent'
})
const ClearEvent = domain.event({
name: 'Danmaku.ClearEvent'
})
const MountEvent = domain.event<HTMLElement>({
name: 'Danmaku.MountEvent'
})
const UnmountEvent = domain.event({
name: 'Danmaku.UnmountEvent'
})
domain.effect({
name: 'Danmaku.OnUserInfoEffect',
impl: ({ fromEvent }) => {
const onUserInfo$ = fromEvent(userInfoDomain.event.UpdateUserInfoEvent)
return onUserInfo$.pipe(
map((userInfo) => {
return userInfo?.danmakuEnabled ? EnableCommand() : DisableCommand()
})
)
}
})
domain.effect({
name: 'Danmaku.OnRoomMessageEffect',
impl: ({ fromEvent, get }) => {
const sendTextMessage$ = fromEvent(chatRoomDomain.event.SendTextMessageEvent)
const onTextMessage$ = fromEvent(chatRoomDomain.event.OnTextMessageEvent)
const onMessage$ = merge(sendTextMessage$, onTextMessage$).pipe(
map((message) => {
const danmakuEnabled = get(IsEnabledQuery())
return danmakuEnabled ? PushCommand(message) : null
})
)
return onMessage$
}
})
return {
query: {
IsMountedQuery,
IsEnabledQuery
},
command: {
EnableCommand,
DisableCommand,
PushCommand,
UnshiftCommand,
ClearCommand,
MountCommand,
UnmountCommand
},
event: {
PushEvent,
UnshiftEvent,
ClearEvent,
MountEvent,
UnmountEvent
}
}
}
})
export default DanmakuDomain

View file

@ -16,20 +16,27 @@ export interface MessageUser {
userAvatar: string
}
export interface AtUser extends MessageUser {
positions: [number, number][]
}
export interface NormalMessage extends MessageUser {
type: MessageType.Normal
id: string
body: string
date: number
sendTime: number
receiveTime: number
likeUsers: MessageUser[]
hateUsers: MessageUser[]
atUsers: AtUser[]
}
export interface PromptMessage extends MessageUser {
type: MessageType.Prompt
id: string
body: string
date: number
sendTime: number
receiveTime: number
}
export type Message = NormalMessage | PromptMessage
@ -48,8 +55,8 @@ const MessageListDomain = Remesh.domain({
key: (message) => message.id
})
const MessageListLoadStatusModule = StatusModule(domain, {
name: 'MessageListLoadStatusModule'
const LoadStatusModule = StatusModule(domain, {
name: 'Message.ListLoadStatusModule'
})
const ListQuery = MessageListModule.query.ItemListQuery
@ -58,6 +65,8 @@ const MessageListDomain = Remesh.domain({
const HasItemQuery = MessageListModule.query.HasItemByKeyQuery
const LoadIsFinishedQuery = LoadStatusModule.query.IsFinishedQuery
const ChangeListEvent = domain.event({
name: 'MessageList.ChangeListEvent',
impl: ({ get }) => {
@ -113,6 +122,38 @@ const MessageListDomain = Remesh.domain({
}
})
const UpsertItemCommand = domain.command({
name: 'MessageList.UpsertItemCommand',
impl: (_, message: Message) => {
return [
MessageListModule.command.UpsertItemCommand(message),
UpsertItemEvent(message),
ChangeListEvent(),
SyncToStorageEvent()
]
}
})
const UpsertItemEvent = domain.event<Message>({
name: 'MessageList.UpsertItemEvent'
})
const ResetListCommand = domain.command({
name: 'MessageList.ResetListCommand',
impl: (_, messages: Message[]) => {
return [
MessageListModule.command.SetListCommand(messages),
ResetListEvent(messages),
ChangeListEvent(),
SyncToStorageEvent()
]
}
})
const ResetListEvent = domain.event<Message[]>({
name: 'MessageList.ResetListEvent'
})
const ClearListEvent = domain.event({
name: 'MessageList.ClearListEvent'
})
@ -144,29 +185,31 @@ const MessageListDomain = Remesh.domain({
storageEffect
.set(SyncToStorageEvent)
.get<
Message[]
>((value) => [SyncToStateCommand(value ?? []), MessageListLoadStatusModule.command.SetFinishedCommand()])
.get<Message[]>((value) => [SyncToStateCommand(value ?? []), LoadStatusModule.command.SetFinishedCommand()])
return {
query: {
HasItemQuery,
ItemQuery,
ListQuery,
MessageListLoadIsFinishedQuery: MessageListLoadStatusModule.query.IsFinishedQuery
LoadIsFinishedQuery
},
command: {
CreateItemCommand,
UpdateItemCommand,
DeleteItemCommand,
ClearListCommand
UpsertItemCommand,
ClearListCommand,
ResetListCommand
},
event: {
ChangeListEvent,
CreateItemEvent,
UpdateItemEvent,
DeleteItemEvent,
UpsertItemEvent,
ClearListEvent,
ResetListEvent,
SyncToStateEvent,
SyncToStorageEvent
}

117
src/domain/Notification.ts Normal file
View file

@ -0,0 +1,117 @@
import { Remesh } from 'remesh'
import { NotificationExtern } from './externs/Notification'
import ChatRoomDomain, { TextMessage } from '@/domain/ChatRoom'
import UserInfoDomain from './UserInfo'
import { map, merge } from 'rxjs'
const NotificationDomain = Remesh.domain({
name: 'NotificationDomain',
impl: (domain) => {
const notificationExtern = domain.getExtern(NotificationExtern)
const userInfoDomain = domain.getDomain(UserInfoDomain())
const chatRoomDomain = domain.getDomain(ChatRoomDomain())
const NotificationEnabledState = domain.state<boolean>({
name: 'Notification.EnabledState',
default: false
})
const IsEnabledQuery = domain.query({
name: 'Notification.IsOpenQuery',
impl: ({ get }) => {
return get(NotificationEnabledState())
}
})
const EnableCommand = domain.command({
name: 'Notification.EnableCommand',
impl: () => {
return NotificationEnabledState().new(true)
}
})
const DisableCommand = domain.command({
name: 'Notification.DisableCommand',
impl: () => {
return NotificationEnabledState().new(false)
}
})
const PushCommand = domain.command({
name: 'Notification.PushCommand',
impl: (_, message: TextMessage) => {
notificationExtern.push(message)
return [PushEvent(message)]
}
})
const PushEvent = domain.event<TextMessage>({
name: 'Notification.PushEvent'
})
const ClearEvent = domain.event<string>({
name: 'Notification.ClearEvent'
})
domain.effect({
name: 'Notification.OnUserInfoEffect',
impl: ({ fromEvent }) => {
const onUserInfo$ = fromEvent(userInfoDomain.event.UpdateUserInfoEvent)
return onUserInfo$.pipe(
map((userInfo) => {
return userInfo?.notificationEnabled ? EnableCommand() : DisableCommand()
})
)
}
})
domain.effect({
name: 'Notification.OnRoomMessageEffect',
impl: ({ fromEvent, get }) => {
const onTextMessage$ = fromEvent(chatRoomDomain.event.OnTextMessageEvent)
const onMessage$ = merge(onTextMessage$).pipe(
map((message) => {
const notificationEnabled = get(IsEnabledQuery())
if (notificationEnabled) {
// Compatible with old versions, without the atUsers field
if (message.atUsers) {
const userInfo = get(userInfoDomain.query.UserInfoQuery())
const hasAtSelf = message.atUsers.find((user) => user.userId === userInfo?.id)
if (userInfo?.notificationType === 'all') {
return PushCommand(message)
}
if (userInfo?.notificationType === 'at' && hasAtSelf) {
return PushCommand(message)
}
return null
} else {
return PushCommand(message)
}
} else {
return null
}
})
)
return onMessage$
}
})
return {
query: {
IsEnabledQuery
},
command: {
EnableCommand,
DisableCommand,
PushCommand
},
event: {
PushEvent,
ClearEvent
}
}
}
})
export default NotificationDomain

View file

@ -1,450 +0,0 @@
import { Remesh } from 'remesh'
import { map, merge, of, EMPTY, mergeMap, fromEvent, Observable, tap, fromEventPattern } from 'rxjs'
import { NormalMessage, type MessageUser } from './MessageList'
import { PeerRoomExtern } from '@/domain/externs/PeerRoom'
import MessageListDomain, { MessageType } from '@/domain/MessageList'
import UserInfoDomain from '@/domain/UserInfo'
import { desert, upsert } from '@/utils'
import { nanoid } from 'nanoid'
import StatusModule from '@/domain/modules/Status'
import { ToastExtern } from './externs/Toast'
export { MessageType }
export enum SendType {
Like = 'like',
Hate = 'hate',
Text = 'text',
Join = 'join'
}
export interface SyncUserMessage extends MessageUser {
type: SendType.Join
id: string
peerId: string
joinTime: number
}
export interface LikeMessage extends MessageUser {
type: SendType.Like
id: string
}
export interface HateMessage extends MessageUser {
type: SendType.Hate
id: string
}
export interface TextMessage extends MessageUser {
type: SendType.Text
id: string
body: string
}
export type RoomMessage = SyncUserMessage | LikeMessage | HateMessage | TextMessage
export type RoomUser = MessageUser & { peerId: string; joinTime: number }
const RoomDomain = Remesh.domain({
name: 'RoomDomain',
impl: (domain) => {
const messageListDomain = domain.getDomain(MessageListDomain())
const userInfoDomain = domain.getDomain(UserInfoDomain())
const peerRoom = domain.getExtern(PeerRoomExtern)
const toast = domain.getExtern(ToastExtern)
const PeerIdState = domain.state<string>({
name: 'Room.PeerIdState',
default: peerRoom.peerId
})
const PeerIdQuery = domain.query({
name: 'Room.PeerIdQuery',
impl: ({ get }) => {
return get(PeerIdState())
}
})
const RoomJoinStatusModule = StatusModule(domain, {
name: 'RoomJoinStatusModule'
})
const UserListState = domain.state<RoomUser[]>({
name: 'RoomUserListState',
default: []
})
const UserListQuery = domain.query({
name: 'Room.UserListQuery',
impl: ({ get }) => {
return get(UserListState())
}
})
const JoinRoomCommand = domain.command({
name: 'RoomJoinRoomCommand',
impl: ({ get }) => {
peerRoom.joinRoom()
const { id: userId, name: username, avatar: userAvatar } = get(userInfoDomain.query.UserInfoQuery())!
return [
UpdateUserListCommand({
type: 'create',
user: { peerId: peerRoom.peerId, joinTime: Date.now(), userId, username, userAvatar }
}),
messageListDomain.command.CreateItemCommand({
id: nanoid(),
userId,
username,
userAvatar,
body: `"${username}" joined the chat`,
type: MessageType.Prompt,
date: Date.now()
}),
RoomJoinStatusModule.command.SetFinishedCommand(),
JoinRoomEvent(peerRoom.roomId)
]
}
})
const LeaveRoomCommand = domain.command({
name: 'RoomLeaveRoomCommand',
impl: ({ get }) => {
peerRoom.leaveRoom()
const { id: userId, name: username, avatar: userAvatar } = get(userInfoDomain.query.UserInfoQuery())!
return [
messageListDomain.command.CreateItemCommand({
id: nanoid(),
userId,
username,
userAvatar,
body: `"${username}" left the chat`,
type: MessageType.Prompt,
date: Date.now()
}),
UpdateUserListCommand({
type: 'delete',
user: { peerId: peerRoom.peerId, joinTime: Date.now(), userId, username, userAvatar }
}),
RoomJoinStatusModule.command.SetInitialCommand(),
LeaveRoomEvent(peerRoom.roomId)
]
}
})
const SendTextMessageCommand = domain.command({
name: 'RoomSendTextMessageCommand',
impl: ({ get }, message: string) => {
const { id: userId, name: username, avatar: userAvatar } = get(userInfoDomain.query.UserInfoQuery())!
const textMessage: TextMessage = {
id: nanoid(),
type: SendType.Text,
body: message,
userId,
username,
userAvatar
}
const listMessage: NormalMessage = {
...textMessage,
type: MessageType.Normal,
date: Date.now(),
likeUsers: [],
hateUsers: []
}
peerRoom.sendMessage<RoomMessage>(textMessage)
return [messageListDomain.command.CreateItemCommand(listMessage), SendTextMessageEvent(textMessage)]
}
})
const SendLikeMessageCommand = domain.command({
name: 'RoomSendLikeMessageCommand',
impl: ({ get }, messageId: string) => {
const { id: userId, name: username, avatar: userAvatar } = get(userInfoDomain.query.UserInfoQuery())!
const localMessage = get(messageListDomain.query.ItemQuery(messageId)) as NormalMessage
const likeMessage: LikeMessage = {
id: messageId,
userId,
username,
userAvatar,
type: SendType.Like
}
const listMessage: NormalMessage = {
...localMessage,
likeUsers: desert(localMessage.likeUsers, likeMessage, 'userId')
}
peerRoom.sendMessage<RoomMessage>(likeMessage)
return [messageListDomain.command.UpdateItemCommand(listMessage), SendLikeMessageEvent(likeMessage)]
}
})
const SendHateMessageCommand = domain.command({
name: 'RoomSendHateMessageCommand',
impl: ({ get }, messageId: string) => {
const { id: userId, name: username, avatar: userAvatar } = get(userInfoDomain.query.UserInfoQuery())!
const localMessage = get(messageListDomain.query.ItemQuery(messageId)) as NormalMessage
const hateMessage: HateMessage = {
id: messageId,
userId,
username,
userAvatar,
type: SendType.Hate
}
const listMessage: NormalMessage = {
...localMessage,
hateUsers: desert(localMessage.hateUsers, hateMessage, 'userId')
}
peerRoom.sendMessage<RoomMessage>(hateMessage)
return [messageListDomain.command.UpdateItemCommand(listMessage), SendHateMessageEvent(hateMessage)]
}
})
const SendJoinMessageCommand = domain.command({
name: 'RoomSendJoinMessageCommand',
impl: ({ get }, targetPeerId: string) => {
const self = get(UserListQuery()).find((user) => user.peerId === peerRoom.peerId)!
const syncUserMessage: SyncUserMessage = {
...self,
id: nanoid(),
type: SendType.Join
}
peerRoom.sendMessage<RoomMessage>(syncUserMessage, targetPeerId)
return [SendJoinMessageEvent(syncUserMessage)]
}
})
const UpdateUserListCommand = domain.command({
name: 'RoomUpdateUserListCommand',
impl: ({ get }, action: { type: 'create' | 'delete'; user: RoomUser }) => {
const userList = get(UserListState())
if (action.type === 'create') {
return [UserListState().new(upsert(userList, action.user, 'peerId'))]
} else {
return [UserListState().new(userList.filter(({ peerId }) => peerId !== action.user.peerId))]
}
}
})
const SendJoinMessageEvent = domain.event<SyncUserMessage>({
name: 'RoomSendJoinMessageEvent'
})
const SendTextMessageEvent = domain.event<TextMessage>({
name: 'RoomSendTextMessageEvent'
})
const SendLikeMessageEvent = domain.event<LikeMessage>({
name: 'RoomSendLikeMessageEvent'
})
const SendHateMessageEvent = domain.event<HateMessage>({
name: 'RoomSendHateMessageEvent'
})
const JoinRoomEvent = domain.event<string>({
name: 'RoomJoinRoomEvent'
})
const LeaveRoomEvent = domain.event<string>({
name: 'RoomLeaveRoomEvent'
})
const OnMessageEvent = domain.event<RoomMessage>({
name: 'RoomOnMessageEvent'
})
const OnJoinRoomEvent = domain.event<string>({
name: 'RoomOnJoinRoomEvent'
})
const OnLeaveRoomEvent = domain.event<string>({
name: 'RoomOnLeaveRoomEvent'
})
domain.effect({
name: 'RoomOnJoinRoomEffect',
impl: () => {
const onJoinRoom$ = fromEventPattern<string>(peerRoom.onJoinRoom).pipe(
mergeMap((peerId) => {
// console.log('onJoinRoom', peerId)
if (peerRoom.peerId === peerId) {
return [OnJoinRoomEvent(peerId)]
} else {
return [SendJoinMessageCommand(peerId), OnJoinRoomEvent(peerId)]
}
})
)
return onJoinRoom$
}
})
domain.effect({
name: 'RoomOnMessageEffect',
impl: ({ get }) => {
const onMessage$ = fromEventPattern<RoomMessage>(peerRoom.onMessage).pipe(
mergeMap((message) => {
// console.log('onMessage', message)
const messageEvent$ = of(OnMessageEvent(message))
const commandEvent$ = (() => {
switch (message.type) {
case SendType.Join: {
const userList = get(UserListQuery())
const selfUser = userList.find((user) => user.peerId === peerRoom.peerId)!
// If the browser has multiple tabs open, it can cause the same user to join multiple times with the same peerId but different userId
const isSelfJoinEvent = !!userList.find((user) => user.userId === message.userId)
// When a new user joins, it triggers join events for all users, i.e., newUser join event and oldUser join event
// Use joinTime to determine if it's a new user
const isNewJoinEvent = selfUser.joinTime < message.joinTime
return isSelfJoinEvent
? EMPTY
: of(
UpdateUserListCommand({ type: 'create', user: message }),
isNewJoinEvent
? messageListDomain.command.CreateItemCommand({
...message,
id: nanoid(),
body: `"${message.username}" joined the chat`,
type: MessageType.Prompt,
date: Date.now()
})
: null
)
}
case SendType.Text:
return of(
messageListDomain.command.CreateItemCommand({
...message,
type: MessageType.Normal,
date: Date.now(),
likeUsers: [],
hateUsers: []
})
)
case SendType.Like:
case SendType.Hate: {
if (!get(messageListDomain.query.HasItemQuery(message.id))) {
return EMPTY
}
const _message = get(messageListDomain.query.ItemQuery(message.id)) as NormalMessage
const type = message.type === 'like' ? 'likeUsers' : 'hateUsers'
return of(
messageListDomain.command.UpdateItemCommand({
..._message,
[type]: desert(
_message[type],
{
userId: message.userId,
username: message.username,
userAvatar: message.userAvatar
},
'userId'
)
})
)
}
default:
console.warn('Unsupported message type', message)
return EMPTY
}
})()
return merge(messageEvent$, commandEvent$)
})
)
return onMessage$
}
})
domain.effect({
name: 'RoomOnLeaveRoomEffect',
impl: ({ get }) => {
const onLeaveRoom$ = fromEventPattern<string>(peerRoom.onLeaveRoom).pipe(
map((peerId) => {
// console.log('onLeaveRoom', peerId)
const user = get(UserListQuery()).find((user) => user.peerId === peerId)
if (user) {
return [
UpdateUserListCommand({ type: 'delete', user }),
messageListDomain.command.CreateItemCommand({
...user,
id: nanoid(),
body: `"${user.username}" left the chat`,
type: MessageType.Prompt,
date: Date.now()
}),
OnLeaveRoomEvent(peerId)
]
} else {
return [OnLeaveRoomEvent(peerId)]
}
})
)
return onLeaveRoom$
}
})
domain.effect({
name: 'RoomOnErrorEffect',
impl: () => {
const onRoomError$ = fromEventPattern<Error>(peerRoom.onError).pipe(
map((error) => {
console.error(error)
toast.error(error.message)
return null
})
)
return onRoomError$
}
})
// TODO: Move this to a service worker in the future, so we don't need to send a leave room message every time the page refreshes
domain.effect({
name: 'RoomOnUnloadEffect',
impl: () => {
const beforeUnload$ = fromEvent(window, 'beforeunload').pipe(
map(() => {
return [LeaveRoomCommand()]
})
)
return beforeUnload$
}
})
return {
query: {
PeerIdQuery,
UserListQuery,
RoomJoinIsFinishedQuery: RoomJoinStatusModule.query.IsFinishedQuery
},
command: {
JoinRoomCommand,
LeaveRoomCommand,
SendTextMessageCommand,
SendLikeMessageCommand,
SendHateMessageCommand,
SendJoinMessageCommand
},
event: {
SendTextMessageEvent,
SendLikeMessageEvent,
SendHateMessageEvent,
SendJoinMessageEvent,
JoinRoomEvent,
LeaveRoomEvent,
OnMessageEvent,
OnJoinRoomEvent,
OnLeaveRoomEvent
}
}
}
})
export default RoomDomain

View file

@ -1,10 +1,56 @@
import { Remesh } from 'remesh'
import ToastModule from './modules/Toast'
import ChatRoomDomain, { SendType } from './ChatRoom'
import VirtualRoomDomain from './VirtualRoom'
import { filter, map, merge } from 'rxjs'
const ToastDomain = Remesh.domain({
name: 'ToastDomain',
impl: (domain) => {
return ToastModule(domain)
const chatRoomDomain = domain.getDomain(ChatRoomDomain())
const virtualRoomDomain = domain.getDomain(VirtualRoomDomain())
const toastModule = ToastModule(domain)
domain.effect({
name: 'Toast.OnRoomSelfJoinRoomEffect',
impl: ({ fromEvent }) => {
const onRoomJoin$ = fromEvent(chatRoomDomain.event.SelfJoinRoomEvent).pipe(
map(() => toastModule.command.LoadingCommand({ message: 'Connected to the chat.', duration: 3000 }))
)
return onRoomJoin$
}
})
domain.effect({
name: 'Toast.OnRoomErrorEffect',
impl: ({ fromEvent }) => {
const onRoomError$ = merge(
fromEvent(chatRoomDomain.event.OnErrorEvent),
fromEvent(virtualRoomDomain.event.OnErrorEvent)
).pipe(
map((error) => {
return toastModule.command.ErrorCommand(error.message)
})
)
return onRoomError$
}
})
domain.effect({
name: 'Toast.OnSyncHistoryEffect',
impl: ({ fromEvent }) => {
const onSyncHistory$ = fromEvent(chatRoomDomain.event.OnMessageEvent).pipe(
filter((message) => message.type === SendType.SyncHistory),
map(() => toastModule.command.SuccessCommand('Syncing history messages.'))
)
return onSyncHistory$
}
})
return toastModule
}
})

View file

@ -10,6 +10,9 @@ export interface UserInfo {
avatar: string
createTime: number
themeMode: 'system' | 'light' | 'dark'
danmakuEnabled: boolean
notificationEnabled: boolean
notificationType: 'all' | 'at'
}
const UserInfoDomain = Remesh.domain({
@ -27,10 +30,10 @@ const UserInfoDomain = Remesh.domain({
})
const UserInfoLoadStatusModule = StatusModule(domain, {
name: 'UserInfoLoadStatusModule'
name: 'UserInfo.LoadStatusModule'
})
const UserInfoSetStatusModule = StatusModule(domain, {
name: 'UserInfoSetStatusModule'
name: 'UserInfo.SetStatusModule'
})
const UserInfoQuery = domain.query({
@ -79,16 +82,16 @@ const UserInfoDomain = Remesh.domain({
UserInfoState().new(userInfo),
UpdateUserInfoEvent(),
SyncToStateEvent(userInfo),
userInfo && UserInfoSetStatusModule.command.SetFinishedCommand()
userInfo
? UserInfoSetStatusModule.command.SetFinishedCommand()
: UserInfoSetStatusModule.command.SetInitialCommand()
]
}
})
storageEffect
.set(SyncToStorageEvent)
.get<UserInfo>((value) => {
return [SyncToStateCommand(value), UserInfoLoadStatusModule.command.SetFinishedCommand()]
})
.get<UserInfo>((value) => [SyncToStateCommand(value), UserInfoLoadStatusModule.command.SetFinishedCommand()])
.watch<UserInfo>((value) => [SyncToStateCommand(value)])
return {

381
src/domain/VirtualRoom.ts Normal file
View file

@ -0,0 +1,381 @@
import { Remesh } from 'remesh'
import { map, merge, of, EMPTY, mergeMap, fromEventPattern } from 'rxjs'
import { type MessageUser } from './MessageList'
import { VirtualRoomExtern } from '@/domain/externs/VirtualRoom'
import UserInfoDomain from '@/domain/UserInfo'
import { upsert } from '@/utils'
import { nanoid } from 'nanoid'
import StatusModule from '@/domain/modules/Status'
import * as v from 'valibot'
import getSiteInfo, { SiteInfo } from '@/utils/getSiteInfo'
export enum SendType {
SyncUser = 'SyncUser'
}
export interface FromInfo extends SiteInfo {
peerId: string
}
export interface SyncUserMessage extends MessageUser {
type: SendType.SyncUser
id: string
peerId: string
joinTime: number
sendTime: number
fromInfo: FromInfo
}
export type RoomMessage = SyncUserMessage
export type RoomUser = MessageUser & { peerIds: string[]; fromInfos: FromInfo[]; joinTime: number }
const MessageUserSchema = {
userId: v.string(),
username: v.string(),
userAvatar: v.string()
}
const FromInfoSchema = {
peerId: v.string(),
host: v.string(),
hostname: v.string(),
href: v.string(),
origin: v.string(),
title: v.string(),
icon: v.string(),
description: v.string()
}
const RoomMessageSchema = v.union([
v.object({
type: v.literal(SendType.SyncUser),
id: v.string(),
peerId: v.string(),
joinTime: v.number(),
sendTime: v.number(),
fromInfo: v.object(FromInfoSchema),
...MessageUserSchema
})
])
// Check if the message conforms to the format
const checkMessageFormat = (message: v.InferInput<typeof RoomMessageSchema>) =>
v.safeParse(RoomMessageSchema, message).success
const VirtualRoomDomain = Remesh.domain({
name: 'VirtualRoomDomain',
impl: (domain) => {
const userInfoDomain = domain.getDomain(UserInfoDomain())
const virtualRoomExtern = domain.getExtern(VirtualRoomExtern)
const PeerIdState = domain.state<string>({
name: 'Room.PeerIdState',
default: virtualRoomExtern.peerId
})
const PeerIdQuery = domain.query({
name: 'Room.PeerIdQuery',
impl: ({ get }) => {
return get(PeerIdState())
}
})
const JoinStatusModule = StatusModule(domain, {
name: 'Room.JoinStatusModule'
})
const UserListState = domain.state<RoomUser[]>({
name: 'Room.UserListState',
default: []
})
const UserListQuery = domain.query({
name: 'Room.UserListQuery',
impl: ({ get }) => {
return get(UserListState())
}
})
const SelfUserQuery = domain.query({
name: 'Room.SelfUserQuery',
impl: ({ get }) => {
return get(UserListQuery()).find((user) => user.peerIds.includes(virtualRoomExtern.peerId))!
}
})
const JoinIsFinishedQuery = JoinStatusModule.query.IsFinishedQuery
const JoinRoomCommand = domain.command({
name: 'Room.JoinRoomCommand',
impl: ({ get }) => {
const { id: userId, name: username, avatar: userAvatar } = get(userInfoDomain.query.UserInfoQuery())!
return [
UpdateUserListCommand({
type: 'create',
user: {
peerId: virtualRoomExtern.peerId,
fromInfo: { ...getSiteInfo(), peerId: virtualRoomExtern.peerId },
joinTime: Date.now(),
userId,
username,
userAvatar
}
}),
JoinStatusModule.command.SetFinishedCommand(),
JoinRoomEvent(virtualRoomExtern.roomId),
SelfJoinRoomEvent(virtualRoomExtern.roomId)
]
}
})
JoinRoomCommand.after(() => {
virtualRoomExtern.joinRoom()
return null
})
const LeaveRoomCommand = domain.command({
name: 'Room.LeaveRoomCommand',
impl: ({ get }) => {
const { id: userId, name: username, avatar: userAvatar } = get(userInfoDomain.query.UserInfoQuery())!
return [
UpdateUserListCommand({
type: 'delete',
user: {
peerId: virtualRoomExtern.peerId,
fromInfo: { ...getSiteInfo(), peerId: virtualRoomExtern.peerId },
joinTime: Date.now(),
userId,
username,
userAvatar
}
}),
JoinStatusModule.command.SetInitialCommand(),
LeaveRoomEvent(virtualRoomExtern.roomId),
SelfLeaveRoomEvent(virtualRoomExtern.roomId)
]
}
})
LeaveRoomCommand.after(() => {
virtualRoomExtern.leaveRoom()
return null
})
const UpdateUserListCommand = domain.command({
name: 'Room.UpdateUserListCommand',
impl: (
{ get },
action: {
type: 'create' | 'delete'
user: Omit<RoomUser, 'peerIds' | 'fromInfos'> & { peerId: string; fromInfo: FromInfo }
}
) => {
const userList = get(UserListState())
const existUser = userList.find((user) => user.userId === action.user.userId)
if (action.type === 'create') {
return [
UserListState().new(
upsert(
userList,
{
...action.user,
peerIds: [...new Set(existUser?.peerIds || []), action.user.peerId],
fromInfos: upsert(existUser?.fromInfos || [], action.user.fromInfo, 'peerId')
},
'userId'
)
)
]
} else {
return [
UserListState().new(
upsert(
userList,
{
...action.user,
peerIds: existUser?.peerIds?.filter((peerId) => peerId !== action.user.peerId) || [],
fromInfos: existUser?.fromInfos?.filter((fromInfo) => fromInfo.peerId !== action.user.peerId) || []
},
'userId'
).filter((user) => user.peerIds.length)
)
]
}
}
})
const SendSyncUserMessageCommand = domain.command({
name: 'Room.SendSyncUserMessageCommand',
impl: ({ get }, peerId: string) => {
const self = get(SelfUserQuery())
const syncUserMessage: SyncUserMessage = {
...self,
id: nanoid(),
peerId: virtualRoomExtern.peerId,
sendTime: Date.now(),
fromInfo: { ...getSiteInfo(), peerId: virtualRoomExtern.peerId },
type: SendType.SyncUser
}
virtualRoomExtern.sendMessage(syncUserMessage, peerId)
return [SendSyncUserMessageEvent(syncUserMessage)]
}
})
const SendSyncUserMessageEvent = domain.event<SyncUserMessage>({
name: 'Room.SendSyncUserMessageEvent'
})
const JoinRoomEvent = domain.event<string>({
name: 'Room.JoinRoomEvent'
})
const LeaveRoomEvent = domain.event<string>({
name: 'Room.LeaveRoomEvent'
})
const OnMessageEvent = domain.event<RoomMessage>({
name: 'Room.OnMessageEvent'
})
const OnJoinRoomEvent = domain.event<string>({
name: 'Room.OnJoinRoomEvent'
})
const SelfJoinRoomEvent = domain.event<string>({
name: 'Room.SelfJoinRoomEvent'
})
const OnLeaveRoomEvent = domain.event<string>({
name: 'Room.OnLeaveRoomEvent'
})
const SelfLeaveRoomEvent = domain.event<string>({
name: 'Room.SelfLeaveRoomEvent'
})
const OnErrorEvent = domain.event<Error>({
name: 'Room.OnErrorEvent'
})
domain.effect({
name: 'Room.OnJoinRoomEffect',
impl: () => {
const onJoinRoom$ = fromEventPattern<string>(virtualRoomExtern.onJoinRoom).pipe(
mergeMap((peerId) => {
// console.log('onJoinRoom', peerId)
if (virtualRoomExtern.peerId === peerId) {
return [OnJoinRoomEvent(peerId)]
} else {
return [SendSyncUserMessageCommand(peerId), OnJoinRoomEvent(peerId)]
}
})
)
return onJoinRoom$
}
})
domain.effect({
name: 'Room.OnMessageEffect',
impl: () => {
const onMessage$ = fromEventPattern<RoomMessage>(virtualRoomExtern.onMessage).pipe(
mergeMap((message) => {
// Filter out messages that do not conform to the format
if (!checkMessageFormat(message)) {
console.warn('Invalid message format', message)
return EMPTY
}
const messageEvent$ = of(OnMessageEvent(message))
const messageCommand$ = (() => {
switch (message.type) {
case SendType.SyncUser: {
return of(UpdateUserListCommand({ type: 'create', user: message }))
}
default:
console.warn('Unsupported message type', message)
return EMPTY
}
})()
return merge(messageEvent$, messageCommand$)
})
)
return onMessage$
}
})
domain.effect({
name: 'Room.OnLeaveRoomEffect',
impl: ({ get }) => {
const onLeaveRoom$ = fromEventPattern<string>(virtualRoomExtern.onLeaveRoom).pipe(
map((peerId) => {
if (get(JoinStatusModule.query.IsInitialQuery())) {
return null
}
// console.log('onLeaveRoom', peerId)
const existUser = get(UserListQuery()).find((user) => user.peerIds.includes(peerId))
if (existUser) {
return [
UpdateUserListCommand({
type: 'delete',
user: { ...existUser, peerId, fromInfo: { ...getSiteInfo(), peerId } }
}),
OnLeaveRoomEvent(peerId)
]
} else {
return [OnLeaveRoomEvent(peerId)]
}
})
)
return onLeaveRoom$
}
})
domain.effect({
name: 'Room.OnErrorEffect',
impl: () => {
const onRoomError$ = fromEventPattern<Error>(virtualRoomExtern.onError).pipe(
map((error) => {
console.error(error)
return OnErrorEvent(error)
})
)
return onRoomError$
}
})
return {
query: {
PeerIdQuery,
UserListQuery,
JoinIsFinishedQuery
},
command: {
JoinRoomCommand,
LeaveRoomCommand,
SendSyncUserMessageCommand
},
event: {
SendSyncUserMessageEvent,
JoinRoomEvent,
SelfJoinRoomEvent,
LeaveRoomEvent,
SelfLeaveRoomEvent,
OnMessageEvent,
OnJoinRoomEvent,
OnLeaveRoomEvent,
OnErrorEvent
}
}
}
})
export default VirtualRoomDomain

View file

@ -1,20 +1,19 @@
import { Remesh } from 'remesh'
import { RoomMessage } from '../ChatRoom'
export type PeerMessage = object | Blob | ArrayBuffer | ArrayBufferView
export interface PeerRoom {
export interface ChatRoom {
readonly peerId: string
readonly roomId: string
joinRoom: () => PeerRoom
sendMessage: <T extends PeerMessage>(message: T, id?: string) => PeerRoom
onMessage: <T extends PeerMessage>(callback: (message: T) => void) => PeerRoom
leaveRoom: () => PeerRoom
onJoinRoom: (callback: (id: string) => void) => PeerRoom
onLeaveRoom: (callback: (id: string) => void) => PeerRoom
onError: (callback: (error: Error) => void) => PeerRoom
joinRoom: () => ChatRoom
sendMessage: (message: RoomMessage, id?: string | string[]) => ChatRoom
onMessage: (callback: (message: RoomMessage) => void) => ChatRoom
leaveRoom: () => ChatRoom
onJoinRoom: (callback: (id: string) => void) => ChatRoom
onLeaveRoom: (callback: (id: string) => void) => ChatRoom
onError: (callback: (error: Error) => void) => ChatRoom
}
export const PeerRoomExtern = Remesh.extern<PeerRoom>({
export const ChatRoomExtern = Remesh.extern<ChatRoom>({
default: {
peerId: '',
roomId: '',

View file

@ -0,0 +1,30 @@
import { Remesh } from 'remesh'
import { TextMessage } from '@/domain/ChatRoom'
export interface Danmaku {
push: (message: TextMessage) => void
unshift: (message: TextMessage) => void
clear: () => void
mount: (root: HTMLElement) => void
unmount: () => void
}
export const DanmakuExtern = Remesh.extern<Danmaku>({
default: {
mount: () => {
throw new Error('"mount" not implemented.')
},
unmount() {
throw new Error('"unmount" not implemented.')
},
clear: () => {
throw new Error('"clear" not implemented.')
},
push: () => {
throw new Error('"push" not implemented.')
},
unshift: () => {
throw new Error('"unshift" not implemented.')
}
}
})

View file

@ -0,0 +1,14 @@
import { Remesh } from 'remesh'
import { TextMessage } from '@/domain/ChatRoom'
export interface Notification {
push: (message: TextMessage) => Promise<string>
}
export const NotificationExtern = Remesh.extern<Notification>({
default: {
push: () => {
throw new Error('"push" not implemented.')
}
}
})

View file

@ -1,10 +1,8 @@
import { Remesh } from 'remesh'
import { type Promisable } from 'type-fest'
export type StorageValue = null | string | number | boolean | object
export type WatchEvent = 'update' | 'remove'
export type WatchCallback = (event: WatchEvent, key: string) => any
export type Unwatch = () => Promisable<void>
export type WatchCallback = () => any
export type Unwatch = () => Promise<void>
export interface Storage {
name: string
@ -16,6 +14,30 @@ export interface Storage {
unwatch: Unwatch
}
export const LocalStorageExtern = Remesh.extern<Storage>({
default: {
name: 'STORAGE',
get: async () => {
throw new Error('"get" not implemented.')
},
set: async () => {
throw new Error('"set" not implemented.')
},
remove: async () => {
throw new Error('"remove" not implemented.')
},
clear: async () => {
throw new Error('"clear" not implemented.')
},
watch: async () => {
throw new Error('"watch" not implemented.')
},
unwatch: async () => {
throw new Error('"unwatch" not implemented.')
}
}
})
export const IndexDBStorageExtern = Remesh.extern<Storage>({
default: {
name: 'STORAGE',
@ -31,10 +53,10 @@ export const IndexDBStorageExtern = Remesh.extern<Storage>({
clear: async () => {
throw new Error('"clear" not implemented.')
},
watch: () => {
watch: async () => {
throw new Error('"watch" not implemented.')
},
unwatch: () => {
unwatch: async () => {
throw new Error('"unwatch" not implemented.')
}
}
@ -55,10 +77,10 @@ export const BrowserSyncStorageExtern = Remesh.extern<Storage>({
clear: async () => {
throw new Error('"clear" not implemented.')
},
watch: () => {
watch: async () => {
throw new Error('"watch" not implemented.')
},
unwatch: () => {
unwatch: async () => {
throw new Error('"unwatch" not implemented.')
}
}

View file

@ -1,10 +1,12 @@
import { Remesh } from 'remesh'
export interface Toast {
success: (message: string) => void
error: (message: string) => void
info: (message: string) => void
warning: (message: string) => void
success: (message: string, duration?: number) => number | string
error: (message: string, duration?: number) => number | string
info: (message: string, duration?: number) => number | string
warning: (message: string, duration?: number) => number | string
loading: (message: string, duration?: number) => number | string
cancel: (id: number | string) => number | string
}
export const ToastExtern = Remesh.extern<Toast>({
@ -20,6 +22,12 @@ export const ToastExtern = Remesh.extern<Toast>({
},
warning: () => {
throw new Error('"warning" not implemented.')
},
loading: () => {
throw new Error('"loading" not implemented.')
},
cancel: () => {
throw new Error('"cancel" not implemented.')
}
}
})

View file

@ -0,0 +1,42 @@
import { Remesh } from 'remesh'
import { RoomMessage } from '@/domain/VirtualRoom'
export interface VirtualRoom {
readonly peerId: string
readonly roomId: string
joinRoom: () => VirtualRoom
sendMessage: (message: RoomMessage, id?: string | string[]) => VirtualRoom
onMessage: (callback: (message: RoomMessage) => void) => VirtualRoom
leaveRoom: () => VirtualRoom
onJoinRoom: (callback: (id: string) => void) => VirtualRoom
onLeaveRoom: (callback: (id: string) => void) => VirtualRoom
onError: (callback: (error: Error) => void) => VirtualRoom
}
export const VirtualRoomExtern = Remesh.extern<VirtualRoom>({
default: {
peerId: '',
roomId: '',
joinRoom: () => {
throw new Error('"joinRoom" not implemented.')
},
sendMessage: () => {
throw new Error('"sendMessage" not implemented.')
},
onMessage: () => {
throw new Error('"onMessage" not implemented.')
},
leaveRoom: () => {
throw new Error('"leaveRoom" not implemented.')
},
onJoinRoom: () => {
throw new Error('"onJoinRoom" not implemented.')
},
onLeaveRoom: () => {
throw new Error('"onLeaveRoom" not implemented.')
},
onError: () => {
throw new Error('"onError" not implemented.')
}
}
})

View file

@ -1,24 +1,28 @@
import { Artico, Room } from '@rtco/client'
import { Room } from '@rtco/client'
import { PeerRoomExtern, type PeerMessage } from '@/domain/externs/PeerRoom'
import { ChatRoomExtern } from '@/domain/externs/ChatRoom'
import { stringToHex } from '@/utils'
import { nanoid } from 'nanoid'
import EventHub from '@resreq/event-hub'
import { RoomMessage } from '@/domain/ChatRoom'
import { JSONR } from '@/utils'
import Peer from './Peer'
export interface Config {
peerId?: string
peer: Peer
roomId: string
}
class PeerRoom extends EventHub {
class ChatRoom extends EventHub {
readonly peer: Peer
readonly roomId: string
private rtco?: Artico
readonly peerId: string
private room?: Room
constructor(config: Config) {
super()
this.peer = config.peer
this.roomId = config.roomId
this.peerId = config.peerId || nanoid()
this.peerId = config.peer.id
this.joinRoom = this.joinRoom.bind(this)
this.sendMessage = this.sendMessage.bind(this)
this.onMessage = this.onMessage.bind(this)
@ -29,46 +33,48 @@ class PeerRoom extends EventHub {
}
joinRoom() {
if (!this.rtco) {
this.rtco = new Artico({ id: this.peerId })
}
if (this.room) {
this.room = this.rtco.join(this.roomId)
this.room = this.peer.join(this.roomId)
} else {
this.rtco!.on('open', () => {
this.room = this.rtco!.join(this.roomId)
if (this.peer.state === 'ready') {
this.room = this.peer.join(this.roomId)
this.emit('action')
})
} else {
this.peer!.on('open', () => {
this.room = this.peer.join(this.roomId)
this.emit('action')
})
}
}
return this
}
sendMessage<T extends PeerMessage>(message: T, id?: string) {
sendMessage(message: RoomMessage, id?: string | string[]) {
if (!this.room) {
this.once('action', () => {
if (!this.room) {
this.emit('error', new Error('Room not joined'))
} else {
this.room.send(JSON.stringify(message), id)
this.room.send(JSONR.stringify(message)!, id)
}
})
} else {
this.room.send(JSON.stringify(message), id)
this.room.send(JSONR.stringify(message)!, id)
}
return this
}
onMessage<T extends PeerMessage>(callback: (message: T) => void) {
onMessage(callback: (message: RoomMessage) => void) {
if (!this.room) {
this.once('action', () => {
if (!this.room) {
this.emit('error', new Error('Room not joined'))
} else {
this.room.on('message', (message) => callback(JSON.parse(message) as T))
this.room.on('message', (message) => callback(JSONR.parse(message) as RoomMessage))
}
})
} else {
this.room.on('message', (message) => callback(JSON.parse(message) as T))
this.room.on('message', (message) => callback(JSONR.parse(message) as RoomMessage))
}
return this
}
@ -120,7 +126,7 @@ class PeerRoom extends EventHub {
return this
}
onError(callback: (error: Error) => void) {
this.rtco?.on('error', (error) => callback(error))
this.peer?.on('error', (error) => callback(error))
this.on('error', (error: Error) => callback(error))
return this
}
@ -128,10 +134,11 @@ class PeerRoom extends EventHub {
const hostRoomId = stringToHex(document.location.host)
const peerRoom = new PeerRoom({ roomId: hostRoomId })
const chatRoom = new ChatRoom({ roomId: hostRoomId, peer: Peer.createInstance() })
export const PeerRoomImpl = PeerRoomExtern.impl(peerRoom)
export const ChatRoomImpl = ChatRoomExtern.impl(chatRoom)
// https://github.com/w3c/webextensions/issues/72
// https://issues.chromium.org/issues/40251342
// https://github.com/w3c/webrtc-extensions/issues/77
// https://github.com/aklinker1/webext-core/pull/70

View file

@ -0,0 +1,74 @@
import { DanmakuExtern } from '@/domain/externs/Danmaku'
import { TextMessage } from '@/domain/ChatRoom'
import { createElement } from 'react'
import DanmakuMessage from '@/app/content/components/DanmakuMessage'
import { createRoot } from 'react-dom/client'
import { create, Manager } from 'danmu'
import { LocalStorageImpl } from './Storage'
import { AppStatus } from '../AppStatus'
import { APP_STATUS_STORAGE_KEY } from '@/constants/config'
import { EVENT } from '@/constants/event'
export class Danmaku {
private container?: Element
private manager?: Manager<TextMessage>
constructor() {
this.manager = create<TextMessage>({
durationRange: [7000, 10000],
plugin: {
$createNode(manager) {
if (!manager.node) return
createRoot(manager.node).render(
createElement(DanmakuMessage, {
data: manager.data,
onClick: async () => {
const appStatus = await LocalStorageImpl.value.get<AppStatus>(APP_STATUS_STORAGE_KEY)
LocalStorageImpl.value.set<AppStatus>(APP_STATUS_STORAGE_KEY, { ...appStatus!, open: true, unread: 0 })
dispatchEvent(new CustomEvent(EVENT.APP_OPEN))
},
onMouseEnter: () => manager.pause(),
onMouseLeave: () => manager.resume()
})
)
}
}
})
}
mount(container: HTMLElement) {
this.container = container
this.manager!.mount(container)
this.manager!.startPlaying()
}
unmount() {
if (!this.container) {
throw new Error('Danmaku not mounted')
}
this.manager!.unmount()
}
push(message: TextMessage) {
if (!this.container) {
throw new Error('Danmaku not mounted')
}
this.manager!.push(message)
}
unshift(message: TextMessage) {
if (!this.container) {
throw new Error('Danmaku not mounted')
}
this.manager!.unshift(message)
}
clear() {
if (!this.container) {
throw new Error('Danmaku not mounted')
}
this.manager!.clear()
}
}
export const DanmakuImpl = DanmakuExtern.impl(new Danmaku())

View file

@ -0,0 +1,13 @@
import { NotificationExtern } from '@/domain/externs/Notification'
import { TextMessage } from '@/domain/ChatRoom'
import { EVENT } from '@/constants/event'
import { messenger } from '@/messenger'
class Notification {
async push(message: TextMessage) {
await messenger.sendMessage(EVENT.NOTIFICATION_PUSH, message)
return message.id
}
}
export const NotificationImpl = NotificationExtern.impl(new Notification())

22
src/domain/impls/Peer.ts Normal file
View file

@ -0,0 +1,22 @@
import { nanoid } from 'nanoid'
import { Artico } from '@rtco/client'
export interface Config {
peerId?: string
}
export default class Peer extends Artico {
private static instance: Peer | null = null
private constructor(config: Config = {}) {
const { peerId = nanoid() } = config
super({ id: peerId })
}
public static createInstance(config: Config = {}) {
return (this.instance ??= new Peer(config))
}
public static getInstance() {
return this.instance
}
}

View file

@ -1,9 +1,22 @@
import { createStorage } from 'unstorage'
import indexedDbDriver from 'unstorage/drivers/indexedb'
import { IndexDBStorageExtern, BrowserSyncStorageExtern } from '@/domain/externs/Storage'
import localStorageDriver from 'unstorage/drivers/localstorage'
import { LocalStorageExtern, IndexDBStorageExtern, BrowserSyncStorageExtern } from '@/domain/externs/Storage'
import { STORAGE_NAME } from '@/constants/config'
import { webExtensionDriver } from '@/utils/webExtensionDriver'
import { Storage } from '@/domain/externs/Storage'
import { EVENT } from '@/constants/event'
/**
* Waiting to be resolved
* @see https://github.com/unjs/unstorage/issues/277
* */
export const localStorage = createStorage({
driver: localStorageDriver({ base: `${STORAGE_NAME}:` })
})
export const indexDBStorage = createStorage({
driver: indexedDbDriver({ base: `${STORAGE_NAME}:` })
})
@ -12,13 +25,36 @@ export const browserSyncStorage = createStorage({
driver: webExtensionDriver({ storageArea: 'sync' })
})
export const LocalStorageImpl = LocalStorageExtern.impl({
name: STORAGE_NAME,
get: localStorage.getItem,
set: localStorage.setItem,
remove: localStorage.removeItem,
clear: localStorage.clear,
watch: async (callback) => {
const unwatch = await localStorage.watch(callback)
/**
* Because the storage event cannot be triggered in the same browsing context
* it is necessary to listen for click events from DanmukuMessage.
* @see https://developer.mozilla.org/en-US/docs/Web/API/Window/storage_event
*/
addEventListener(EVENT.APP_OPEN, callback)
return async () => {
removeEventListener(EVENT.APP_OPEN, callback)
return unwatch()
}
},
unwatch: localStorage.unwatch
})
export const IndexDBStorageImpl = IndexDBStorageExtern.impl({
name: STORAGE_NAME,
get: indexDBStorage.getItem,
set: indexDBStorage.setItem,
remove: indexDBStorage.removeItem,
clear: indexDBStorage.clear,
watch: indexDBStorage.watch,
watch: indexDBStorage.watch as Storage['watch'],
unwatch: indexDBStorage.unwatch
})
@ -28,6 +64,6 @@ export const BrowserSyncStorageImpl = BrowserSyncStorageExtern.impl({
set: browserSyncStorage.setItem,
remove: browserSyncStorage.removeItem,
clear: browserSyncStorage.clear,
watch: browserSyncStorage.watch,
watch: browserSyncStorage.watch as Storage['watch'],
unwatch: browserSyncStorage.unwatch
})

View file

@ -2,16 +2,24 @@ import { toast } from 'sonner'
import { ToastExtern } from '@/domain/externs/Toast'
export const ToastImpl = ToastExtern.impl({
success: (message: string) => {
toast.success(message)
success: (message: string, duration: number = 4000) => {
return toast.success(message, { duration })
},
error: (message: string) => {
toast.error(message)
error: (message: string, duration: number = 4000) => {
return toast.error(message, { duration })
},
info: (message: string) => {
toast.info(message)
info: (message: string, duration: number = 4000) => {
return toast.info(message, { duration })
},
warning: (message: string) => {
toast.warning(message)
warning: (message: string, duration: number = 4000) => {
return toast.warning(message, { duration })
},
loading: (message: string, duration: number = 4000) => {
const id = toast.loading(message, { duration })
setTimeout(() => toast.dismiss(id), duration)
return id
},
cancel: (id: number | string) => {
return toast.dismiss(id)
}
})

View file

@ -1,26 +1,29 @@
import { type DataPayload, type Room, joinRoom, selfId } from 'trystero'
import { Room } from '@rtco/client'
// import { joinRoom } from 'trystero/firebase'
import { PeerRoomExtern, type PeerMessage } from '@/domain/externs/PeerRoom'
import { VirtualRoomExtern } from '@/domain/externs/VirtualRoom'
import { stringToHex } from '@/utils'
import EventHub from '@resreq/event-hub'
import { RoomMessage } from '@/domain/VirtualRoom'
import { JSONR } from '@/utils'
import { VIRTUAL_ROOM_ID } from '@/constants/config'
import Peer from './Peer'
export interface Config {
peerId?: string
peer: Peer
roomId: string
}
class PeerRoom extends EventHub {
readonly appId: string
private room?: Room
class VirtualRoom extends EventHub {
readonly peer: Peer
readonly roomId: string
readonly peerId: string
private room?: Room
constructor(config: Config) {
super()
this.appId = __NAME__
this.peer = config.peer
this.roomId = config.roomId
this.peerId = selfId
this.peerId = config.peer.id
this.joinRoom = this.joinRoom.bind(this)
this.sendMessage = this.sendMessage.bind(this)
this.onMessage = this.onMessage.bind(this)
@ -31,50 +34,48 @@ class PeerRoom extends EventHub {
}
joinRoom() {
this.room = joinRoom({ appId: this.appId }, this.roomId)
/**
* If we wait to join, it will result in not being able to listen to our own join event.
* This might be related to the fact that:
* (If called more than once, only the latest callback registered is ever called.)
* Multiple listeners may overwrite each other.
* @see: https://github.com/dmotz/trystero?tab=readme-ov-file#onpeerjoincallback
*/
// this.room.onPeerJoin(() => this.emit('action'))
this.emit('action')
return this
}
sendMessage<T extends PeerMessage>(message: T, id?: string) {
if (!this.room) {
this.once('action', () => {
if (!this.room) {
this.emit('error', new Error('Room not joined'))
} else {
const [send] = this.room.makeAction('MESSAGE')
send(message as DataPayload, id)
}
})
if (this.room) {
this.room = this.peer.join(this.roomId)
} else {
const [send] = this.room.makeAction('MESSAGE')
send(message as DataPayload, id)
if (this.peer.state === 'ready') {
this.room = this.peer.join(this.roomId)
this.emit('action')
} else {
this.peer!.on('open', () => {
this.room = this.peer.join(this.roomId)
this.emit('action')
})
}
}
return this
}
onMessage<T extends PeerMessage>(callback: (message: T) => void) {
sendMessage(message: RoomMessage, id?: string | string[]) {
if (!this.room) {
this.once('action', () => {
if (!this.room) {
this.emit('error', new Error('Room not joined'))
} else {
const [, on] = this.room.makeAction('MESSAGE')
on((message) => callback(message as T))
this.room.send(JSONR.stringify(message)!, id)
}
})
} else {
const [, on] = this.room.makeAction('MESSAGE')
on((message) => callback(message as T))
this.room.send(JSONR.stringify(message)!, id)
}
return this
}
onMessage(callback: (message: RoomMessage) => void) {
if (!this.room) {
this.once('action', () => {
if (!this.room) {
this.emit('error', new Error('Room not joined'))
} else {
this.room.on('message', (message) => callback(JSONR.parse(message) as RoomMessage))
}
})
} else {
this.room.on('message', (message) => callback(JSONR.parse(message) as RoomMessage))
}
return this
}
@ -85,15 +86,11 @@ class PeerRoom extends EventHub {
if (!this.room) {
this.emit('error', new Error('Room not joined'))
} else {
this.room.onPeerJoin((peerId) => {
callback(peerId)
})
this.room.on('join', (id) => callback(id))
}
})
} else {
this.room.onPeerJoin((peerId) => {
callback(peerId)
})
this.room.on('join', (id) => callback(id))
}
return this
}
@ -104,11 +101,11 @@ class PeerRoom extends EventHub {
if (!this.room) {
this.emit('error', new Error('Room not joined'))
} else {
this.room.onPeerLeave((peerId) => callback(peerId))
this.room.on('leave', (id) => callback(id))
}
})
} else {
this.room.onPeerLeave((peerId) => callback(peerId))
this.room.on('leave', (id) => callback(id))
}
return this
}
@ -129,18 +126,15 @@ class PeerRoom extends EventHub {
}
return this
}
onError(callback: (error: Error) => void) {
this.peer?.on('error', (error) => callback(error))
this.on('error', (error: Error) => callback(error))
return this
}
}
const hostRoomId = stringToHex(document.location.host)
const peerRoom = new PeerRoom({ roomId: hostRoomId })
const hostRoomId = stringToHex(VIRTUAL_ROOM_ID)
export const PeerRoomImpl = PeerRoomExtern.impl(peerRoom)
const virtualRoom = new VirtualRoom({ roomId: hostRoomId, peer: Peer.createInstance() })
// https://github.com/w3c/webextensions/issues/72
// https://issues.chromium.org/issues/40251342
// https://github.com/w3c/webrtc-extensions/issues/77
export const VirtualRoomImpl = VirtualRoomExtern.impl(virtualRoom)

View file

@ -1,18 +1,7 @@
import { type RemeshEvent, type RemeshAction, type RemeshDomainContext, type RemeshExtern } from 'remesh'
import { defer, from, fromEventPattern, map, Observable, switchMap } from 'rxjs'
import { type Promisable } from 'type-fest'
import { from, map, Observable, switchMap } from 'rxjs'
export type StorageValue = null | string | number | boolean | object
export type WatchEvent = 'update' | 'remove'
export type WatchCallback = (event: WatchEvent, key: string) => any
export type Unwatch = () => Promisable<void>
export interface Storage {
get: <T extends StorageValue>(key: string) => Promise<T | null>
set: <T extends StorageValue>(key: string, value: T) => Promise<void>
watch: (callback: WatchCallback) => Promise<Unwatch>
unwatch?: Unwatch
}
import { Storage, StorageValue } from '@/domain/externs/Storage'
export interface Options {
domain: RemeshDomainContext
@ -60,20 +49,14 @@ export default class StorageEffect {
this.domain.effect({
name: 'WatchStorageToStateEffect',
impl: () => {
return defer(() => {
let unwatch: Unwatch
return new Observable<void>((observer) => {
this.storage
.watch(() => observer.next())
.then((_unwatch) => {
unwatch = _unwatch
})
return () => unwatch?.()
}).pipe(
switchMap(() => from(this.storage.get<T | null>(this.key))),
map(callback)
)
})
// TODO: Report the bug to https://github.com/unjs/unstorage
return new Observable((observer) => {
const unwatchPromise = this.storage.watch(() => observer.next())
return () => unwatchPromise.then((unwatch) => unwatch())
}).pipe(
switchMap(() => from(this.storage.get<T | null>(this.key))),
map(callback)
)
}
})
return this

View file

@ -6,53 +6,92 @@ export interface ToastOptions {
}
const ToastModule = (domain: RemeshDomainContext, options: ToastOptions = { name: 'MessageToastModule' }) => {
const toast = domain.getExtern(ToastExtern)
const toastExtern = domain.getExtern(ToastExtern)
const SuccessEvent = domain.event({
const SuccessEvent = domain.event<number | string>({
name: `${options.name}.SuccessEvent`
})
const SuccessCommand = domain.command({
name: `${options.name}.SuccessCommand`,
impl: (_, message: string) => {
toast.success(message)
return [SuccessEvent()]
impl: (_, message: string | { message: string; duration?: number }) => {
const id = toastExtern.success(
typeof message === 'string' ? message : message.message,
typeof message === 'string' ? undefined : message.duration
)
return [SuccessEvent(id)]
}
})
const ErrorEvent = domain.event({
const ErrorEvent = domain.event<number | string>({
name: `${options.name}.ErrorEvent`
})
const ErrorCommand = domain.command({
name: `${options.name}.ErrorCommand`,
impl: (_, message: string) => {
toast.error(message)
return [ErrorEvent()]
impl: (_, message: string | { message: string; duration?: number }) => {
const id = toastExtern.error(
typeof message === 'string' ? message : message.message,
typeof message === 'string' ? undefined : message.duration
)
return [ErrorEvent(id)]
}
})
const InfoEvent = domain.event({
const InfoEvent = domain.event<number | string>({
name: `${options.name}.InfoEvent`
})
const InfoCommand = domain.command({
name: `${options.name}.InfoCommand`,
impl: (_, message: string) => {
toast.info(message)
return [InfoEvent()]
impl: (_, message: string | { message: string; duration?: number }) => {
const id = toastExtern.info(
typeof message === 'string' ? message : message.message,
typeof message === 'string' ? undefined : message.duration
)
return [InfoEvent(id)]
}
})
const WarningEvent = domain.event({
const WarningEvent = domain.event<number | string>({
name: `${options.name}.WarningEvent`
})
const WarningCommand = domain.command({
name: `${options.name}.WarningCommand`,
impl: (_, message: string) => {
toast.warning(message)
return [WarningEvent()]
impl: (_, message: string | { message: string; duration?: number }) => {
const id = toastExtern.warning(
typeof message === 'string' ? message : message.message,
typeof message === 'string' ? undefined : message.duration
)
return [WarningEvent(id)]
}
})
const LoadingEvent = domain.event<number | string>({
name: `${options.name}.LoadingEvent`
})
const LoadingCommand = domain.command({
name: `${options.name}.LoadingCommand`,
impl: (_, message: string | { message: string; duration?: number }) => {
const id = toastExtern.loading(
typeof message === 'string' ? message : message.message,
typeof message === 'string' ? undefined : message.duration
)
return [LoadingEvent(id)]
}
})
const CancelEvent = domain.event<number | string>({
name: `${options.name}.CancelEvent`
})
const CancelCommand = domain.command({
name: `${options.name}.CancelCommand`,
impl: (_, id: number | string) => {
toastExtern.cancel(id)
return [CancelEvent(id)]
}
})
@ -61,13 +100,17 @@ const ToastModule = (domain: RemeshDomainContext, options: ToastOptions = { name
SuccessEvent,
ErrorEvent,
InfoEvent,
WarningEvent
WarningEvent,
LoadingEvent,
CancelEvent
},
command: {
SuccessCommand,
ErrorCommand,
InfoCommand,
WarningCommand
WarningCommand,
LoadingCommand,
CancelCommand
}
}
}

View file

@ -1,51 +0,0 @@
import { type RefObject, useEffect, useRef } from 'react'
export type Events = Array<keyof GlobalEventHandlersEventMap>
/**
* Waiting for PR merge
* @see https://github.com/streamich/react-use/pull/2528
*/
const useClickAway = <E extends Event = Event>(
ref: RefObject<HTMLElement | null>,
onClickAway: (event: E) => void,
events: Events = ['mousedown', 'touchstart']
) => {
const savedCallback = useRef(onClickAway)
useEffect(() => {
savedCallback.current = onClickAway
}, [onClickAway])
useEffect(() => {
const { current: el } = ref
if (!el) return
const rootNode = el.getRootNode()
const isInShadow = rootNode instanceof ShadowRoot
/**
* When events are captured outside the component, events that occur in shadow DOM will target the host element
* so additional event listeners need to be added for shadowDom
*
* document shadowDom target
* | | |
* |- on(document) -|- on(shadowRoot) -|
*/
const handler = (event: SafeAny) => {
!el.contains(event.target) && event.target.shadowRoot !== rootNode && savedCallback.current(event)
}
for (const eventName of events) {
// eslint-disable-next-line @eslint-react/web-api/no-leaked-event-listener
document.addEventListener(eventName, handler)
// eslint-disable-next-line @eslint-react/web-api/no-leaked-event-listener
isInShadow && rootNode.addEventListener(eventName, handler)
}
return () => {
for (const eventName of events) {
document.removeEventListener(eventName, handler)
isInShadow && rootNode.removeEventListener(eventName, handler)
}
}
}, [events, ref])
}
export default useClickAway

View file

@ -0,0 +1,43 @@
import { RefCallback, useCallback, useRef, useState } from 'react'
import getCursorPosition, { Position } from '@/utils/getCursorPosition'
const useCursorPosition = () => {
const [position, setPosition] = useState<Position>({ x: 0, y: 0, selectionStart: 0, selectionEnd: 0 })
const handler = async (e: Event) => {
const newPosition = await getCursorPosition(e.target as HTMLInputElement | HTMLTextAreaElement)
if (JSON.stringify(newPosition) !== JSON.stringify(position)) {
setPosition(newPosition)
}
}
const handleRef = useRef<HTMLInputElement | HTMLTextAreaElement | null>(null)
const setRef: RefCallback<HTMLInputElement | HTMLTextAreaElement | null> = useCallback(
(node) => {
if (handleRef.current) {
handleRef.current.removeEventListener('click', handler)
handleRef.current.removeEventListener('input', handler)
handleRef.current.removeEventListener('keydown', handler)
handleRef.current.removeEventListener('keyup', handler)
handleRef.current.removeEventListener('focus', handler)
}
if (node) {
node.addEventListener('click', handler)
node.addEventListener('input', handler)
node.addEventListener('keydown', handler)
node.addEventListener('keyup', handler)
node.addEventListener('focus', handler)
}
handleRef.current = node
},
[handler]
)
return {
...position,
setRef
}
}
export default useCursorPosition

94
src/hooks/useDraggable.ts Normal file
View file

@ -0,0 +1,94 @@
import { clamp, isInRange } from '@/utils'
import { useCallback, useLayoutEffect, useRef, useState } from 'react'
export interface DargOptions {
initX: number
initY: number
maxX: number
minX: number
maxY: number
minY: number
}
const useDraggable = (options: DargOptions) => {
const { initX, initY, maxX = 0, minX = 0, maxY = 0, minY = 0 } = options
const mousePosition = useRef({ x: 0, y: 0 })
const [position, setPosition] = useState({ x: clamp(initX, minX, maxX), y: clamp(initY, minY, maxY) })
useLayoutEffect(() => {
const newPosition = { x: clamp(initX, minX, maxX), y: clamp(initY, minY, maxY) }
if (JSON.stringify(newPosition) !== JSON.stringify(position)) {
setPosition(newPosition)
}
}, [initX, initY, maxX, minX, maxY, minY])
const isMove = useRef(false)
const handleMove = useCallback(
(e: MouseEvent) => {
if (isMove.current) {
const { clientX, clientY } = e
const delta = {
x: position.x + clientX - mousePosition.current.x,
y: position.y + clientY - mousePosition.current.y
}
const hasChanged = delta.x !== position.x || delta.y !== position.y
if (isInRange(delta.x, minX, maxX)) {
mousePosition.current.x = clientX
}
if (isInRange(delta.y, minY, maxY)) {
mousePosition.current.y = clientY
}
if (hasChanged) {
setPosition(() => {
const x = clamp(delta.x, minX, maxX)
const y = clamp(delta.y, minY, maxY)
return { x, y }
})
}
}
},
[minX, maxX, minY, maxY, position]
)
const handleEnd = useCallback(() => {
isMove.current = false
document.documentElement.style.cursor = ''
document.documentElement.style.userSelect = ''
}, [])
const handleStart = useCallback((e: MouseEvent) => {
const { clientX, clientY } = e
mousePosition.current = { x: clientX, y: clientY }
isMove.current = true
document.documentElement.style.userSelect = 'none'
document.documentElement.style.cursor = 'grab'
}, [])
const handleRef = useRef<HTMLElement | null>(null)
const setRef = useCallback(
(node: HTMLElement | null) => {
if (handleRef.current) {
handleRef.current.removeEventListener('mousedown', handleStart)
document.removeEventListener('mouseup', handleEnd)
document.removeEventListener('mousemove', handleMove)
}
if (node) {
node.addEventListener('mousedown', handleStart)
document.addEventListener('mouseup', handleEnd)
document.addEventListener('mousemove', handleMove)
}
handleRef.current = node
},
[handleEnd, handleMove, handleStart]
)
return { setRef, ...position }
}
export default useDraggable

View file

@ -1,4 +1,4 @@
import { useCallback, useRef, useState } from 'react'
import { RefCallback, useCallback, useLayoutEffect, useRef, useState } from 'react'
import { clamp, isInRange } from '@/utils'
export interface ResizableOptions {
@ -11,7 +11,14 @@ export interface ResizableOptions {
const useResizable = (options: ResizableOptions) => {
const { minSize, maxSize, initSize = 0, direction } = options
const [size, setSize] = useState(initSize)
const [size, setSize] = useState(clamp(initSize, minSize, maxSize))
useLayoutEffect(() => {
const newSize = clamp(initSize, minSize, maxSize)
if (newSize !== size) {
setSize(newSize)
}
}, [initSize, minSize, maxSize])
const position = useRef(0)
@ -67,13 +74,13 @@ const useResizable = (options: ResizableOptions) => {
[isHorizontal]
)
const ref = useRef<HTMLElement | null>(null)
const handlerRef = useRef<HTMLElement | null>(null)
// Watch ref: https://medium.com/@teh_builder/ref-objects-inside-useeffect-hooks-eb7c15198780
const setRef = useCallback(
(node: HTMLElement | null) => {
if (ref.current) {
ref.current.removeEventListener('mousedown', handleStart)
const setRef: RefCallback<HTMLElement | null> = useCallback(
(node) => {
if (handlerRef.current) {
handlerRef.current.removeEventListener('mousedown', handleStart)
document.removeEventListener('mouseup', handleEnd)
document.removeEventListener('mousemove', handleMove)
}
@ -82,12 +89,12 @@ const useResizable = (options: ResizableOptions) => {
document.addEventListener('mouseup', handleEnd)
document.addEventListener('mousemove', handleMove)
}
ref.current = node
handlerRef.current = node
},
[handleEnd, handleMove, handleStart]
)
return { size, ref: setRef }
return { size, setRef }
}
export default useResizable

21
src/hooks/useShareRef.ts Normal file
View file

@ -0,0 +1,21 @@
import { ForwardedRef, MutableRefObject, RefCallback, useCallback } from 'react'
const useShareRef = <T extends HTMLElement | null>(
...refs: (MutableRefObject<T> | ForwardedRef<T> | RefCallback<T>)[]
) => {
const setRef = useCallback(
(node: T) =>
refs.forEach((ref) => {
if (typeof ref === 'function') {
ref(node)
} else if (ref) {
ref.current = node
}
}),
[...refs]
)
return setRef
}
export default useShareRef

View file

@ -0,0 +1,52 @@
import { RefCallback, useCallback, useRef } from 'react'
export type Events = Array<keyof GlobalEventHandlersEventMap>
/**
* @see https://github.com/streamich/react-use/pull/2528
*/
const useTriggerAway = <E extends Event = Event>(events: Events, callback: (event: E) => void) => {
const handleRef = useRef<HTMLElement | null>(null)
const handler = (event: SafeAny) => {
const rootNode = handleRef.current?.getRootNode()
!handleRef.current?.contains(event.target) && event.target.shadowRoot !== rootNode && callback(event)
}
/**
* When events are captured outside the component, events that occur in shadow DOM will target the host element
* so additional event listeners need to be added for shadowDom
*
* document shadowDom target
* | | |
* |- on(document) -|- on(shadowRoot) -|
*/
const setRef: RefCallback<HTMLElement | null> = useCallback(
(node) => {
if (handleRef.current) {
const rootNode = handleRef.current.getRootNode()
const isInShadow = rootNode instanceof ShadowRoot
events.forEach(() => {
for (const eventName of events) {
document.removeEventListener(eventName, handler)
isInShadow && rootNode.removeEventListener(eventName, handler)
}
})
}
if (node) {
const rootNode = node.getRootNode()
const isInShadow = rootNode instanceof ShadowRoot
events.forEach((eventName) => {
document.addEventListener(eventName, handler)
isInShadow && rootNode.addEventListener(eventName, handler)
})
}
handleRef.current = node
},
[handler]
)
return { setRef }
}
export default useTriggerAway

View file

@ -0,0 +1,22 @@
import { useEffect, useState } from 'react'
const useWindowResize = (callback?: ({ width, height }: { width: number; height: number }) => void) => {
const [size, setSize] = useState({ width: window.innerWidth, height: window.innerHeight })
useEffect(() => {
const handler = () => {
const width = window.innerWidth
const height = window.innerHeight
setSize({ width, height })
callback?.({ width, height })
}
window.addEventListener('resize', handler)
return () => {
window.removeEventListener('resize', handler)
}
}, [])
return size
}
export default useWindowResize

11
src/messenger/index.ts Normal file
View file

@ -0,0 +1,11 @@
import { EVENT } from '@/constants/event'
import { defineExtensionMessaging } from '@webext-core/messaging'
import { TextMessage } from '@/domain/ChatRoom'
interface ProtocolMap {
[EVENT.OPTIONS_PAGE_OPEN]: () => void
[EVENT.NOTIFICATION_PUSH]: (message: TextMessage) => void
[EVENT.NOTIFICATION_CLEAR]: (id: string) => void
}
export const messenger = defineExtensionMessaging<ProtocolMap>()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 987 KiB

20
src/types/shim.d.ts vendored
View file

@ -1,17 +1,7 @@
// issues: https://github.com/facebook/react/issues/17157
// issues: https://github.com/facebook/react/pull/24730
declare module 'react' {
interface HTMLAttributes<T> extends AriaAttributes, DOMAttributes<T> {
inert?: boolean | undefined | ''
}
}
declare module '*.svg' {
import * as React from 'react'
declare global {
namespace JSX {
interface IntrinsicAttributes {
inert?: boolean | undefined | ''
}
}
}
const ReactComponent: React.FunctionComponent<React.ComponentProps<'svg'> & { title?: string }>
export {}
export default ReactComponent
}

9
src/utils/asyncMap.ts Normal file
View file

@ -0,0 +1,9 @@
const asyncMap = async <T = any, U = any>(list: T[], run: (arg: T, index: number, list: T[]) => Promise<U>) => {
const task: U[] = []
for (let index = 0; index < list.length; index++) {
task.push(await run(list[index], index, list))
}
return task
}
export default asyncMap

10
src/utils/blobToBase64.ts Normal file
View file

@ -0,0 +1,10 @@
const blobToBase64 = (blob: Blob) => {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = (e) => resolve(e.target?.result as string)
reader.onerror = () => reject(new Error('Failed to read file.'))
reader.readAsDataURL(blob)
})
}
export default blobToBase64

View file

@ -0,0 +1,11 @@
const checkDarkMode = () => {
const colorScheme = document.documentElement.style.getPropertyValue('color-scheme').trim()
if (colorScheme === 'dark') {
return true // Prefer the website's color-scheme property value
}
return window.matchMedia('(prefers-color-scheme: dark)').matches // Otherwise, check the system theme
}
export default checkDarkMode

View file

@ -1,3 +0,0 @@
const checkSystemDarkMode = () => window.matchMedia('(prefers-color-scheme: dark)').matches
export default checkSystemDarkMode

View file

@ -59,15 +59,21 @@ const compress = async (
const compressImage = async (options: Options) => {
const { input, targetSize, toleranceSize = -1024 } = options
if (!['image/jpeg', 'image/png', 'image/webp'].includes(input.type)) {
throw new Error('Invalid input type, only support image/jpeg, image/png, image/webp')
throw new Error('Only PNG, JPEG and WebP image are supported.')
}
if (input.size <= targetSize) {
return input
if (toleranceSize % 1024 !== 0) {
throw new Error('Tolerance size must be a multiple of 1024.')
}
const outputType = options.outputType || (input.type as ImageType)
if (input.size <= targetSize && input.type === outputType) {
return input
}
const imageBitmap = await createImageBitmap(input)
// Initialize quality range

View file

@ -1,7 +1,7 @@
import generateUglyAvatar from '@/lib/uglyAvatar'
import compressImage from './compressImage'
import compressImage, { ImageType } from './compressImage'
const generateRandomAvatar = async (targetSize: number) => {
const generateRandomAvatar = async (targetSize: number, outputType: ImageType = 'image/webp') => {
const svgBlob = generateUglyAvatar()
// compressImage can't directly compress svg, need to convert to jpeg first
@ -11,13 +11,13 @@ const generateRandomAvatar = async (targetSize: number) => {
const canvas = new OffscreenCanvas(image.width, image.height)
const ctx = canvas.getContext('2d')
ctx?.drawImage(image, 0, 0)
const blob = await canvas.convertToBlob({ type: 'image/jpeg' })
const blob = await canvas.convertToBlob({ type: outputType })
resolve(blob)
}
image.onerror = () => reject(new Error('Failed to load SVG'))
image.src = URL.createObjectURL(svgBlob)
})
const miniAvatarBlob = await compressImage({ input: imageBlob, targetSize })
const miniAvatarBlob = await compressImage({ input: imageBlob, targetSize, outputType })
const miniAvatarBase64 = await new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = (e) => resolve(e.target?.result as string)

View file

@ -0,0 +1,74 @@
import { createElement } from '@/utils'
export interface Position {
x: number
y: number
selectionStart: number
selectionEnd: number
}
const getCursorPosition = (target: HTMLInputElement | HTMLTextAreaElement) => {
return new Promise<Position>((resolve, reject) =>
requestIdleCallback(() => {
try {
const value = target.value
const inputWrapper = createElement<HTMLDivElement>(
`<div style="position: fixed; z-index: calc(-infinity); width: 0; height: 0; overflow: hidden; visibility: hidden; pointer-events: none;"></div>`
// `<div id="input-wrapper" style="position: fixed"></div>`
)
const copyInput = createElement<HTMLDivElement>(`<div contenteditable></div>`)
inputWrapper.appendChild(copyInput)
target.ownerDocument.body.appendChild(inputWrapper)
const { left, top, width, height } = target.getBoundingClientRect()
const isEmptyOrBreakEnd = /(\n|\s*$)/.test(value)
copyInput.textContent = isEmptyOrBreakEnd ? `${value}\u200b` : value
const copyStyle = getComputedStyle(target)
for (const key of copyStyle) {
Reflect.set(copyInput.style, key, copyStyle[key as keyof CSSStyleDeclaration])
}
if (target.tagName === 'INPUT') {
copyInput.style.lineHeight = copyStyle.height
}
copyInput.style.overflow = 'auto'
copyInput.style.width = `${width}px`
copyInput.style.height = `${height}px`
copyInput.style.boxSizing = 'border-box'
copyInput.style.margin = '0'
copyInput.style.position = 'fixed'
copyInput.style.top = `${top}px`
copyInput.style.left = `${left}px`
copyInput.style.pointerEvents = 'none'
// sync scroll
copyInput.scrollTop = target.scrollTop
copyInput.scrollLeft = target.scrollLeft
const selectionStart = target.selectionStart!
const selectionEnd = target.selectionEnd!
const range = new Range()
range.setStart(copyInput.childNodes[0], selectionStart)
range.setEnd(copyInput.childNodes[0], isEmptyOrBreakEnd ? selectionEnd + 1 : selectionEnd)
const { x, y } = range.getBoundingClientRect()
target.ownerDocument.body.removeChild(inputWrapper)
resolve({ x, y, selectionStart, selectionEnd })
} catch (error) {
reject(error)
}
})
)
}
export default getCursorPosition

5
src/utils/getRootNode.ts Normal file
View file

@ -0,0 +1,5 @@
export const getRootNode = () => {
return document.querySelector(__NAME__)?.shadowRoot?.querySelector('#app') || document.body
}
export default getRootNode

View file

@ -1,3 +1,5 @@
import { buildFullURL } from '@/utils'
export interface SiteInfo {
host: string
hostname: string
@ -8,6 +10,21 @@ export interface SiteInfo {
description: string
}
const getIcon = (): string => {
const path =
document.querySelector('link[rel="icon" i]')?.getAttribute('href') ??
document.querySelector('link[rel="shortcut icon" i]')?.getAttribute('href') ??
document.querySelector('meta[property="og:image" i]')?.getAttribute('content') ??
document.querySelector('link[rel="apple-touch-icon" i]')?.getAttribute('href') ??
`/favicon.ico`
if (path.startsWith('data:') || path.startsWith('//')) {
return path
} else {
return buildFullURL(document.location.origin, path)
}
}
const getSiteInfo = (): SiteInfo => {
return {
host: document.location.host,
@ -15,15 +32,10 @@ const getSiteInfo = (): SiteInfo => {
href: document.location.href,
origin: document.location.origin,
title:
document.querySelector('meta[rel="og:title i"]')?.getAttribute('content') ??
document.querySelector('meta[rel="og:title i"]')?.getAttribute('content') ??
document.querySelector('meta[rel="og:site_name i"]')?.getAttribute('content') ??
document.querySelector('meta[property="og:site_name" i]')?.getAttribute('content') ??
document.querySelector('meta[property="og:title" i]')?.getAttribute('content') ??
document.title,
icon:
document.querySelector('meta[property="og:image" i]')?.getAttribute('href') ??
document.querySelector('link[rel="icon" i]')?.getAttribute('href') ??
document.querySelector('link[rel="shortcut icon" i]')?.getAttribute('href') ??
`${document.location.origin}/favicon.ico`,
icon: getIcon(),
description:
document.querySelector('meta[property="og:description i"]')?.getAttribute('content') ??
document.querySelector('meta[name="description" i]')?.getAttribute('content') ??

Some files were not shown because too many files have changed in this diff Show more