Compare commits

..

59 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
59 changed files with 2674 additions and 3809 deletions

View file

@ -1,3 +1,107 @@
## [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)

View file

@ -12,14 +12,13 @@ This is an anonymous chat browser extension that is decentralized and serverless
The aim is to add chat room functionality to any website, you'll never feel alone again.
### Install
**Install from Store**
- [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/)
**Manual Installation**
@ -34,11 +33,9 @@ The aim is to add chat room functionality to any website, you'll never feel alon
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/34890975-5926-4e38-9a5f-34a28e17ff36
https://github.com/user-attachments/assets/e7ac9b8e-1b6c-43fb-8469-7a0a2c09d450
### Standing on the Shoulders of Giants
@ -50,7 +47,8 @@ In addition to the good idea of decentralized chat, it also leverages some fanta
- **[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.
- ~~**[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.

View file

@ -1,7 +1,7 @@
{
"name": "web-chat",
"displayName": "WebChat",
"version": "1.5.0",
"version": "1.7.1",
"description": "Chat with anyone on any website.",
"type": "module",
"scripts": {
@ -45,38 +45,39 @@
"homepage": "https://github.com/molvqingtai/WebChat",
"dependencies": {
"@hookform/resolvers": "^3.9.1",
"@lottiefiles/dotlottie-react": "^0.9.2",
"@number-flow/react": "^0.3.2",
"@perfsee/jsonr": "^1.13.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.0",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.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.0",
"@radix-ui/react-scroll-area": "^1.2.1",
"@radix-ui/react-slot": "^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",
"@webext-core/messaging": "^2.0.2",
"@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.11.10",
"framer-motion": "^11.11.17",
"idb-keyval": "^6.2.1",
"lucide-react": "^0.454.0",
"lucide-react": "^0.456.0",
"nanoid": "^5.0.8",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.53.1",
"react-hook-form": "^7.53.2",
"react-markdown": "^9.0.1",
"react-use": "^17.5.1",
"react-virtuoso": "^4.12.0",
@ -86,52 +87,51 @@
"remesh-logger": "^4.1.0",
"remesh-react": "^4.1.2",
"rxjs": "^7.8.1",
"sonner": "^1.5.0",
"sonner": "^1.7.0",
"tailwind-merge": "^2.5.4",
"trystero": "^0.20.0",
"type-fest": "^4.26.1",
"unstorage": "1.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.15.2",
"@eslint/js": "^9.13.0",
"@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__js": "^8.42.3",
"@types/eslint-plugin-tailwindcss": "^3.17.0",
"@types/node": "^22.8.2",
"@types/node": "^22.9.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@typescript-eslint/parser": "^8.12.1",
"@typescript-eslint/parser": "^8.14.0",
"@vitejs/plugin-react": "^4.3.3",
"autoprefixer": "^10.4.20",
"cross-env": "^7.0.3",
"eslint": "^9.13.0",
"eslint": "^9.14.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1",
"eslint-plugin-tailwindcss": "^3.17.5",
"globals": "^15.11.0",
"globals": "^15.12.0",
"husky": "^9.1.6",
"jiti": "^2.3.3",
"jiti": "^2.4.0",
"lint-staged": "^15.2.10",
"npm-run-all": "^4.1.5",
"postcss": "^8.4.47",
"postcss": "^8.4.49",
"postcss-rem-to-responsive-pixel": "^6.0.2",
"prettier": "^3.3.3",
"rimraf": "^6.0.1",
"semantic-release": "^24.2.0",
"tailwindcss": "^3.4.14",
"tailwindcss": "^3.4.15",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.6.3",
"typescript-eslint": "^8.12.1",
"vite-plugin-svgr": "^4.2.0",
"typescript-eslint": "^8.14.0",
"vite-plugin-svgr": "^4.3.0",
"webext-bridge": "^6.0.1",
"wxt": "^0.19.13"
"wxt": "^0.19.15"
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": "eslint --fix --flag unstable_ts_config"

3592
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -1,21 +1,22 @@
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 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, useRef } from 'react'
import { Toaster } from 'sonner'
import { AnimatePresence, motion } from 'framer-motion'
import DanmakuContainer from './components/DanmakuContainer'
import DanmakuDomain from '@/domain/Danmaku'
import AppStatusDomain from '@/domain/AppStatus'
import { cn } from '@/utils'
import { checkDarkMode, cn } from '@/utils'
import VirtualRoomDomain from '@/domain/VirtualRoom'
/**
* Fix requestAnimationFrame error in jest
@ -28,7 +29,8 @@ if (import.meta.env.FIREFOX) {
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())
@ -38,23 +40,34 @@ export default function App() {
const userInfoLoadFinished = useRemeshQuery(userInfoDomain.query.UserInfoLoadIsFinishedQuery())
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 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 danmakuContainerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
danmakuIsEnabled && send(danmakuDomain.command.MountCommand(danmakuContainerRef.current!))
return () => {
@ -62,31 +75,48 @@ export default function App() {
}
}, [danmakuIsEnabled])
return (
appStatusLoadIsFinished && (
<div id="app" className={cn('contents', userInfo?.themeMode)}>
<AppMain>
<Header />
<Main />
<Footer />
<AnimatePresence>
{notUserInfo && (
<motion.div
className="contents"
initial={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
>
<Setup></Setup>
</motion.div>
)}
</AnimatePresence>
<Toaster richColors offset="70px" visibleToasts={1} position="top-center"></Toaster>
</AppMain>
<AppButton></AppButton>
useEffect(() => {
window.addEventListener('beforeunload', leaveRoom)
return () => {
window.removeEventListener('beforeunload', leaveRoom)
}
}, [])
<DanmakuContainer ref={danmakuContainerRef} />
</div>
)
const themeMode =
userInfo?.themeMode === 'system'
? checkDarkMode()
? 'dark'
: 'light'
: (userInfo?.themeMode ?? (checkDarkMode() ? 'dark' : 'light'))
const danmakuContainerRef = useRef<HTMLDivElement>(null)
return (
<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

@ -1,6 +1,6 @@
import { Avatar, AvatarFallback } from '@/components/ui/Avatar'
import { Button } from '@/components/ui/Button'
import { TextMessage } from '@/domain/Room'
import { TextMessage } from '@/domain/ChatRoom'
import { cn } from '@/utils'
import { AvatarImage } from '@radix-ui/react-avatar'
import { FC, MouseEvent } from 'react'

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
@ -40,7 +41,11 @@ const LikeButton: FC<LikeButtonProps> & { Icon: FC<LikeButtonIconProps> } = ({
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,4 +1,4 @@
import { forwardRef, type ChangeEvent, CompositionEvent, type KeyboardEvent } from 'react'
import { forwardRef, type ChangeEvent, CompositionEvent, type KeyboardEvent, ClipboardEvent } from 'react'
import { cn } from '@/utils'
import { Textarea } from '@/components/ui/Textarea'
@ -14,6 +14,7 @@ export interface MessageInputProps {
disabled?: boolean
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
@ -31,6 +32,7 @@ const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(
className,
maxLength = 500,
onInput,
onPaste,
onKeyDown,
onCompositionStart,
onCompositionEnd,
@ -45,6 +47,7 @@ const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(
<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}

View file

@ -1,5 +1,5 @@
import { type FC } from 'react'
import { FrownIcon, ThumbsUpIcon } from 'lucide-react'
import { FrownIcon, HeartIcon } from 'lucide-react'
import LikeButton from './LikeButton'
import FormatDate from './FormatDate'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/Avatar'
@ -58,7 +58,7 @@ const MessageItem: FC<MessageItemProps> = (props) => {
<div className="overflow-hidden">
<div className="grid grid-cols-[1fr_auto] items-center gap-x-2 leading-none">
<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.date}></FormatDate>
<FormatDate className="text-xs text-slate-400 dark:text-slate-100" date={props.data.sendTime}></FormatDate>
</div>
<div>
<div className="pb-2">
@ -71,7 +71,7 @@ const MessageItem: FC<MessageItemProps> = (props) => {
count={props.data.likeUsers.length}
>
<LikeButton.Icon>
<ThumbsUpIcon size={14}></ThumbsUpIcon>
<HeartIcon size={14}></HeartIcon>
</LikeButton.Icon>
</LikeButton>
<LikeButton

View file

@ -11,10 +11,12 @@ import { LocalStorageImpl, IndexDBStorageImpl, BrowserSyncStorageImpl } from '@/
import { DanmakuImpl } from '@/domain/impls/Danmaku'
import { NotificationImpl } from '@/domain/impls/Notification'
import { ToastImpl } from '@/domain/impls/Toast'
// import { PeerRoomImpl } from '@/domain/impls/PeerRoom'
import { PeerRoomImpl } from '@/domain/impls/PeerRoom2'
import '@/assets/styles/tailwind.css'
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'
@ -36,7 +38,8 @@ export default defineContentScript({
LocalStorageImpl,
IndexDBStorageImpl,
BrowserSyncStorageImpl,
PeerRoomImpl,
ChatRoomImpl,
VirtualRoomImpl,
ToastImpl,
DanmakuImpl,
NotificationImpl

View file

@ -7,7 +7,7 @@ import { Button } from '@/components/ui/Button'
import { EVENT } from '@/constants/event'
import UserInfoDomain from '@/domain/UserInfo'
import useTriggerAway from '@/hooks/useTriggerAway'
import { checkSystemDarkMode, cn } from '@/utils'
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'
@ -18,7 +18,7 @@ import LogoIcon6 from '@/assets/images/logo-6.svg'
import AppStatusDomain from '@/domain/AppStatus'
import { getDay } from 'date-fns'
import { messenger } from '@/messenger'
import useDarg from '@/hooks/useDarg'
import useDraggable from '@/hooks/useDraggable'
import useWindowResize from '@/hooks/useWindowResize'
export interface AppButtonProps {
@ -36,8 +36,7 @@ const AppButton: FC<AppButtonProps> = ({ className }) => {
const DayLogo = [LogoIcon0, LogoIcon1, LogoIcon2, LogoIcon3, LogoIcon4, LogoIcon5, LogoIcon6][getDay(Date())]
const isDarkMode =
userInfo?.themeMode === 'dark' ? true : userInfo?.themeMode === 'light' ? false : checkSystemDarkMode()
const isDarkMode = userInfo?.themeMode === 'dark' ? true : userInfo?.themeMode === 'light' ? false : checkDarkMode()
const [menuOpen, setMenuOpen] = useState(false)
@ -45,13 +44,13 @@ const AppButton: FC<AppButtonProps> = ({ className }) => {
x,
y,
setRef: appButtonRef
} = useDarg({
} = useDraggable({
initX: appPosition.x,
initY: appPosition.y,
minX: 50,
maxX: window.innerWidth - 50,
maxY: window.innerHeight - 22,
minY: window.innerHeight / 2
minY: 750
})
useWindowResize(({ width, height }) => {
@ -107,7 +106,7 @@ const AppButton: FC<AppButtonProps> = ({ className }) => {
<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(
@ -121,10 +120,18 @@ const AppButton: FC<AppButtonProps> = ({ className }) => {
</div>
</Button>
<Button onClick={handleOpenOptionsPage} variant="outline" className="size-10 rounded-full p-0 shadow">
<Button
onClick={handleOpenOptionsPage}
variant="outline"
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">
<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>

View file

@ -1,12 +1,12 @@
import { ChangeEvent, useMemo, useRef, useState, KeyboardEvent, 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'
@ -15,7 +15,7 @@ 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, getTextSimilarity } from '@/utils'
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'
@ -25,12 +25,12 @@ import { nanoid } from 'nanoid'
const Footer: FC = () => {
const send = useRemeshSend()
const toastDomain = useRemeshDomain(ToastDomain())
const roomDomain = useRemeshDomain(RoomDomain())
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(roomDomain.query.UserListQuery())
const userList = useRemeshQuery(chatRoomDomain.query.UserListQuery())
const inputRef = useRef<HTMLTextAreaElement>(null)
const { x, y, selectionStart, selectionEnd, setRef } = useCursorPosition()
@ -136,7 +136,14 @@ const Footer: FC = () => {
})
.filter(Boolean)
send(roomDomain.command.SendTextMessageCommand({ body: transformedMessage, atUsers }))
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())
}
@ -216,6 +223,13 @@ const Footer: FC = () => {
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)}`
@ -236,7 +250,13 @@ const Footer: FC = () => {
const handleInjectImage = async (file: File) => {
try {
setInputLoading(true)
const blob = await compressImage({ input: file, targetSize: 30 * 1024, outputType: 'image/webp' })
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)}`
@ -323,7 +343,7 @@ const Footer: FC = () => {
<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">{user.username}</div>
<div className="flex-1 truncate text-xs text-slate-500 dark:text-slate-50">{user.username}</div>
</div>
)}
></Virtuoso>
@ -335,6 +355,7 @@ const Footer: FC = () => {
value={message}
onInput={handleInput}
loading={inputLoading}
onPaste={handlePaste}
onKeyDown={handleKeyDown}
maxLength={MESSAGE_MAX_LENGTH}
></MessageInput>

View file

@ -5,21 +5,44 @@ import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/H
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 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 [scrollParentRef, setScrollParentRef] = useState<HTMLDivElement | null>(null)
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 dark:bg-slate-950">
<Avatar className="size-8">
<Avatar className="size-8 rounded-sm">
<AvatarImage src={siteInfo.icon} alt="favicon" />
<AvatarFallback>
<Globe2Icon size="100%" className="text-gray-400" />
@ -27,60 +50,111 @@ const Header: FC = () => {
</Avatar>
<HoverCard>
<HoverCardTrigger asChild>
<Button className="overflow-hidden p-2" variant="link">
<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, '')}
</span>
</Button>
</HoverCardTrigger>
<HoverCardContent className="w-80 rounded-lg">
<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 dark:text-slate-300">
{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="p-0" variant="link">
<div className="flex items-center gap-x-1 text-nowrap text-xs 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 className="dark:text-slate-50">ONLINE {onlineCount > 99 ? '99+' : onlineCount}</span>
<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 className="max-h-[204px] min-h-9 p-1" ref={setScrollParentRef}>
<ScrollArea type="scroll" className="max-h-[204px] min-h-9 p-1" ref={setChatUserListScrollParentRef}>
<Virtuoso
data={userList}
data={chatUserList}
defaultItemHeight={28}
customScrollParent={scrollParentRef!}
itemContent={(index, user) => (
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" />

View file

@ -5,33 +5,35 @@ 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'
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 (

View file

@ -2,7 +2,7 @@ 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'
@ -33,15 +33,13 @@ 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'
@ -52,8 +50,9 @@ 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,
@ -72,8 +71,8 @@ const Setup: FC = () => {
const [userInfo, setUserInfo] = useState<UserInfo>()
const handleSetup = () => {
send(userInfoDomain.command.UpdateUserInfoCommand(userInfo!))
send(messageListDomain.command.ClearListCommand())
send(userInfoDomain.command.UpdateUserInfoCommand(userInfo!))
}
const refreshUserInfo = async () => {
@ -87,19 +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.on('stop', () => {
printTextList.length === 0 && send(messageListDomain.command.ClearListCommand())
})
timer.start()
return () => {
timer.stop()
send(messageListDomain.command.ClearListCommand())
}
}, [])

View file

@ -16,7 +16,15 @@ function App() {
<VersionLink></VersionLink>
<Main>
<ProfileForm></ProfileForm>
<Toaster richColors position="top-center" duration={1000000} />
<Toaster
richColors
position="top-center"
toastOptions={{
classNames: {
toast: 'dark:bg-slate-950 border dark:border-slate-600'
}
}}
/>
</Main>
<BadgeList></BadgeList>
</Layout>

View file

@ -9,7 +9,7 @@ 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, cn, 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'
@ -24,7 +24,7 @@ const defaultUserInfo: UserInfo = {
name: '',
avatar: '',
createTime: Date.now(),
themeMode: checkSystemDarkMode() ? 'dark' : 'system',
themeMode: 'system',
danmakuEnabled: true,
notificationEnabled: true,
notificationType: 'all'
@ -32,9 +32,9 @@ const defaultUserInfo: UserInfo = {
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
createTime: v.number(),
name: v.pipe(
v.string(),
v.trim(),

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,26 +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]) {
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

@ -73,6 +73,7 @@
* {
@apply border-border;
}
:host,
:root {
@apply !bg-background !text-foreground !text-base !visible;

View file

@ -5,11 +5,18 @@ export interface LinkProps {
href: string
className?: string
children: ReactNode
underline?: boolean
}
const Link = forwardRef<HTMLAnchorElement, LinkProps>(({ href, className, children }, ref) => {
const Link = forwardRef<HTMLAnchorElement, LinkProps>(({ href, className, children, underline = true }, ref) => {
return (
<a href={href} target={href} rel="noopener noreferrer" className={cn('hover:underline', className)} ref={ref}>
<a
href={href}
target={href}
rel="noopener noreferrer"
className={cn(underline && 'hover:underline', className)}
ref={ref}
>
{children}
</a>
)

View file

@ -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)}

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,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,12 +186,12 @@ 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 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
@ -198,3 +199,13 @@ export const APP_STATUS_STORAGE_KEY = 'WEB_CHAT_APP_STATUS' as const
* 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

@ -3,7 +3,7 @@ import StatusModule from './modules/Status'
import { LocalStorageExtern } from './externs/Storage'
import { APP_STATUS_STORAGE_KEY } from '@/constants/config'
import StorageEffect from './modules/StorageEffect'
import RoomDomain, { SendType } from './Room'
import ChatRoomDomain, { SendType } from '@/domain/ChatRoom'
import { map } from 'rxjs'
export interface AppStatus {
@ -26,7 +26,7 @@ const AppStatusDomain = Remesh.domain({
extern: LocalStorageExtern,
key: APP_STATUS_STORAGE_KEY
})
const roomDomain = domain.getDomain(RoomDomain())
const chatRoomDomain = domain.getDomain(ChatRoomDomain())
const StatusLoadModule = StatusModule(domain, {
name: 'AppStatus.LoadStatusModule'
@ -40,7 +40,7 @@ const AppStatusDomain = Remesh.domain({
})
const StatusState = domain.state<AppStatus>({
name: 'AppStatus.OpenState',
name: 'AppStatus.StatusState',
default: defaultStatusState
})
@ -131,7 +131,7 @@ const AppStatusDomain = Remesh.domain({
domain.effect({
name: 'OnMessageEffect',
impl: ({ fromEvent, get }) => {
const onMessage$ = fromEvent(roomDomain.event.OnMessageEvent).pipe(
const onMessage$ = fromEvent(chatRoomDomain.event.OnMessageEvent).pipe(
map((message) => {
const status = get(StatusState())
if (!status.open && message.type === SendType.Text) {

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

View file

@ -1,15 +1,15 @@
import { Remesh } from 'remesh'
import { DanmakuExtern } from './externs/Danmaku'
import RoomDomain, { TextMessage } from './Room'
import ChatRoomDomain, { TextMessage } from '@/domain/ChatRoom'
import UserInfoDomain from './UserInfo'
import { map, merge } from 'rxjs'
const DanmakuDomain = Remesh.domain({
name: 'DanmakuDomain',
impl: (domain) => {
const danmaku = domain.getExtern(DanmakuExtern)
const danmakuExtern = domain.getExtern(DanmakuExtern)
const userInfoDomain = domain.getDomain(UserInfoDomain())
const roomDomain = domain.getDomain(RoomDomain())
const chatRoomDomain = domain.getDomain(ChatRoomDomain())
const MountState = domain.state({
name: 'Danmaku.MountState',
@ -49,7 +49,7 @@ const DanmakuDomain = Remesh.domain({
const PushCommand = domain.command({
name: 'Danmaku.PushCommand',
impl: (_, message: TextMessage) => {
danmaku.push(message)
danmakuExtern.push(message)
return [PushEvent(message)]
}
})
@ -57,7 +57,7 @@ const DanmakuDomain = Remesh.domain({
const UnshiftCommand = domain.command({
name: 'Danmaku.UnshiftCommand',
impl: (_, message: TextMessage) => {
danmaku.unshift(message)
danmakuExtern.unshift(message)
return [UnshiftEvent(message)]
}
})
@ -65,7 +65,7 @@ const DanmakuDomain = Remesh.domain({
const ClearCommand = domain.command({
name: 'Danmaku.ClearCommand',
impl: () => {
danmaku.clear()
danmakuExtern.clear()
return [ClearEvent()]
}
})
@ -73,7 +73,7 @@ const DanmakuDomain = Remesh.domain({
const MountCommand = domain.command({
name: 'Danmaku.ClearCommand',
impl: (_, container: HTMLElement) => {
danmaku.mount(container)
danmakuExtern.mount(container)
return [MountEvent(container)]
}
})
@ -81,7 +81,7 @@ const DanmakuDomain = Remesh.domain({
const UnmountCommand = domain.command({
name: 'Danmaku.UnmountCommand',
impl: () => {
danmaku.unmount()
danmakuExtern.unmount()
return [UnmountEvent()]
}
})
@ -121,8 +121,8 @@ const DanmakuDomain = Remesh.domain({
domain.effect({
name: 'Danmaku.OnRoomMessageEffect',
impl: ({ fromEvent, get }) => {
const sendTextMessage$ = fromEvent(roomDomain.event.SendTextMessageEvent)
const onTextMessage$ = fromEvent(roomDomain.event.OnTextMessageEvent)
const sendTextMessage$ = fromEvent(chatRoomDomain.event.SendTextMessageEvent)
const onTextMessage$ = fromEvent(chatRoomDomain.event.OnTextMessageEvent)
const onMessage$ = merge(sendTextMessage$, onTextMessage$).pipe(
map((message) => {

View file

@ -24,7 +24,8 @@ export interface NormalMessage extends MessageUser {
type: MessageType.Normal
id: string
body: string
date: number
sendTime: number
receiveTime: number
likeUsers: MessageUser[]
hateUsers: MessageUser[]
atUsers: AtUser[]
@ -34,7 +35,8 @@ export interface PromptMessage extends MessageUser {
type: MessageType.Prompt
id: string
body: string
date: number
sendTime: number
receiveTime: number
}
export type Message = NormalMessage | PromptMessage
@ -120,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'
})
@ -164,14 +198,18 @@ const MessageListDomain = Remesh.domain({
CreateItemCommand,
UpdateItemCommand,
DeleteItemCommand,
ClearListCommand
UpsertItemCommand,
ClearListCommand,
ResetListCommand
},
event: {
ChangeListEvent,
CreateItemEvent,
UpdateItemEvent,
DeleteItemEvent,
UpsertItemEvent,
ClearListEvent,
ResetListEvent,
SyncToStateEvent,
SyncToStorageEvent
}

View file

@ -1,15 +1,15 @@
import { Remesh } from 'remesh'
import { NotificationExtern } from './externs/Notification'
import RoomDomain, { TextMessage } from './Room'
import ChatRoomDomain, { TextMessage } from '@/domain/ChatRoom'
import UserInfoDomain from './UserInfo'
import { map, merge } from 'rxjs'
const NotificationDomain = Remesh.domain({
name: 'NotificationDomain',
impl: (domain) => {
const notification = domain.getExtern(NotificationExtern)
const notificationExtern = domain.getExtern(NotificationExtern)
const userInfoDomain = domain.getDomain(UserInfoDomain())
const roomDomain = domain.getDomain(RoomDomain())
const chatRoomDomain = domain.getDomain(ChatRoomDomain())
const NotificationEnabledState = domain.state<boolean>({
name: 'Notification.EnabledState',
@ -40,7 +40,7 @@ const NotificationDomain = Remesh.domain({
const PushCommand = domain.command({
name: 'Notification.PushCommand',
impl: (_, message: TextMessage) => {
notification.push(message)
notificationExtern.push(message)
return [PushEvent(message)]
}
})
@ -68,7 +68,7 @@ const NotificationDomain = Remesh.domain({
domain.effect({
name: 'Notification.OnRoomMessageEffect',
impl: ({ fromEvent, get }) => {
const onTextMessage$ = fromEvent(roomDomain.event.OnTextMessageEvent)
const onTextMessage$ = fromEvent(chatRoomDomain.event.OnTextMessageEvent)
const onMessage$ = merge(onTextMessage$).pipe(
map((message) => {
const notificationEnabled = get(IsEnabledQuery())

View file

@ -1,466 +0,0 @@
import { Remesh } from 'remesh'
import { map, merge, of, EMPTY, mergeMap, fromEvent, fromEventPattern } from 'rxjs'
import { AtUser, 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'
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
atUsers: AtUser[]
}
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 PeerIdState = domain.state<string>({
name: 'Room.PeerIdState',
default: peerRoom.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 JoinIsFinishedQuery = JoinStatusModule.query.IsFinishedQuery
const JoinRoomCommand = domain.command({
name: 'Room.JoinRoomCommand',
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()
}),
JoinStatusModule.command.SetFinishedCommand(),
JoinRoomEvent(peerRoom.roomId)
]
}
})
const LeaveRoomCommand = domain.command({
name: 'Room.LeaveRoomCommand',
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 }
}),
JoinStatusModule.command.SetInitialCommand(),
LeaveRoomEvent(peerRoom.roomId)
]
}
})
const SendTextMessageCommand = domain.command({
name: 'Room.SendTextMessageCommand',
impl: ({ get }, message: string | { body: string; atUsers: AtUser[] }) => {
const { id: userId, name: username, avatar: userAvatar } = get(userInfoDomain.query.UserInfoQuery())!
const textMessage: TextMessage = {
id: nanoid(),
type: SendType.Text,
body: typeof message === 'string' ? message : message.body,
userId,
username,
userAvatar,
atUsers: typeof message === 'string' ? [] : message.atUsers
}
const listMessage: NormalMessage = {
...textMessage,
type: MessageType.Normal,
date: Date.now(),
likeUsers: [],
hateUsers: [],
atUsers: typeof message === 'string' ? [] : message.atUsers
}
peerRoom.sendMessage(textMessage)
return [messageListDomain.command.CreateItemCommand(listMessage), SendTextMessageEvent(textMessage)]
}
})
const SendLikeMessageCommand = domain.command({
name: 'Room.SendLikeMessageCommand',
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(likeMessage)
return [messageListDomain.command.UpdateItemCommand(listMessage), SendLikeMessageEvent(likeMessage)]
}
})
const SendHateMessageCommand = domain.command({
name: 'Room.SendHateMessageCommand',
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(hateMessage)
return [messageListDomain.command.UpdateItemCommand(listMessage), SendHateMessageEvent(hateMessage)]
}
})
const SendJoinMessageCommand = domain.command({
name: 'Room.SendJoinMessageCommand',
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(syncUserMessage, targetPeerId)
return [SendJoinMessageEvent(syncUserMessage)]
}
})
const UpdateUserListCommand = domain.command({
name: 'Room.UpdateUserListCommand',
impl: ({ get }, action: { type: 'create' | 'delete'; user: RoomUser }) => {
const userList = get(UserListState())
if (action.type === 'create') {
return [UserListState().new(upsert(userList, action.user, 'userId'))]
} else {
return [UserListState().new(userList.filter(({ userId }) => userId !== action.user.userId))]
}
}
})
const SendJoinMessageEvent = domain.event<SyncUserMessage>({
name: 'Room.SendJoinMessageEvent'
})
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 OnLeaveRoomEvent = domain.event<string>({
name: 'Room.OnLeaveRoomEvent'
})
const OnErrorEvent = domain.event<Error>({
name: 'Room.OnErrorEvent'
})
domain.effect({
name: 'Room.OnJoinRoomEffect',
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: 'Room.OnMessageEffect',
impl: ({ get }) => {
const onMessage$ = fromEventPattern<RoomMessage>(peerRoom.onMessage).pipe(
mergeMap((message) => {
// console.log('onMessage', message)
const messageEvent$ = of(OnMessageEvent(message))
const textMessageEvent$ = of(message.type === SendType.Text ? OnTextMessageEvent(message) : null)
const messageCommand$ = (() => {
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$, textMessageEvent$, messageCommand$)
})
)
return onMessage$
}
})
domain.effect({
name: 'Room.OnLeaveRoomEffect',
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: 'Room.OnErrorEffect',
impl: () => {
const onRoomError$ = fromEventPattern<Error>(peerRoom.onError).pipe(
map((error) => {
console.error(error)
return OnErrorEvent(error)
})
)
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: 'Room.OnUnloadEffect',
impl: ({ get }) => {
const beforeUnload$ = fromEvent(window, 'beforeunload').pipe(
map(() => {
return get(JoinStatusModule.query.IsFinishedQuery()) ? LeaveRoomCommand() : null
})
)
return beforeUnload$
}
})
return {
query: {
PeerIdQuery,
UserListQuery,
JoinIsFinishedQuery
},
command: {
JoinRoomCommand,
LeaveRoomCommand,
SendTextMessageCommand,
SendLikeMessageCommand,
SendHateMessageCommand,
SendJoinMessageCommand
},
event: {
SendTextMessageEvent,
SendLikeMessageEvent,
SendHateMessageEvent,
SendJoinMessageEvent,
JoinRoomEvent,
LeaveRoomEvent,
OnMessageEvent,
OnTextMessageEvent,
OnJoinRoomEvent,
OnLeaveRoomEvent,
OnErrorEvent
}
}
}
})
export default RoomDomain

View file

@ -1,27 +1,55 @@
import { Remesh } from 'remesh'
import ToastModule from './modules/Toast'
import RoomDomain from './Room'
import { map, merge } from 'rxjs'
import ChatRoomDomain, { SendType } from './ChatRoom'
import VirtualRoomDomain from './VirtualRoom'
import { filter, map, merge } from 'rxjs'
const ToastDomain = Remesh.domain({
name: 'ToastDomain',
impl: (domain) => {
const roomDomain = domain.getDomain(RoomDomain())
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$ = fromEvent(roomDomain.event.OnErrorEvent)
const onError$ = merge(onRoomError$).pipe(
const onRoomError$ = merge(
fromEvent(chatRoomDomain.event.OnErrorEvent),
fromEvent(virtualRoomDomain.event.OnErrorEvent)
).pipe(
map((error) => {
return toastModule.command.ErrorCommand(error.message)
})
)
return onError$
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
}
})

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,19 +1,19 @@
import { Remesh } from 'remesh'
import { RoomMessage } from '../Room'
import { RoomMessage } from '../ChatRoom'
export interface PeerRoom {
export interface ChatRoom {
readonly peerId: string
readonly roomId: string
joinRoom: () => PeerRoom
sendMessage: (message: RoomMessage, id?: string) => PeerRoom
onMessage: (callback: (message: RoomMessage) => 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

@ -1,5 +1,5 @@
import { Remesh } from 'remesh'
import { TextMessage } from '../Room'
import { TextMessage } from '@/domain/ChatRoom'
export interface Danmaku {
push: (message: TextMessage) => void

View file

@ -1,5 +1,5 @@
import { Remesh } from 'remesh'
import { TextMessage } from '../Room'
import { TextMessage } from '@/domain/ChatRoom'
export interface Notification {
push: (message: TextMessage) => Promise<string>

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,25 +1,28 @@
import { Artico, Room } from '@rtco/client'
import { Room } from '@rtco/client'
import { PeerRoomExtern } 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 '../Room'
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)
@ -30,31 +33,33 @@ 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(message: RoomMessage, 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
}
@ -65,11 +70,11 @@ class PeerRoom extends EventHub {
if (!this.room) {
this.emit('error', new Error('Room not joined'))
} else {
this.room.on('message', (message) => callback(JSON.parse(message) as RoomMessage))
this.room.on('message', (message) => callback(JSONR.parse(message) as RoomMessage))
}
})
} else {
this.room.on('message', (message) => callback(JSON.parse(message) as RoomMessage))
this.room.on('message', (message) => callback(JSONR.parse(message) as RoomMessage))
}
return this
}
@ -121,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
}
@ -129,9 +134,9 @@ 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

View file

@ -1,6 +1,6 @@
import { DanmakuExtern } from '@/domain/externs/Danmaku'
import { TextMessage } from '@/domain/Room'
import { TextMessage } from '@/domain/ChatRoom'
import { createElement } from 'react'
import DanmakuMessage from '@/app/content/components/DanmakuMessage'
import { createRoot } from 'react-dom/client'

View file

@ -1,5 +1,5 @@
import { NotificationExtern } from '@/domain/externs/Notification'
import { TextMessage } from '../Room'
import { TextMessage } from '@/domain/ChatRoom'
import { EVENT } from '@/constants/event'
import { messenger } from '@/messenger'

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

@ -8,6 +8,11 @@ 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}:` })
})

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,27 +1,29 @@
import { type DataPayload, type Room, joinRoom, selfId } from 'trystero'
import { Room } from '@rtco/client'
// import { joinRoom } from 'trystero/firebase'
import { PeerRoomExtern } from '@/domain/externs/PeerRoom'
import { VirtualRoomExtern } from '@/domain/externs/VirtualRoom'
import { stringToHex } from '@/utils'
import EventHub from '@resreq/event-hub'
import { RoomMessage } from '../Room'
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)
@ -32,34 +34,34 @@ 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')
if (this.room) {
this.room = this.peer.join(this.roomId)
} else {
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(message: RoomMessage, 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 {
const [send] = this.room.makeAction('MESSAGE')
send(message as any as DataPayload, id)
this.room.send(JSONR.stringify(message)!, id)
}
})
} else {
const [send] = this.room.makeAction('MESSAGE')
send(message as any as DataPayload, id)
this.room.send(JSONR.stringify(message)!, id)
}
return this
}
@ -69,13 +71,11 @@ class PeerRoom extends EventHub {
if (!this.room) {
this.emit('error', new Error('Room not joined'))
} else {
const [, on] = this.room.makeAction('MESSAGE')
on((message) => callback(message as any as RoomMessage))
this.room.on('message', (message) => callback(JSONR.parse(message) as RoomMessage))
}
})
} else {
const [, on] = this.room.makeAction('MESSAGE')
on((message) => callback(message as any as RoomMessage))
this.room.on('message', (message) => callback(JSONR.parse(message) as RoomMessage))
}
return this
}
@ -86,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
}
@ -105,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
}
@ -130,19 +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
// https://github.com/aklinker1/webext-core/pull/70
export const VirtualRoomImpl = VirtualRoomExtern.impl(virtualRoom)

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

@ -20,12 +20,14 @@ const useCursorPosition = () => {
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
},

View file

@ -10,7 +10,7 @@ export interface DargOptions {
minY: number
}
const useDarg = (options: DargOptions) => {
const useDraggable = (options: DargOptions) => {
const { initX, initY, maxX = 0, minX = 0, maxY = 0, minY = 0 } = options
const mousePosition = useRef({ x: 0, y: 0 })
@ -91,4 +91,4 @@ const useDarg = (options: DargOptions) => {
return { setRef, ...position }
}
export default useDarg
export default useDraggable

View file

@ -1,6 +1,6 @@
import { EVENT } from '@/constants/event'
import { defineExtensionMessaging } from '@webext-core/messaging'
import { TextMessage } from '@/domain/Room'
import { TextMessage } from '@/domain/ChatRoom'
interface ProtocolMap {
[EVENT.OPTIONS_PAGE_OPEN]: () => void

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('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

@ -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') ??

View file

@ -0,0 +1,3 @@
export const getTextByteSize = (text: string) => {
return new TextEncoder().encode(text).length
}

View file

@ -4,7 +4,7 @@ export { default as createElement } from './createElement'
export { default as getSiteInfo } from './getSiteInfo'
export { default as compressImage } from './compressImage'
export { default as isNullish } from './isNullish'
export { default as checkSystemDarkMode } from './checkSystemDarkMode'
export { default as checkDarkMode } from './checkDarkMode'
export { default as stringToHex } from './stringToHex'
export { default as debounce } from './debounce'
export { default as throttle } from './throttle'
@ -15,3 +15,7 @@ export { default as getCursorPosition } from './getCursorPosition'
export { default as getTextSimilarity } from './getTextSimilarity'
export { default as getRootNode } from './getRootNode'
export { default as blobToBase64 } from './blobToBase64'
export * as JSONR from './jsonr'
export { getTextByteSize } from './getTextByteSize'
export { default as isEqual } from './isEqual'
export { cleanURL, isAbsoluteURL, assembleURL, buildFullURL } from './url'

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

@ -0,0 +1,5 @@
const isEqual = (a: object, b: object) => {
return JSON.stringify(a) === JSON.stringify(b)
}
export default isEqual

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

@ -0,0 +1,10 @@
import JSONR from '@perfsee/jsonr'
import { isNullish } from '@/utils'
export const parse = <T = any>(value: string | number | boolean | null): T | null => {
return !isNullish(value) ? JSONR.parse(value!.toString()) : null
}
export const stringify = (value: any): string | null => {
return !isNullish(value) ? JSONR.stringify(value) : null
}

36
src/utils/url.ts Normal file
View file

@ -0,0 +1,36 @@
export const cleanURL = (url: string) => url.replace(/([^:]\/)\/+/g, '$1').replace(/\/+$/, '')
/**
* Determines whether the specified URL is absolute
* Reference: https://github.com/axios/axios/blob/v1.x/lib/helpers/isAbsoluteURL.js
*/
export const isAbsoluteURL = (url: string) => {
// A URL is considered absolute if it begins with "<scheme>://" or "//" (protocol-relative URL).
// RFC 3986 defines scheme name as a sequence of characters beginning with a letter and followed
// by any combination of letters, digits, plus, period, or hyphen.
return /^([a-z][a-z\d+\-.]*:)?\/\//i.test(url)
}
/**
* Add params to the URL
*/
export const assembleURL = (url: string, params: Record<string, string>) => {
return Object.entries(params)
.reduce((url, [key, value]) => {
url.searchParams.append(key, value)
return url
}, new URL(url))
.toString()
}
/**
* Creates a new URL by combining the baseURL with the requestedURL,
* only when the requestedURL is not already an absolute URL.
* If the requestURL is absolute, this function returns the requestedURL untouched.
*
* reference: https://github.com/axios/axios/blob/v1.x/lib/core/buildFullPath.js
*/
export const buildFullURL = (baseURL: string = '', pathURL: string = '', params: Record<string, any> = {}) => {
const url = cleanURL(isAbsoluteURL(pathURL) ? pathURL : `${baseURL}/${pathURL}`)
return assembleURL(url, params)
}

View file

@ -11,6 +11,9 @@ export default {
padding: '2rem'
},
extend: {
fontSize: {
'2xs': '0.625rem'
},
zIndex: {
infinity: 'calc(infinity)'
},