Compare commits
No commits in common. "master" and "v1.0.14" have entirely different histories.
110 changed files with 4975 additions and 5836 deletions
4
.github/workflows/cd.yml
vendored
4
.github/workflows/cd.yml
vendored
|
@ -21,7 +21,7 @@ jobs:
|
|||
- run: pnpm install --ignore-scripts
|
||||
- run: pnpm wxt prepare
|
||||
- run: pnpm run lint
|
||||
- run: pnpm run check
|
||||
- run: pnpm run tsc
|
||||
|
||||
release:
|
||||
needs: linter
|
||||
|
@ -39,6 +39,6 @@ jobs:
|
|||
version: latest
|
||||
- run: pnpm install --ignore-scripts
|
||||
- run: pnpm wxt prepare
|
||||
- run: pnpm semantic-release
|
||||
- run: pnpm semantic-release --debug
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.WEB_CHAT_GITHUB_TOKEN }}
|
||||
|
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -14,5 +14,4 @@ web-ext.config.ts
|
|||
*.pem
|
||||
*.xpi
|
||||
*.zip
|
||||
.idea
|
||||
|
||||
|
|
|
@ -1,2 +1,2 @@
|
|||
|
||||
pnpm commitlint --edit "$1"
|
||||
npx commitlint --edit "$1"
|
||||
|
|
|
@ -1,2 +1,2 @@
|
|||
|
||||
pnpm lint-staged && pnpm check
|
||||
pnpm lint-staged && pnpm tsc
|
||||
|
|
37
.releaserc
Normal file
37
.releaserc
Normal file
|
@ -0,0 +1,37 @@
|
|||
{
|
||||
"branches": [
|
||||
"master"
|
||||
],
|
||||
"plugins": [
|
||||
"@semantic-release/commit-analyzer",
|
||||
"@semantic-release/release-notes-generator",
|
||||
"@semantic-release/changelog",
|
||||
[
|
||||
"@semantic-release/exec",
|
||||
{
|
||||
"prepareCmd": "pnpm pack:chrome && cat package.json"
|
||||
}
|
||||
],
|
||||
[
|
||||
"@semantic-release/github",
|
||||
{
|
||||
"assets": [
|
||||
{
|
||||
"path": "${{ github.workspace }}/.output/web-chat-${{ nextRelease.version }}-chrome.zip"
|
||||
}
|
||||
],
|
||||
"labels": [
|
||||
"release"
|
||||
]
|
||||
}
|
||||
],
|
||||
[
|
||||
"@semantic-release/git",
|
||||
{
|
||||
"assets": [
|
||||
"package.json"
|
||||
]
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
|
@ -1,59 +0,0 @@
|
|||
/**
|
||||
* @type {import('semantic-release').GlobalConfig}
|
||||
*/
|
||||
|
||||
import packageJson from './package.json' with { type: 'json' }
|
||||
|
||||
const name = packageJson.name
|
||||
|
||||
export default {
|
||||
branches: ['master'],
|
||||
plugins: [
|
||||
'@semantic-release/commit-analyzer',
|
||||
'@semantic-release/release-notes-generator',
|
||||
'@semantic-release/changelog',
|
||||
[
|
||||
'@semantic-release/npm',
|
||||
{
|
||||
npmPublish: false
|
||||
}
|
||||
],
|
||||
[
|
||||
'@semantic-release/exec',
|
||||
{
|
||||
prepareCmd: `pnpm run pack`
|
||||
}
|
||||
],
|
||||
/**
|
||||
* Because assets.path does not support environment variables, a copy of the file without the version number is needed.
|
||||
* @see https://github.com/semantic-release/github/issues/274
|
||||
* */
|
||||
[
|
||||
'@semantic-release/exec',
|
||||
{
|
||||
prepareCmd: `cp .output/${name}-\${nextRelease.version}-chrome.zip .output/${name}-chrome.zip`
|
||||
}
|
||||
],
|
||||
[
|
||||
'@semantic-release/exec',
|
||||
{
|
||||
prepareCmd: `cp .output/${name}-\${nextRelease.version}-firefox.zip .output/${name}-firefox.zip`
|
||||
}
|
||||
],
|
||||
[
|
||||
'@semantic-release/github',
|
||||
{
|
||||
assets: [
|
||||
{
|
||||
path: `.output/${name}-chrome.zip`
|
||||
},
|
||||
{
|
||||
path: `.output/${name}-firefox.zip`
|
||||
}
|
||||
],
|
||||
labels: ['release']
|
||||
}
|
||||
],
|
||||
'@semantic-release/git'
|
||||
]
|
||||
}
|
343
CHANGELOG.md
343
CHANGELOG.md
|
@ -1,346 +1,3 @@
|
|||
## [1.7.1](https://github.com/molvqingtai/WebChat/compare/v1.7.0...v1.7.1) (2024-11-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* parse icon url error ([7763f34](https://github.com/molvqingtai/WebChat/commit/7763f34d5d07a104f8a66e53b05a7f87a4e0da28))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* add number animation ([eb37dd2](https://github.com/molvqingtai/WebChat/commit/eb37dd28338d9e5420c91fb3d25c318411bdfd31))
|
||||
* compatible with rectangular icons ([b860b16](https://github.com/molvqingtai/WebChat/commit/b860b16e908a744f615c8cea35a3dcd4ca008f1a))
|
||||
* optimize scrollbar ([c5185e4](https://github.com/molvqingtai/WebChat/commit/c5185e419c5e175b8bc30e3f2b2207c18b9503b2))
|
||||
|
||||
# [1.7.0](https://github.com/molvqingtai/WebChat/compare/v1.6.6...v1.7.0) (2024-11-13)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* ranking of users supporting online websites Closes [#48](https://github.com/molvqingtai/WebChat/issues/48) ([d0fea9e](https://github.com/molvqingtai/WebChat/commit/d0fea9e42d52d0e56171c08ed780066d66ebe3f1))
|
||||
|
||||
## [1.6.6](https://github.com/molvqingtai/WebChat/compare/v1.6.5...v1.6.6) (2024-11-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* the number of online users is inaccurate ([c6301a8](https://github.com/molvqingtai/WebChat/commit/c6301a826ebcf38a34b93a02c8013dd1ef9e7abc))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* optimize taost dark mode ([00f0bd0](https://github.com/molvqingtai/WebChat/commit/00f0bd08b04e49f83cee60bb5767acd460a1b5d0))
|
||||
* theme mode is compatible with website themes by default ([6222e3f](https://github.com/molvqingtai/WebChat/commit/6222e3f8af1bf4fad2466a9bf88c3b3159478a86))
|
||||
|
||||
## [1.6.5](https://github.com/molvqingtai/WebChat/compare/v1.6.4...v1.6.5) (2024-11-07)
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* delete setup exit animation ([d325be4](https://github.com/molvqingtai/WebChat/commit/d325be4becf562d2232a1a1e9a4e1582e44869a2))
|
||||
|
||||
## [1.6.4](https://github.com/molvqingtai/WebChat/compare/v1.6.3...v1.6.4) (2024-11-07)
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* check message format ([f6864e0](https://github.com/molvqingtai/WebChat/commit/f6864e06be01fd434136901ae85278ed4eab4c03))
|
||||
|
||||
## [1.6.3](https://github.com/molvqingtai/WebChat/compare/v1.6.2...v1.6.3) (2024-11-06)
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* optimize image processing ([9438a31](https://github.com/molvqingtai/WebChat/commit/9438a3169dfda166776610ba6aac1ac168231636))
|
||||
|
||||
## [1.6.2](https://github.com/molvqingtai/WebChat/compare/v1.6.1...v1.6.2) (2024-11-04)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* incompatible with old data of userInfo, causing crash ([d5ced07](https://github.com/molvqingtai/WebChat/commit/d5ced0718f586ca156e80c56078ae1f3de4ee917))
|
||||
|
||||
## [1.6.1](https://github.com/molvqingtai/WebChat/compare/v1.6.0...v1.6.1) (2024-11-03)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* sooner style ([7e49ec2](https://github.com/molvqingtai/WebChat/commit/7e49ec210ed706a0ee94b3c2b7b17af719b604e1))
|
||||
|
||||
# [1.6.0](https://github.com/molvqingtai/WebChat/compare/v1.5.4...v1.6.0) (2024-11-03)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* support offline message sync [#45](https://github.com/molvqingtai/WebChat/issues/45) ([7c4f655](https://github.com/molvqingtai/WebChat/commit/7c4f65573c591da2a8c8938e14066cee96d15b40))
|
||||
|
||||
## [1.5.4](https://github.com/molvqingtai/WebChat/compare/v1.5.3...v1.5.4) (2024-10-31)
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* support reading image from the clipboard ([362d7db](https://github.com/molvqingtai/WebChat/commit/362d7db7386d978c6d053a3e7262adf844e24f55))
|
||||
|
||||
## [1.5.3](https://github.com/molvqingtai/WebChat/compare/v1.5.2...v1.5.3) (2024-10-30)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* insertion cursor position is incorrect ([2987c2d](https://github.com/molvqingtai/WebChat/commit/2987c2d85dd84639c06848ddc5cd4dc0b3288538))
|
||||
|
||||
## [1.5.2](https://github.com/molvqingtai/WebChat/compare/v1.5.1...v1.5.2) (2024-10-30)
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* optimize theme style ([7b91944](https://github.com/molvqingtai/WebChat/commit/7b91944fbf60c27d21274ddb7f28f97344c89ef5))
|
||||
|
||||
## [1.5.1](https://github.com/molvqingtai/WebChat/compare/v1.5.0...v1.5.1) (2024-10-29)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* incompatibility with old data causes app to crash ([bd07bdc](https://github.com/molvqingtai/WebChat/commit/bd07bdc2c3df031d5a04d3eebade5d7fc7672600))
|
||||
|
||||
# [1.5.0](https://github.com/molvqingtai/WebChat/compare/v1.4.0...v1.5.0) (2024-10-29)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* support send image button ([a01a93f](https://github.com/molvqingtai/WebChat/commit/a01a93f260c3fefadb1ad1ce0369af3ea8c6b3f0))
|
||||
|
||||
# [1.4.0](https://github.com/molvqingtai/WebChat/compare/v1.3.1...v1.4.0) (2024-10-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* delete bad z-index ([bcdd435](https://github.com/molvqingtai/WebChat/commit/bcdd435e45e0b39d2c3ac45fbe594609165bacd8))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* app button support drag ([4eba638](https://github.com/molvqingtai/WebChat/commit/4eba638a367d4be2dc3d0b3e378298fd98a9ff5d))
|
||||
* support [@user](https://github.com/user) syntax ([bef576a](https://github.com/molvqingtai/WebChat/commit/bef576a77bc995e8eaf57de212a233081be34727))
|
||||
* support dark mode ([010aa2f](https://github.com/molvqingtai/WebChat/commit/010aa2f45e8cf864ac54fed44668369b5ff8fd9e))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* optimize danmuku theme styles ([4f6eb56](https://github.com/molvqingtai/WebChat/commit/4f6eb560fe88e5e7e5d5b920666ed5e19b952fe9))
|
||||
* optimize header theme styles ([025166e](https://github.com/molvqingtai/WebChat/commit/025166ead5529f66c26810e6b7ab6ba07dd874aa))
|
||||
* optimize theme styles ([2d051fe](https://github.com/molvqingtai/WebChat/commit/2d051fedd763427d10ac2c0c1a0bd74fe7788501))
|
||||
* reset app position when window resize ([eee1735](https://github.com/molvqingtai/WebChat/commit/eee17356545515905813f5937b4dbe183fb081ed))
|
||||
|
||||
## [1.3.1](https://github.com/molvqingtai/WebChat/compare/v1.3.0...v1.3.1) (2024-10-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* missing tabs permission ([3cfc16c](https://github.com/molvqingtai/WebChat/commit/3cfc16c9ee0f3f46c8b5692c02e5c569f40744c9))
|
||||
|
||||
# [1.3.0](https://github.com/molvqingtai/WebChat/compare/v1.2.2...v1.3.0) (2024-10-12)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* p2p use artico ([a0a8462](https://github.com/molvqingtai/WebChat/commit/a0a8462f5ff55a50511e335f70f5b814f2713358))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* support notification ([9898718](https://github.com/molvqingtai/WebChat/commit/9898718b1a14605d140852faca74b8af12f9b2a2))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* notification supports clicking to open the source website ([653229c](https://github.com/molvqingtai/WebChat/commit/653229c8fa1ef748c84c4a5cec756a42f51933ab))
|
||||
|
||||
## [1.2.2](https://github.com/molvqingtai/WebChat/compare/v1.2.1...v1.2.2) (2024-10-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* danmuku message ellipsis ([e8e243e](https://github.com/molvqingtai/WebChat/commit/e8e243ee096a0fb22183170ef3c0005291b72870))
|
||||
* online text overflow ([d4e42c6](https://github.com/molvqingtai/WebChat/commit/d4e42c68caf8e2e080854f244328c1e519ed6338))
|
||||
|
||||
## [1.2.1](https://github.com/molvqingtai/WebChat/compare/v1.2.0...v1.2.1) (2024-10-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* avatar is not displayed completely ([de97d05](https://github.com/molvqingtai/WebChat/commit/de97d0552894a33f2b15dd232598c40335d941a4))
|
||||
* the text in the button is not visible in dark mode ([d6652cb](https://github.com/molvqingtai/WebChat/commit/d6652cb2a43116016af32697b52d5bba276e6d2c))
|
||||
* the text in the textarea is not visible in dark mode ([d75a191](https://github.com/molvqingtai/WebChat/commit/d75a191dedd40a02fc58707ac60cccd9ff020c5f))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* change https://github.com/weizhenye/Danmaku to https://github.com/imtaotao/danmu ([05ee49e](https://github.com/molvqingtai/WebChat/commit/05ee49e7c4019f32c654f2f935b734ec2383bebc))
|
||||
* submit store flow ([5235a6e](https://github.com/molvqingtai/WebChat/commit/5235a6ee8703597df227942208b4075bff880c2d))
|
||||
|
||||
# [1.2.0](https://github.com/molvqingtai/WebChat/compare/v1.1.6...v1.2.0) (2024-10-08)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* support display of online user list ([4c7137d](https://github.com/molvqingtai/WebChat/commit/4c7137d045a127bef6e8a3afe319f15a480b149c))
|
||||
|
||||
## [1.1.6](https://github.com/molvqingtai/WebChat/compare/v1.1.5...v1.1.6) (2024-10-04)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* it should not be sent when composing ([8ee9ed6](https://github.com/molvqingtai/WebChat/commit/8ee9ed6259f731fa43ef0d458a7e040ad1618d12))
|
||||
|
||||
## [1.1.5](https://github.com/molvqingtai/WebChat/compare/v1.1.4...v1.1.5) (2024-10-02)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* multiple tabs display duplicate online users ([8b843ac](https://github.com/molvqingtai/WebChat/commit/8b843ac45cc415676641b66dbfb21329c3f7c962))
|
||||
|
||||
## [1.1.4](https://github.com/molvqingtai/WebChat/compare/v1.1.3...v1.1.4) (2024-10-02)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* firfox requestAnimationFrame error ([65bf9b2](https://github.com/molvqingtai/WebChat/commit/65bf9b2419ec65b6c53355986df9a0e2eb593d6f))
|
||||
|
||||
## [1.1.3](https://github.com/molvqingtai/WebChat/compare/v1.1.2...v1.1.3) (2024-10-02)
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* add version link ([4551ad2](https://github.com/molvqingtai/WebChat/commit/4551ad2964e21e1bf85866b79acd25bf556aa26d))
|
||||
|
||||
## [1.1.2](https://github.com/molvqingtai/WebChat/compare/v1.1.1...v1.1.2) (2024-10-02)
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* support unread status ([1f44af8](https://github.com/molvqingtai/WebChat/commit/1f44af873c57aaed2eb3d845342ad427ce1d8a4f))
|
||||
|
||||
## [1.1.1](https://github.com/molvqingtai/WebChat/compare/v1.1.0...v1.1.1) (2024-10-01)
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* a tag use Link component ([fce64b7](https://github.com/molvqingtai/WebChat/commit/fce64b744c2ada3532ff3d4b78d08559c718ca1a))
|
||||
|
||||
# [1.1.0](https://github.com/molvqingtai/WebChat/compare/v1.0.29...v1.1.0) (2024-09-30)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* support danmaku ([999a55c](https://github.com/molvqingtai/WebChat/commit/999a55c65f78d0a1a0938c354a8453f2aa39fcd0))
|
||||
|
||||
## [1.0.29](https://github.com/molvqingtai/WebChat/compare/v1.0.28...v1.0.29) (2024-09-29)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* compile by environment ([52cd203](https://github.com/molvqingtai/WebChat/commit/52cd203a53ec10dda48572659d0e9959667575be))
|
||||
* error when leaving the room without joining ([8476595](https://github.com/molvqingtai/WebChat/commit/8476595011c0e38929e6ebaa44ab7d8d5292a8e3))
|
||||
|
||||
## [1.0.28](https://github.com/molvqingtai/WebChat/compare/v1.0.27...v1.0.28) (2024-09-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* svg icon size ([089d69a](https://github.com/molvqingtai/WebChat/commit/089d69a095c22ea24bd2e8960799d7f2acb0b1ac))
|
||||
|
||||
## [1.0.27](https://github.com/molvqingtai/WebChat/compare/v1.0.26...v1.0.27) (2024-09-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* uniformly resizable size ([3bb2b55](https://github.com/molvqingtai/WebChat/commit/3bb2b55f21e2ead16be4f7c4d9aa40cee87cca93))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* add isolate events ([8fd5f04](https://github.com/molvqingtai/WebChat/commit/8fd5f04ecd730bf4bc73fe72c1ce9281a572ca4c))
|
||||
|
||||
## [1.0.26](https://github.com/molvqingtai/WebChat/compare/v1.0.25...v1.0.26) (2024-09-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* release flow ([e0f4a3f](https://github.com/molvqingtai/WebChat/commit/e0f4a3f18adc4452ec0732bbfdc0a240d203a0e7))
|
||||
* release flow ([aa0088b](https://github.com/molvqingtai/WebChat/commit/aa0088bbc909c1c7b4745673978802e3016fde13))
|
||||
|
||||
## [1.0.25](https://github.com/molvqingtai/WebChat/compare/v1.0.24...v1.0.25) (2024-09-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* test release flow ([b10e9db](https://github.com/molvqingtai/WebChat/commit/b10e9dbb8288af9fe976e3d65ed2ea38530bdbcc))
|
||||
|
||||
## [1.0.24](https://github.com/molvqingtai/WebChat/compare/v1.0.23...v1.0.24) (2024-09-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* test release flow ([b4fe712](https://github.com/molvqingtai/WebChat/commit/b4fe7128250210012ae55b3209107362dcbb2df8))
|
||||
|
||||
## [1.0.23](https://github.com/molvqingtai/WebChat/compare/v1.0.22...v1.0.23) (2024-09-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* test release flow ([3d984fc](https://github.com/molvqingtai/WebChat/commit/3d984fc42bc3581723fe29ece360a9ee842026c3))
|
||||
|
||||
## [1.0.22](https://github.com/molvqingtai/WebChat/compare/v1.0.21...v1.0.22) (2024-09-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* test release flow ([72137e8](https://github.com/molvqingtai/WebChat/commit/72137e811d07459fbd0859e114c22c515a5d6e26))
|
||||
|
||||
## [1.0.21](https://github.com/molvqingtai/WebChat/compare/v1.0.20...v1.0.21) (2024-09-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* test release flow ([444d24c](https://github.com/molvqingtai/WebChat/commit/444d24c3b923d184da55a22cd165cb33a8751908))
|
||||
|
||||
## [1.0.20](https://github.com/molvqingtai/WebChat/compare/v1.0.19...v1.0.20) (2024-09-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* test release flow ([16c29e6](https://github.com/molvqingtai/WebChat/commit/16c29e6805001450e165d3db37991bda9619305f))
|
||||
|
||||
## [1.0.19](https://github.com/molvqingtai/WebChat/compare/v1.0.18...v1.0.19) (2024-09-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* test release flow ([7b543bc](https://github.com/molvqingtai/WebChat/commit/7b543bc4f354fc3a1483d3eed5d60bc235a4953f))
|
||||
|
||||
## [1.0.18](https://github.com/molvqingtai/WebChat/compare/v1.0.17...v1.0.18) (2024-09-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* test release flow ([f4fb1f7](https://github.com/molvqingtai/WebChat/commit/f4fb1f7c3a6180a7183659fa523e634f47ae9738))
|
||||
|
||||
## [1.0.17](https://github.com/molvqingtai/WebChat/compare/v1.0.16...v1.0.17) (2024-09-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* release flow ([ffa8d42](https://github.com/molvqingtai/WebChat/commit/ffa8d4233ba55d623d9870e70c952d3b176c25db))
|
||||
* release flow ([5c043a2](https://github.com/molvqingtai/WebChat/commit/5c043a22d2ff4064d932a1d9df4a1c9b23365528))
|
||||
|
||||
## [1.0.16](https://github.com/molvqingtai/WebChat/compare/v1.0.15...v1.0.16) (2024-09-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* test release flow ([2a77a6f](https://github.com/molvqingtai/WebChat/commit/2a77a6ff94831f7dda116a2d55182980cb56a03b))
|
||||
* test release flow ([e851939](https://github.com/molvqingtai/WebChat/commit/e8519393b64377609f8889fe665b2ef17ded1198))
|
||||
|
||||
## [1.0.4](https://github.com/molvqingtai/WebChat/compare/v1.0.3...v1.0.4) (2024-09-27)
|
||||
|
||||
|
||||
|
|
41
README.md
41
README.md
|
@ -2,55 +2,42 @@
|
|||
<img src="https://github.com/molvqingtai/WebChat/blob/master/src/public/logo.png" width="200px"/>
|
||||
</p>
|
||||
|
||||
# WebChat
|
||||
|
||||
[](https://github.com/molvqingtai/WebChat/blob/master/LICENSE) [](https://chromewebstore.google.com/detail/webchat/cpaedhbidlpnbdfegakhiamfpndhjpgf) [](https://github.com/molvqingtai/WebChat/releases)
|
||||
# WebChat
|
||||
|
||||
> Chat with anyone on any website
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
This is an anonymous chat browser extension that is decentralized and serverless, utilizing WebRTC for end-to-end encrypted communication. It prioritizes privacy, with all data stored locally.
|
||||
|
||||
The aim is to add chat room functionality to any website, you'll never feel alone again.
|
||||
The goal is to address the issue of delayed replies on websites by adding instant messaging capabilities to any site, allowing for chatting anytime, anywhere.
|
||||
|
||||
### 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/)
|
||||
### Example
|
||||
|
||||
**Manual Installation**
|
||||

|
||||
|
||||
1. Go to the GitHub repository ([Releases](https://github.com/molvqingtai/WebChat/releases))
|
||||
2. Click on the "Assets" button and select "web-chat-\*.zip"
|
||||
3. Extract the ZIP file to a folder on your computer
|
||||
4. Open the extension management page in your browser (usually chrome://extensions/)
|
||||
- Enable "Developer mode"
|
||||
- Click "Load unpacked" and select the folder you just extracted
|
||||
|
||||
### Use
|
||||
|
||||
After installing the extension, you'll see a ghost icon in the bottom-right corner of any website. Click it, and you'll be able to chat happily with others on the same site!
|
||||
|
||||
### Video
|
||||
|
||||
https://github.com/user-attachments/assets/e7ac9b8e-1b6c-43fb-8469-7a0a2c09d450
|
||||
|
||||
### Standing on the Shoulders of Giants
|
||||
|
||||
In addition to the good idea of decentralized chat, it also leverages some fantastic technologies.
|
||||
In addition to the great idea of decentralized chat, it also leverages some fantastic technologies.
|
||||
|
||||
- **[remesh](https://github.com/remesh-js/remesh)**: A framework in JavaScript that implements DDD principles, achieving true separation of UI and logic, allowing for easy implementation of the UI part, such as rewriting it in Vue, due to its independence from the UI.
|
||||
* **[remesh](https://github.com/remesh-js/remesh)**: A frontend framework that implements DDD principles in code, achieving true separation of UI and logic. This provides a robust structure that can be easily migrated to other frontend frameworks like Vue.
|
||||
|
||||
- **[shadcn/ui](https://ui.shadcn.com/)**: A beautiful UI library and a pioneer of the no-install concept, offering unmatched convenience in customizing styles.
|
||||
* **[shadcn/ui](https://ui.shadcn.com/)**: A beautiful UI library and a pioneer of no-install options, with unmatched ease of customizing styles.
|
||||
|
||||
- **[wxt](https://wxt.dev/)**: This is the best framework I’ve used for building browser extensions, bar none.
|
||||
* **[wxt](https://wxt.dev/)**: This is the best framework I’ve 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.
|
||||
|
||||
### License
|
||||
|
||||
|
|
|
@ -34,9 +34,8 @@ export default [
|
|||
'@typescript-eslint/no-unused-expressions': 'off',
|
||||
'@eslint-react/no-array-index-key': 'off',
|
||||
'@eslint-react/hooks-extra/no-redundant-custom-hook': 'off',
|
||||
'@eslint-react/dom/no-missing-button-type': 'off',
|
||||
'@eslint-react/hooks-extra/prefer-use-state-lazy-initialization': 'off'
|
||||
'@eslint-react/dom/no-missing-button-type': 'off'
|
||||
}
|
||||
}
|
||||
]
|
||||
// satisfies Linter.Config[]
|
||||
// satisfies Linter.Config[]
|
||||
|
|
95
package.json
95
package.json
|
@ -1,21 +1,20 @@
|
|||
{
|
||||
"name": "web-chat",
|
||||
"displayName": "WebChat",
|
||||
"version": "1.7.1",
|
||||
"version": "0.0.1",
|
||||
"description": "Chat with anyone on any website.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "wxt",
|
||||
"dev:firefox": "wxt -b firefox",
|
||||
"build": "cross-env NODE_ENV=production run-p build:*",
|
||||
"build:chrome": "wxt build -b chrome",
|
||||
"build": "wxt build",
|
||||
"build:firefox": "wxt build -b firefox",
|
||||
"pack": "cross-env NODE_ENV=production run-p pack:*",
|
||||
"pack:chrome": "wxt zip -b chrome",
|
||||
"pack:firefox": "wxt zip -b firefox",
|
||||
"lint": "eslint --fix --flag unstable_ts_config",
|
||||
"clear": "rimraf .output",
|
||||
"check": "tsc --noEmit",
|
||||
"tsc": "tsc --noEmit",
|
||||
"prepare": "husky",
|
||||
"postinstall": "wxt prepare"
|
||||
},
|
||||
|
@ -44,94 +43,92 @@
|
|||
},
|
||||
"homepage": "https://github.com/molvqingtai/WebChat",
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@number-flow/react": "^0.3.2",
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"@lottiefiles/dotlottie-react": "^0.9.0",
|
||||
"@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.2",
|
||||
"@radix-ui/react-avatar": "^1.1.0",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
||||
"@radix-ui/react-hover-card": "^1.1.1",
|
||||
"@radix-ui/react-icons": "^1.3.0",
|
||||
"@radix-ui/react-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.1",
|
||||
"@radix-ui/react-popover": "^1.1.1",
|
||||
"@radix-ui/react-radio-group": "^1.2.0",
|
||||
"@radix-ui/react-scroll-area": "^1.1.0",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-switch": "^1.1.1",
|
||||
"@radix-ui/react-switch": "^1.1.0",
|
||||
"@resreq/event-hub": "^1.6.0",
|
||||
"@resreq/timer": "^1.1.6",
|
||||
"@rtco/client": "^0.2.17",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@webcomponents/custom-elements": "^1.6.0",
|
||||
"@webext-core/messaging": "^2.1.0",
|
||||
"@webext-core/proxy-service": "^1.2.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"danmu": "^0.14.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"framer-motion": "^11.11.17",
|
||||
"framer-motion": "^11.7.0",
|
||||
"idb-keyval": "^6.2.1",
|
||||
"lucide-react": "^0.456.0",
|
||||
"nanoid": "^5.0.8",
|
||||
"lucide-react": "^0.446.0",
|
||||
"nanoid": "^5.0.7",
|
||||
"next-themes": "^0.3.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.53.2",
|
||||
"react-hook-form": "^7.53.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-use": "^17.5.1",
|
||||
"react-virtuoso": "^4.12.0",
|
||||
"react-virtuoso": "^4.10.4",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"remesh": "^4.2.2",
|
||||
"remesh-logger": "^4.1.0",
|
||||
"remesh-react": "^4.1.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"sonner": "^1.7.0",
|
||||
"tailwind-merge": "^2.5.4",
|
||||
"sonner": "^1.5.0",
|
||||
"tailwind-merge": "^2.5.2",
|
||||
"trystero": "^0.20.0",
|
||||
"type-fest": "^4.26.1",
|
||||
"unstorage": "^1.13.1",
|
||||
"valibot": "1.0.0-beta.0"
|
||||
"unstorage": "^1.12.0",
|
||||
"valibot": "^0.42.1",
|
||||
"webextension-polyfill": "^0.12.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^19.5.0",
|
||||
"@commitlint/config-conventional": "^19.5.0",
|
||||
"@eslint-react/eslint-plugin": "^1.16.1",
|
||||
"@eslint/js": "^9.14.0",
|
||||
"@eslint-react/eslint-plugin": "^1.14.2",
|
||||
"@eslint/js": "^9.11.1",
|
||||
"@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.9.0",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@typescript-eslint/parser": "^8.14.0",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"@types/eslint__js": "^8.42.3",
|
||||
"@types/node": "^22.7.2",
|
||||
"@types/react": "^18.3.9",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/webextension-polyfill": "^0.12.1",
|
||||
"@typescript-eslint/parser": "^8.7.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^9.14.0",
|
||||
"eslint": "^9.11.1",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"eslint-plugin-tailwindcss": "^3.17.5",
|
||||
"globals": "^15.12.0",
|
||||
"eslint-plugin-tailwindcss": "^3.17.4",
|
||||
"globals": "^15.9.0",
|
||||
"husky": "^9.1.6",
|
||||
"jiti": "^2.4.0",
|
||||
"jiti": "^2.0.0",
|
||||
"lint-staged": "^15.2.10",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"postcss": "^8.4.49",
|
||||
"postcss": "^8.4.47",
|
||||
"postcss-rem-to-responsive-pixel": "^6.0.2",
|
||||
"prettier": "^3.3.3",
|
||||
"rimraf": "^6.0.1",
|
||||
"semantic-release": "^24.2.0",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"rimraf": "^5.0.10",
|
||||
"semantic-release": "^24.1.1",
|
||||
"tailwindcss": "^3.4.13",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "^5.6.3",
|
||||
"typescript-eslint": "^8.14.0",
|
||||
"vite-plugin-svgr": "^4.3.0",
|
||||
"typescript": "^5.6.2",
|
||||
"typescript-eslint": "^8.7.0",
|
||||
"vite-plugin-svgr": "^4.2.0",
|
||||
"webext-bridge": "^6.0.1",
|
||||
"wxt": "^0.19.15"
|
||||
"wxt": "^0.19.10"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,jsx,ts,tsx}": "eslint --fix --flag unstable_ts_config"
|
||||
|
|
4880
pnpm-lock.yaml
generated
4880
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
@ -1,64 +1,17 @@
|
|||
import { EVENT } from '@/constants/event'
|
||||
import { messenger } from '@/messenger'
|
||||
import { browser, Tabs } from 'wxt/browser'
|
||||
import { browser } from 'wxt/browser'
|
||||
import { defineBackground } from 'wxt/sandbox'
|
||||
|
||||
export default defineBackground({
|
||||
// Set manifest options
|
||||
persistent: true,
|
||||
type: 'module',
|
||||
|
||||
main() {
|
||||
const historyNotificationTabs = new Map<string, Tabs.Tab>()
|
||||
messenger.onMessage(EVENT.OPTIONS_PAGE_OPEN, () => {
|
||||
browser.runtime.openOptionsPage()
|
||||
})
|
||||
|
||||
messenger.onMessage(EVENT.NOTIFICATION_PUSH, async ({ data: message, sender }) => {
|
||||
// Check if there is an active tab on the same site
|
||||
const tabs = await browser.tabs.query({ active: true })
|
||||
const hasActiveSomeSiteTab = tabs.some((tab) => {
|
||||
return new URL(tab.url!).origin === new URL(sender.tab!.url!).origin
|
||||
})
|
||||
|
||||
if (hasActiveSomeSiteTab) return
|
||||
|
||||
browser.notifications.create(message.id, {
|
||||
type: 'basic',
|
||||
iconUrl: message.userAvatar,
|
||||
title: message.username,
|
||||
message: message.body,
|
||||
contextMessage: sender.tab!.url!
|
||||
})
|
||||
historyNotificationTabs.set(message.id, sender.tab!)
|
||||
})
|
||||
messenger.onMessage(EVENT.NOTIFICATION_CLEAR, async ({ data: id }) => {
|
||||
browser.notifications.clear(id)
|
||||
})
|
||||
|
||||
browser.notifications.onButtonClicked.addListener(async (id) => {
|
||||
const fromTab = historyNotificationTabs.get(id)
|
||||
if (fromTab?.id) {
|
||||
try {
|
||||
const tab = await browser.tabs.get(fromTab.id)
|
||||
browser.tabs.update(tab.id, { active: true })
|
||||
} catch {
|
||||
browser.tabs.create({ url: fromTab.url })
|
||||
}
|
||||
browser.runtime.onMessage.addListener(async (event: EVENT) => {
|
||||
if (event === EVENT.OPEN_OPTIONS_PAGE) {
|
||||
browser.runtime.openOptionsPage()
|
||||
}
|
||||
})
|
||||
|
||||
browser.notifications.onClicked.addListener(async (id) => {
|
||||
const fromTab = historyNotificationTabs.get(id)
|
||||
if (fromTab?.id) {
|
||||
try {
|
||||
const tab = await browser.tabs.get(fromTab.id)
|
||||
browser.tabs.update(tab.id, { active: true })
|
||||
} catch {
|
||||
browser.tabs.create({ url: fromTab.url })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
browser.notifications.onClosed.addListener(async (id) => {
|
||||
historyNotificationTabs.delete(id)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
|
|
@ -1,122 +1,79 @@
|
|||
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 AppContainer from '@/app/content/views/AppContainer'
|
||||
import { useRemeshDomain, useRemeshQuery, useRemeshSend } from 'remesh-react'
|
||||
import ChatRoomDomain from '@/domain/ChatRoom'
|
||||
import RoomDomain from '@/domain/Room'
|
||||
import UserInfoDomain from '@/domain/UserInfo'
|
||||
import Setup from '@/app/content/views/Setup'
|
||||
import MessageListDomain from '@/domain/MessageList'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Toaster } from 'sonner'
|
||||
|
||||
import DanmakuContainer from './components/DanmakuContainer'
|
||||
import DanmakuDomain from '@/domain/Danmaku'
|
||||
import AppStatusDomain from '@/domain/AppStatus'
|
||||
import { checkDarkMode, cn } from '@/utils'
|
||||
import VirtualRoomDomain from '@/domain/VirtualRoom'
|
||||
|
||||
/**
|
||||
* Fix requestAnimationFrame error in jest
|
||||
* @see https://github.com/facebook/react/issues/16606
|
||||
* @see https://bugzilla.mozilla.org/show_bug.cgi?id=1469304
|
||||
*/
|
||||
if (import.meta.env.FIREFOX) {
|
||||
window.requestAnimationFrame = window.requestAnimationFrame.bind(window)
|
||||
}
|
||||
import { indexDBStorage } from '@/domain/impls/Storage'
|
||||
import { APP_OPEN_STATUS_STORAGE_KEY } from '@/constants/config'
|
||||
import LogoIcon0 from '@/assets/images/logo-0.svg'
|
||||
import LogoIcon1 from '@/assets/images/logo-1.svg'
|
||||
import LogoIcon2 from '@/assets/images/logo-2.svg'
|
||||
import LogoIcon3 from '@/assets/images/logo-3.svg'
|
||||
import LogoIcon4 from '@/assets/images/logo-4.svg'
|
||||
import LogoIcon5 from '@/assets/images/logo-5.svg'
|
||||
import LogoIcon6 from '@/assets/images/logo-6.svg'
|
||||
import { getDay } from 'date-fns'
|
||||
|
||||
export default function App() {
|
||||
const send = useRemeshSend()
|
||||
const chatRoomDomain = useRemeshDomain(ChatRoomDomain())
|
||||
const virtualRoomDomain = useRemeshDomain(VirtualRoomDomain())
|
||||
const roomDomain = useRemeshDomain(RoomDomain())
|
||||
const userInfoDomain = useRemeshDomain(UserInfoDomain())
|
||||
const messageListDomain = useRemeshDomain(MessageListDomain())
|
||||
const danmakuDomain = useRemeshDomain(DanmakuDomain())
|
||||
const danmakuIsEnabled = useRemeshQuery(danmakuDomain.query.IsEnabledQuery())
|
||||
const userInfoSetFinished = useRemeshQuery(userInfoDomain.query.UserInfoSetIsFinishedQuery())
|
||||
const messageListLoadFinished = useRemeshQuery(messageListDomain.query.LoadIsFinishedQuery())
|
||||
const userInfoLoadFinished = useRemeshQuery(userInfoDomain.query.UserInfoLoadIsFinishedQuery())
|
||||
const appStatusDomain = useRemeshDomain(AppStatusDomain())
|
||||
const appStatusLoadIsFinished = useRemeshQuery(appStatusDomain.query.StatusLoadIsFinishedQuery())
|
||||
const chatRoomJoinIsFinished = useRemeshQuery(chatRoomDomain.query.JoinIsFinishedQuery())
|
||||
const virtualRoomJoinIsFinished = useRemeshQuery(virtualRoomDomain.query.JoinIsFinishedQuery())
|
||||
const messageListLoadFinished = useRemeshQuery(messageListDomain.query.MessageListLoadIsFinishedQuery())
|
||||
|
||||
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())
|
||||
}
|
||||
const DayLogo = [LogoIcon0, LogoIcon1, LogoIcon2, LogoIcon3, LogoIcon4, LogoIcon5, LogoIcon6][getDay(Date())]
|
||||
|
||||
useEffect(() => {
|
||||
if (messageListLoadFinished) {
|
||||
if (userInfoSetFinished) {
|
||||
joinRoom()
|
||||
send(roomDomain.command.JoinRoomCommand())
|
||||
} else {
|
||||
// Clear simulated data when refreshing on the setup page
|
||||
send(messageListDomain.command.ClearListCommand())
|
||||
}
|
||||
}
|
||||
return () => leaveRoom()
|
||||
}, [userInfoSetFinished, messageListLoadFinished])
|
||||
|
||||
useEffect(() => {
|
||||
danmakuIsEnabled && send(danmakuDomain.command.MountCommand(danmakuContainerRef.current!))
|
||||
return () => {
|
||||
danmakuIsEnabled && send(danmakuDomain.command.UnmountCommand())
|
||||
}
|
||||
}, [danmakuIsEnabled])
|
||||
const [appOpen, setAppOpen] = useState(false)
|
||||
|
||||
const handleToggleApp = async () => {
|
||||
const value = !appOpen
|
||||
setAppOpen(value)
|
||||
await indexDBStorage.setItem<boolean>(APP_OPEN_STATUS_STORAGE_KEY, value)
|
||||
}
|
||||
|
||||
const getAppOpenStatus = async () => {
|
||||
const value = await indexDBStorage.getItem<boolean>(APP_OPEN_STATUS_STORAGE_KEY)
|
||||
setAppOpen(!!value)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('beforeunload', leaveRoom)
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', leaveRoom)
|
||||
}
|
||||
getAppOpenStatus()
|
||||
}, [])
|
||||
|
||||
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>
|
||||
<>
|
||||
<AppContainer open={appOpen}>
|
||||
<Header />
|
||||
<Main />
|
||||
<Footer />
|
||||
{notUserInfo && <Setup />}
|
||||
<Toaster richColors offset="70px" visibleToasts={1} position="top-center"></Toaster>
|
||||
</AppContainer>
|
||||
<AppButton onClick={handleToggleApp}>
|
||||
<DayLogo></DayLogo>
|
||||
</AppButton>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,19 +0,0 @@
|
|||
import { cn } from '@/utils'
|
||||
import { forwardRef } from 'react'
|
||||
|
||||
export interface DanmakuContainerProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
const DanmakuContainer = forwardRef<HTMLDivElement, DanmakuContainerProps>(({ className }, ref) => {
|
||||
return (
|
||||
<div
|
||||
className={cn('fixed left-0 top-0 z-infinity w-full h-full invisible pointer-events-none shadow-md', className)}
|
||||
ref={ref}
|
||||
></div>
|
||||
)
|
||||
})
|
||||
|
||||
DanmakuContainer.displayName = 'DanmakuContainer'
|
||||
|
||||
export default DanmakuContainer
|
|
@ -1,38 +0,0 @@
|
|||
import { Avatar, AvatarFallback } from '@/components/ui/Avatar'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { TextMessage } from '@/domain/ChatRoom'
|
||||
import { cn } from '@/utils'
|
||||
import { AvatarImage } from '@radix-ui/react-avatar'
|
||||
import { FC, MouseEvent } from 'react'
|
||||
|
||||
export interface PromptItemProps {
|
||||
data: TextMessage
|
||||
className?: string
|
||||
onClick?: (e: MouseEvent<HTMLButtonElement>) => void
|
||||
onMouseEnter?: (e: MouseEvent<HTMLButtonElement>) => void
|
||||
onMouseLeave?: (e: MouseEvent<HTMLButtonElement>) => void
|
||||
}
|
||||
|
||||
const DanmakuMessage: FC<PromptItemProps> = ({ data, className, onClick, onMouseEnter, onMouseLeave }) => {
|
||||
return (
|
||||
<Button
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'flex justify-center pointer-events-auto visible gap-x-2 border border-slate-50 px-2.5 py-0.5 rounded-full bg-primary/30 text-base font-medium text-white backdrop-blur-md',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Avatar className="size-5">
|
||||
<AvatarImage src={data.userAvatar} className="size-full" alt="avatar" />
|
||||
<AvatarFallback>{data.username.at(0)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="max-w-44 truncate">{data.body}</div>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
DanmakuMessage.displayName = 'DanmakuMessage'
|
||||
|
||||
export default DanmakuMessage
|
|
@ -10,7 +10,7 @@ export interface EmojiButtonProps {
|
|||
onSelect?: (value: string) => void
|
||||
}
|
||||
|
||||
const emojiGroups = chunk([...EMOJI_LIST], 6)
|
||||
const emojiGroups = chunk([...EMOJI_LIST], 8)
|
||||
|
||||
// BUG: https://github.com/radix-ui/primitives/pull/2433
|
||||
// BUG https://github.com/radix-ui/primitives/issues/1666
|
||||
|
@ -30,23 +30,20 @@ const EmojiButton: FC<EmojiButtonProps> = ({ onSelect }) => {
|
|||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="dark:text-white">
|
||||
<Button variant="ghost" size="icon">
|
||||
<SmileIcon size={20} />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="z-infinity w-64 overflow-hidden rounded-xl p-0 dark:bg-slate-900"
|
||||
onCloseAutoFocus={handleCloseAutoFocus}
|
||||
>
|
||||
<ScrollArea className="size-64 p-1">
|
||||
<PopoverContent className="z-infinity w-72 px-0" onCloseAutoFocus={handleCloseAutoFocus}>
|
||||
<ScrollArea className="size-72 px-3">
|
||||
{emojiGroups.map((group, index) => {
|
||||
return (
|
||||
<div key={index} className="grid grid-cols-6">
|
||||
<div key={index} className="grid grid-cols-8">
|
||||
{group.map((emoji, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
size="icon"
|
||||
className="text-xl"
|
||||
size="sm"
|
||||
className="text-base"
|
||||
variant="ghost"
|
||||
onClick={() => handleSelect(emoji)}
|
||||
>
|
||||
|
|
|
@ -1,34 +0,0 @@
|
|||
import { Button } from '@/components/ui/Button'
|
||||
import { createElement } from '@/utils'
|
||||
import { ImageIcon } from 'lucide-react'
|
||||
|
||||
export interface ImageButtonProps {
|
||||
onSelect?: (file: File) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const ImageButton = ({ onSelect, disabled }: ImageButtonProps) => {
|
||||
const handleClick = () => {
|
||||
const input = createElement<HTMLInputElement>(`<input type="file" accept="image/png,image/jpeg,image/webp" />`)
|
||||
|
||||
input.addEventListener(
|
||||
'change',
|
||||
async (e: Event) => {
|
||||
onSelect?.((e.target as HTMLInputElement).files![0])
|
||||
},
|
||||
{ once: true }
|
||||
)
|
||||
|
||||
input.click()
|
||||
}
|
||||
|
||||
return (
|
||||
<Button disabled={disabled} onClick={handleClick} variant="ghost" size="icon" className="dark:text-white">
|
||||
<ImageIcon size={20} />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
ImageButton.displayName = 'ImageButton'
|
||||
|
||||
export default ImageButton
|
|
@ -1,7 +1,6 @@
|
|||
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
|
||||
|
@ -34,18 +33,14 @@ const LikeButton: FC<LikeButtonProps> & { Icon: FC<LikeButtonIconProps> } = ({
|
|||
onClick={handleClick}
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
'grid items-center overflow-hidden rounded-full leading-none transition-all select-none dark:bg-slate-600',
|
||||
checked ? 'text-orange-500' : 'text-slate-500 dark:text-slate-100',
|
||||
'grid items-center overflow-hidden rounded-full leading-none transition-all select-none',
|
||||
checked ? 'text-orange-500' : 'text-slate-500',
|
||||
count ? 'grid-cols-[auto_1fr] gap-x-1' : 'grid-cols-[auto_0fr] gap-x-0'
|
||||
)}
|
||||
size="xs"
|
||||
>
|
||||
{children}
|
||||
{!!count && (
|
||||
<span className="min-w-0 text-xs">
|
||||
{import.meta.env.FIREFOX ? <span className="tabular-nums">{count}</span> : <NumberFlow value={count} />}
|
||||
</span>
|
||||
)}
|
||||
{!!count && <span className="min-w-0 text-xs">{count}</span>}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
import { forwardRef, type ChangeEvent, CompositionEvent, type KeyboardEvent, ClipboardEvent } from 'react'
|
||||
import { type ChangeEvent, type KeyboardEvent } from 'react'
|
||||
|
||||
import { cn } from '@/utils'
|
||||
import React from 'react'
|
||||
import { Textarea } from '@/components/ui/Textarea'
|
||||
import { Markdown } from '@/components/ui/Markdown'
|
||||
import { cn } from '@/utils'
|
||||
import { ScrollArea } from '@/components/ui/ScrollArea'
|
||||
import LoadingIcon from '@/assets/images/loading.svg'
|
||||
|
||||
export interface MessageInputProps {
|
||||
value?: string
|
||||
|
@ -12,73 +13,45 @@ export interface MessageInputProps {
|
|||
preview?: boolean
|
||||
autoFocus?: boolean
|
||||
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
|
||||
onInput?: (value: string) => void
|
||||
onEnter?: (value: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Need @ syntax highlighting? Waiting for textarea to support Highlight API
|
||||
*
|
||||
* @see https://github.com/w3c/csswg-drafts/issues/4603
|
||||
*/
|
||||
const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(
|
||||
(
|
||||
{
|
||||
value = '',
|
||||
className,
|
||||
maxLength = 500,
|
||||
onInput,
|
||||
onPaste,
|
||||
onKeyDown,
|
||||
onCompositionStart,
|
||||
onCompositionEnd,
|
||||
autoFocus,
|
||||
disabled,
|
||||
loading
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const MessageInput = React.forwardRef<HTMLTextAreaElement, MessageInputProps>(
|
||||
({ value = '', className, maxLength = 500, onInput, onEnter, preview, autoFocus, disabled }, ref) => {
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !(e.shiftKey || e.ctrlKey || e.altKey || e.metaKey)) {
|
||||
e.preventDefault()
|
||||
onEnter?.(value)
|
||||
}
|
||||
}
|
||||
const handleInput = (e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
onInput?.(e.target.value)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('relative', className)}>
|
||||
<ScrollArea className="box-border max-h-28 w-full rounded-lg border border-input bg-background ring-offset-background focus-within:ring-1 focus-within:ring-ring 2xl:max-h-40">
|
||||
<Textarea
|
||||
ref={ref}
|
||||
onPaste={onPaste}
|
||||
onKeyDown={onKeyDown}
|
||||
autoFocus={autoFocus}
|
||||
maxLength={maxLength}
|
||||
className={cn(
|
||||
'box-border resize-none whitespace-pre-wrap break-words border-none bg-slate-100 pb-5 [field-sizing:content] [word-break:break-word] focus:ring-0 focus:ring-offset-0 dark:bg-slate-800',
|
||||
{
|
||||
'disabled:opacity-100': loading
|
||||
}
|
||||
)}
|
||||
rows={2}
|
||||
value={value}
|
||||
spellCheck={false}
|
||||
onCompositionStart={onCompositionStart}
|
||||
onCompositionEnd={onCompositionEnd}
|
||||
placeholder="Type your message here."
|
||||
onInput={onInput}
|
||||
disabled={disabled || loading}
|
||||
/>
|
||||
</ScrollArea>
|
||||
<div
|
||||
className={cn('absolute bottom-1 right-3 rounded-lg text-xs text-slate-400', {
|
||||
'opacity-50': disabled || loading
|
||||
})}
|
||||
>
|
||||
{preview ? (
|
||||
<Markdown className="max-h-28 rounded-lg border border-input bg-gray-50 2xl:max-h-40">{value}</Markdown>
|
||||
) : (
|
||||
<ScrollArea className="box-border max-h-28 w-full rounded-lg border border-input bg-background ring-offset-background focus-within:ring-1 focus-within:ring-ring 2xl:max-h-40">
|
||||
<Textarea
|
||||
ref={ref}
|
||||
onKeyDown={handleKeyDown}
|
||||
autoFocus={autoFocus}
|
||||
maxLength={maxLength}
|
||||
className="box-border resize-none whitespace-pre-wrap break-words border-none bg-gray-50 pb-5 [field-sizing:content] focus:ring-0 focus:ring-offset-0"
|
||||
rows={2}
|
||||
value={value}
|
||||
placeholder="Type your message here."
|
||||
onInput={handleInput}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</ScrollArea>
|
||||
)}
|
||||
<div className="absolute bottom-1 right-3 rounded-lg text-xs text-slate-400">
|
||||
{value?.length ?? 0}/{maxLength}
|
||||
</div>
|
||||
{loading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-slate-800 after:absolute after:inset-0 after:backdrop-blur-xs dark:text-slate-100">
|
||||
<LoadingIcon className="relative z-10 size-10"></LoadingIcon>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
import { type FC } from 'react'
|
||||
import { FrownIcon, HeartIcon } from 'lucide-react'
|
||||
import { FrownIcon, ThumbsUpIcon } from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/Badge'
|
||||
import LikeButton from './LikeButton'
|
||||
import FormatDate from './FormatDate'
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/Avatar'
|
||||
|
||||
import { Markdown } from '@/components/Markdown'
|
||||
import { Markdown } from '@/components/ui/Markdown'
|
||||
import { type NormalMessage } from '@/domain/MessageList'
|
||||
import { cn } from '@/utils'
|
||||
|
||||
|
@ -25,53 +26,34 @@ const MessageItem: FC<MessageItemProps> = (props) => {
|
|||
const handleHateChange = (checked: boolean) => {
|
||||
props.onHateChange?.(checked)
|
||||
}
|
||||
|
||||
let content = props.data.body
|
||||
|
||||
// Check if the field exists, compatible with old data
|
||||
if (props.data.atUsers) {
|
||||
const atUserPositions = props.data.atUsers.flatMap((user) =>
|
||||
user.positions.map((position) => ({ username: user.username, userId: user.userId, position }))
|
||||
)
|
||||
|
||||
// Replace from back to front according to position to avoid affecting previous indices
|
||||
atUserPositions
|
||||
.sort((a, b) => b.position[0] - a.position[0])
|
||||
.forEach(({ position, username }) => {
|
||||
const [start, end] = position
|
||||
content = `${content.slice(0, start)} **@${username}** ${content.slice(end + 1)}`
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-index={props.index}
|
||||
className={cn(
|
||||
'box-border grid grid-cols-[auto_1fr] gap-x-2 px-4 first:pt-4 last:pb-4 dark:text-slate-50',
|
||||
props.className
|
||||
)}
|
||||
className={cn('box-border grid grid-cols-[auto_1fr] gap-x-2 px-4 first:pt-4 last:pb-4', props.className)}
|
||||
>
|
||||
<Avatar>
|
||||
<AvatarImage src={props.data.userAvatar} className="size-full" alt="avatar" />
|
||||
<AvatarImage src={props.data.userAvatar} alt="avatar" />
|
||||
<AvatarFallback>{props.data.username.at(0)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="overflow-hidden">
|
||||
<div className="grid grid-cols-[1fr_auto] items-center gap-x-2 leading-none">
|
||||
<div className="truncate text-sm font-semibold text-slate-600 dark:text-slate-50">{props.data.username}</div>
|
||||
<FormatDate className="text-xs text-slate-400 dark:text-slate-100" date={props.data.sendTime}></FormatDate>
|
||||
<div className="overflow-hidden text-ellipsis text-sm font-semibold text-slate-600">
|
||||
{props.data.username}
|
||||
</div>
|
||||
<FormatDate className="text-xs text-slate-400" date={props.data.date}></FormatDate>
|
||||
</div>
|
||||
<div>
|
||||
<div className="pb-2">
|
||||
<Markdown>{content}</Markdown>
|
||||
<Markdown>{props.data.body}</Markdown>
|
||||
</div>
|
||||
<div className="grid grid-flow-col justify-end gap-x-2 leading-none dark:text-slate-600">
|
||||
<div className="grid grid-flow-col justify-end gap-x-2 leading-none">
|
||||
<LikeButton
|
||||
checked={props.like}
|
||||
onChange={(checked) => handleLikeChange(checked)}
|
||||
count={props.data.likeUsers.length}
|
||||
>
|
||||
<LikeButton.Icon>
|
||||
<HeartIcon size={14}></HeartIcon>
|
||||
<ThumbsUpIcon size={14}></ThumbsUpIcon>
|
||||
</LikeButton.Icon>
|
||||
</LikeButton>
|
||||
<LikeButton
|
||||
|
|
|
@ -12,9 +12,8 @@ const MessageList: FC<MessageListProps> = ({ children }) => {
|
|||
const [scrollParentRef, setScrollParentRef] = useState<HTMLDivElement | null>(null)
|
||||
|
||||
return (
|
||||
<ScrollArea ref={setScrollParentRef} className="dark:bg-slate-900">
|
||||
<ScrollArea ref={setScrollParentRef}>
|
||||
<Virtuoso
|
||||
defaultItemHeight={108}
|
||||
followOutput={(isAtBottom: boolean) => (isAtBottom ? 'smooth' : 'auto')}
|
||||
initialTopMostItemIndex={{ index: 'LAST', align: 'end' }}
|
||||
data={children}
|
||||
|
|
|
@ -12,10 +12,10 @@ export interface PromptItemProps {
|
|||
|
||||
const PromptItem: FC<PromptItemProps> = ({ data, className }) => {
|
||||
return (
|
||||
<div className={cn('flex justify-center py-1 px-4 ', className)}>
|
||||
<Badge variant="secondary" className="gap-x-2 rounded-full px-2 font-medium text-slate-400 dark:bg-slate-800">
|
||||
<div className={cn('flex justify-center py-1 px-4', className)}>
|
||||
<Badge variant="secondary" className="gap-x-2 rounded-full font-medium text-slate-400">
|
||||
<Avatar className="size-4">
|
||||
<AvatarImage src={data.userAvatar} className="size-full" alt="avatar" />
|
||||
<AvatarImage src={data.userAvatar} alt="avatar" />
|
||||
<AvatarFallback>{data.username.at(0)}</AvatarFallback>
|
||||
</Avatar>
|
||||
{data.body}
|
||||
|
|
|
@ -1,50 +1,29 @@
|
|||
import React from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { Remesh } from 'remesh'
|
||||
import { RemeshRoot, RemeshScope } from 'remesh-react'
|
||||
// import { RemeshLogger } from 'remesh-logger'
|
||||
import { RemeshRoot } from 'remesh-react'
|
||||
import { RemeshLogger } from 'remesh-logger'
|
||||
import { defineContentScript } from 'wxt/sandbox'
|
||||
import { createShadowRootUi } from 'wxt/client'
|
||||
|
||||
import App from './App'
|
||||
import { LocalStorageImpl, IndexDBStorageImpl, BrowserSyncStorageImpl } from '@/domain/impls/Storage'
|
||||
import { DanmakuImpl } from '@/domain/impls/Danmaku'
|
||||
import { NotificationImpl } from '@/domain/impls/Notification'
|
||||
import { ToastImpl } from '@/domain/impls/Toast'
|
||||
import { ChatRoomImpl } from '@/domain/impls/ChatRoom'
|
||||
import { VirtualRoomImpl } from '@/domain/impls/VirtualRoom'
|
||||
// Remove import after merging: https://github.com/emilkowalski/sonner/pull/508
|
||||
import '@/assets/styles/sonner.css'
|
||||
import '@/assets/styles/overlay.css'
|
||||
import { IndexDBStorageImpl, BrowserSyncStorageImpl } from '@/domain/impls/Storage'
|
||||
import { PeerRoomImpl } from '@/domain/impls/PeerRoom'
|
||||
// import { PeerRoomImpl } from '@/domain/impls/PeerRoom2'
|
||||
import '@/assets/styles/tailwind.css'
|
||||
import NotificationDomain from '@/domain/Notification'
|
||||
import '@/assets/styles/sonner.css'
|
||||
import { createElement } from '@/utils'
|
||||
import { ToastImpl } from '@/domain/impls/Toast'
|
||||
|
||||
export default defineContentScript({
|
||||
cssInjectionMode: 'ui',
|
||||
runAt: 'document_end',
|
||||
matches: ['https://*/*'],
|
||||
excludeMatches: ['*://localhost/*', '*://127.0.0.1/*', '*://*.csdn.net/*', '*://*.csdn.com/*'],
|
||||
excludeMatches: ['*://localhost/*', '*://127.0.0.1/*'],
|
||||
async main(ctx) {
|
||||
window.CSS.registerProperty({
|
||||
name: '--shimmer-angle',
|
||||
syntax: '<angle>',
|
||||
inherits: false,
|
||||
initialValue: '0deg'
|
||||
})
|
||||
|
||||
const store = Remesh.store({
|
||||
externs: [
|
||||
LocalStorageImpl,
|
||||
IndexDBStorageImpl,
|
||||
BrowserSyncStorageImpl,
|
||||
ChatRoomImpl,
|
||||
VirtualRoomImpl,
|
||||
ToastImpl,
|
||||
DanmakuImpl,
|
||||
NotificationImpl
|
||||
]
|
||||
// inspectors: __DEV__ ? [RemeshLogger()] : []
|
||||
externs: [IndexDBStorageImpl, BrowserSyncStorageImpl, PeerRoomImpl, ToastImpl],
|
||||
inspectors: __DEV__ ? [RemeshLogger()] : []
|
||||
})
|
||||
|
||||
const ui = await createShadowRootUi(ctx, {
|
||||
|
@ -53,17 +32,15 @@ export default defineContentScript({
|
|||
anchor: 'body',
|
||||
append: 'last',
|
||||
mode: 'open',
|
||||
isolateEvents: ['keyup', 'keydown', 'keypress'],
|
||||
onMount: (container) => {
|
||||
const app = createElement('<div id="root"></div>')
|
||||
const app = createElement('<div id="app"></div>')
|
||||
container.append(app)
|
||||
|
||||
const root = createRoot(app)
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<RemeshRoot store={store}>
|
||||
<RemeshScope domains={[NotificationDomain()]}>
|
||||
<App />
|
||||
</RemeshScope>
|
||||
<App />
|
||||
</RemeshRoot>
|
||||
</React.StrictMode>
|
||||
)
|
||||
|
|
|
@ -1,67 +1,37 @@
|
|||
import { type FC, useState, type MouseEvent, useEffect } from 'react'
|
||||
import { SettingsIcon, MoonIcon, SunIcon, HandIcon } from 'lucide-react'
|
||||
import { type ReactNode, type FC, useState, type MouseEvent, useRef, useEffect } from 'react'
|
||||
import { SettingsIcon, MoonIcon, SunIcon } from 'lucide-react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
|
||||
import { browser } from 'wxt/browser'
|
||||
import { useRemeshDomain, useRemeshQuery, useRemeshSend } from 'remesh-react'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { EVENT } from '@/constants/event'
|
||||
import UserInfoDomain from '@/domain/UserInfo'
|
||||
import useTriggerAway from '@/hooks/useTriggerAway'
|
||||
import { checkDarkMode, cn } from '@/utils'
|
||||
import LogoIcon0 from '@/assets/images/logo-0.svg'
|
||||
import LogoIcon1 from '@/assets/images/logo-1.svg'
|
||||
import LogoIcon2 from '@/assets/images/logo-2.svg'
|
||||
import LogoIcon3 from '@/assets/images/logo-3.svg'
|
||||
import LogoIcon4 from '@/assets/images/logo-4.svg'
|
||||
import LogoIcon5 from '@/assets/images/logo-5.svg'
|
||||
import LogoIcon6 from '@/assets/images/logo-6.svg'
|
||||
import AppStatusDomain from '@/domain/AppStatus'
|
||||
import { getDay } from 'date-fns'
|
||||
import { messenger } from '@/messenger'
|
||||
import useDraggable from '@/hooks/useDraggable'
|
||||
import useWindowResize from '@/hooks/useWindowResize'
|
||||
import useClickAway from '@/hooks/useClickAway'
|
||||
import { checkSystemDarkMode, cn } from '@/utils'
|
||||
import ToastDomain from '@/domain/Toast'
|
||||
|
||||
export interface AppButtonProps {
|
||||
className?: string
|
||||
children?: ReactNode
|
||||
onClick?: (e: MouseEvent<HTMLButtonElement>) => void
|
||||
}
|
||||
|
||||
const AppButton: FC<AppButtonProps> = ({ className }) => {
|
||||
const AppButton: FC<AppButtonProps> = ({ children, onClick }) => {
|
||||
const send = useRemeshSend()
|
||||
const appStatusDomain = useRemeshDomain(AppStatusDomain())
|
||||
const appOpenStatus = useRemeshQuery(appStatusDomain.query.OpenQuery())
|
||||
const hasUnreadQuery = useRemeshQuery(appStatusDomain.query.HasUnreadQuery())
|
||||
const userInfoDomain = useRemeshDomain(UserInfoDomain())
|
||||
const userInfo = useRemeshQuery(userInfoDomain.query.UserInfoQuery())
|
||||
const appPosition = useRemeshQuery(appStatusDomain.query.PositionQuery())
|
||||
const toastDomain = useRemeshDomain(ToastDomain())
|
||||
|
||||
const DayLogo = [LogoIcon0, LogoIcon1, LogoIcon2, LogoIcon3, LogoIcon4, LogoIcon5, LogoIcon6][getDay(Date())]
|
||||
|
||||
const isDarkMode = userInfo?.themeMode === 'dark' ? true : userInfo?.themeMode === 'light' ? false : checkDarkMode()
|
||||
const isDarkMode =
|
||||
userInfo?.themeMode === 'dark' ? true : userInfo?.themeMode === 'light' ? false : checkSystemDarkMode()
|
||||
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
|
||||
const {
|
||||
x,
|
||||
y,
|
||||
setRef: appButtonRef
|
||||
} = useDraggable({
|
||||
initX: appPosition.x,
|
||||
initY: appPosition.y,
|
||||
minX: 50,
|
||||
maxX: window.innerWidth - 50,
|
||||
maxY: window.innerHeight - 22,
|
||||
minY: 750
|
||||
})
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useWindowResize(({ width, height }) => {
|
||||
send(appStatusDomain.command.UpdatePositionCommand({ x: width - 50, y: height - 22 }))
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
send(appStatusDomain.command.UpdatePositionCommand({ x, y }))
|
||||
}, [x, y])
|
||||
|
||||
const { setRef: appMenuRef } = useTriggerAway(['click'], () => setMenuOpen(false))
|
||||
useClickAway(menuRef, () => {
|
||||
setMenuOpen(false)
|
||||
}, ['click'])
|
||||
|
||||
const handleToggleMenu = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault()
|
||||
|
@ -70,6 +40,7 @@ const AppButton: FC<AppButtonProps> = ({ className }) => {
|
|||
|
||||
const handleSwitchTheme = () => {
|
||||
if (userInfo) {
|
||||
send(toastDomain.command.WarningCommand('Developer is too lazy~'))
|
||||
send(userInfoDomain.command.UpdateUserInfoCommand({ ...userInfo, themeMode: isDarkMode ? 'light' : 'dark' }))
|
||||
} else {
|
||||
handleOpenOptionsPage()
|
||||
|
@ -77,27 +48,15 @@ const AppButton: FC<AppButtonProps> = ({ className }) => {
|
|||
}
|
||||
|
||||
const handleOpenOptionsPage = () => {
|
||||
messenger.sendMessage(EVENT.OPTIONS_PAGE_OPEN, undefined)
|
||||
}
|
||||
|
||||
const handleToggleApp = () => {
|
||||
send(appStatusDomain.command.UpdateOpenCommand(!appOpenStatus))
|
||||
browser.runtime.sendMessage(EVENT.OPEN_OPTIONS_PAGE)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={appMenuRef}
|
||||
className={cn('fixed bottom-5 right-5 z-infinity grid w-min select-none justify-center gap-y-3', className)}
|
||||
style={{
|
||||
left: `calc(${appPosition.x}px)`,
|
||||
bottom: `calc(100vh - ${appPosition.y}px)`,
|
||||
transform: 'translateX(-50%)'
|
||||
}}
|
||||
>
|
||||
<div ref={menuRef} className="fixed bottom-5 right-5 z-infinity grid select-none justify-center gap-y-3">
|
||||
<AnimatePresence>
|
||||
{menuOpen && (
|
||||
<motion.div
|
||||
className="z-10 grid gap-y-3"
|
||||
className="z-infinity grid gap-y-3"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 12 }}
|
||||
|
@ -106,13 +65,13 @@ const AppButton: FC<AppButtonProps> = ({ className }) => {
|
|||
<Button
|
||||
onClick={handleSwitchTheme}
|
||||
variant="outline"
|
||||
className="relative size-10 overflow-hidden rounded-full p-0 shadow dark:border-slate-600"
|
||||
className="relative size-10 overflow-hidden rounded-full p-0 shadow"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute grid grid-rows-[repeat(2,minmax(0,2.5rem))] w-full justify-center items-center transition-all duration-300',
|
||||
'absolute grid grid-rows-[repeat(2,minmax(0,2.5rem))] w-full justify-center items-center transition-all duration-500',
|
||||
isDarkMode ? 'top-0' : '-top-10',
|
||||
isDarkMode ? 'bg-slate-950 text-white' : 'bg-white text-orange-400'
|
||||
isDarkMode ? 'bg-slate-800 text-white' : 'bg-white text-orange-400'
|
||||
)}
|
||||
>
|
||||
<MoonIcon size={20} />
|
||||
|
@ -123,43 +82,19 @@ const AppButton: FC<AppButtonProps> = ({ className }) => {
|
|||
<Button
|
||||
onClick={handleOpenOptionsPage}
|
||||
variant="outline"
|
||||
className="size-10 rounded-full p-0 shadow dark:border-slate-600"
|
||||
className="pointer-events-auto size-10 rounded-full p-0 shadow"
|
||||
>
|
||||
<SettingsIcon size={20} />
|
||||
</Button>
|
||||
<Button
|
||||
ref={appButtonRef}
|
||||
variant="outline"
|
||||
className="size-10 cursor-grab rounded-full p-0 shadow dark:border-slate-600"
|
||||
>
|
||||
<HandIcon size={20} />
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<Button
|
||||
onClick={handleToggleApp}
|
||||
onClick={onClick}
|
||||
onContextMenu={handleToggleMenu}
|
||||
className="relative z-20 size-11 rounded-full p-0 text-xs shadow-lg shadow-slate-500/50 after:absolute after:-inset-0.5 after:z-10 after:animate-[shimmer_2s_linear_infinite] after:rounded-full after:bg-[conic-gradient(from_var(--shimmer-angle),theme(colors.slate.500)_0%,theme(colors.white)_10%,theme(colors.slate.500)_20%)]"
|
||||
className="relative z-10 size-11 rounded-full p-0 text-xs shadow-lg shadow-slate-500/50"
|
||||
>
|
||||
<AnimatePresence>
|
||||
{hasUnreadQuery && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.1 }}
|
||||
className="absolute -right-1 -top-1 z-30 flex size-5 items-center justify-center"
|
||||
>
|
||||
<span
|
||||
className={cn('absolute inline-flex size-full animate-ping rounded-full opacity-75', 'bg-orange-400')}
|
||||
></span>
|
||||
<span className={cn('relative inline-flex size-3 rounded-full', 'bg-orange-500')}></span>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<DayLogo className="relative z-20 max-h-full max-w-full overflow-hidden"></DayLogo>
|
||||
{children}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
|
|
44
src/app/content/views/AppContainer/index.tsx
Normal file
44
src/app/content/views/AppContainer/index.tsx
Normal file
|
@ -0,0 +1,44 @@
|
|||
import { type ReactNode, type FC } from 'react'
|
||||
import useResizable from '@/hooks/useResizable'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
|
||||
export interface AppContainerProps {
|
||||
children?: ReactNode
|
||||
open?: boolean
|
||||
}
|
||||
|
||||
const AppContainer: FC<AppContainerProps> = ({ children, open }) => {
|
||||
const { size, ref } = useResizable({
|
||||
initSize: Math.max(375, window.innerWidth / 6),
|
||||
maxSize: Math.min(750, window.innerWidth / 3),
|
||||
minSize: Math.max(375, window.innerWidth / 5),
|
||||
direction: 'left'
|
||||
})
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10, x: 10 }}
|
||||
animate={{ opacity: 1, y: 0, x: 0 }}
|
||||
exit={{ opacity: 0, y: 10, x: 10 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
style={{
|
||||
width: `${size}px`
|
||||
}}
|
||||
className="fixed bottom-10 right-10 z-infinity box-border grid h-screen max-h-[min(calc(100vh_-60px),_1200px)] grid-flow-col grid-rows-[auto_1fr_auto] rounded-xl bg-slate-50 font-sans shadow-2xl"
|
||||
>
|
||||
{children}
|
||||
<div
|
||||
ref={ref}
|
||||
className="absolute inset-y-3 -left-0.5 z-20 w-1 cursor-ew-resize rounded-xl bg-slate-100 opacity-0 shadow transition-opacity duration-200 ease-in hover:opacity-100"
|
||||
></div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
AppContainer.displayName = 'AppContainer'
|
||||
|
||||
export default AppContainer
|
|
@ -1,69 +0,0 @@
|
|||
import { type ReactNode, type FC, useState } from 'react'
|
||||
import useResizable from '@/hooks/useResizable'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import AppStatusDomain from '@/domain/AppStatus'
|
||||
import { useRemeshDomain, useRemeshQuery } from 'remesh-react'
|
||||
import { cn } from '@/utils'
|
||||
import useWindowResize from '@/hooks/useWindowResize'
|
||||
|
||||
export interface AppMainProps {
|
||||
children?: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
const AppMain: FC<AppMainProps> = ({ children, className }) => {
|
||||
const appStatusDomain = useRemeshDomain(AppStatusDomain())
|
||||
const appOpenStatus = useRemeshQuery(appStatusDomain.query.OpenQuery())
|
||||
const { x, y } = useRemeshQuery(appStatusDomain.query.PositionQuery())
|
||||
|
||||
const { width } = useWindowResize()
|
||||
|
||||
const isOnRightSide = x >= width / 2 + 50
|
||||
|
||||
const { size, setRef } = useResizable({
|
||||
initSize: Math.max(375, width / 6),
|
||||
maxSize: Math.max(Math.min(750, width / 3), 375),
|
||||
minSize: Math.max(375, width / 6),
|
||||
direction: isOnRightSide ? 'left' : 'right'
|
||||
})
|
||||
|
||||
const [isAnimationComplete, setAnimationComplete] = useState(false)
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{appOpenStatus && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10, x: isOnRightSide ? '-100%' : '0' }}
|
||||
animate={{ opacity: 1, y: 0, x: isOnRightSide ? '-100%' : '0' }}
|
||||
exit={{ opacity: 0, y: 10 }}
|
||||
transition={{ duration: 0.3, ease: 'linear' }}
|
||||
onAnimationEnd={() => setAnimationComplete(true)}
|
||||
onAnimationStart={() => setAnimationComplete(false)}
|
||||
style={{
|
||||
width: `${size}px`,
|
||||
left: `${x}px`,
|
||||
bottom: `calc(100vh - ${y}px + 22px)`
|
||||
}}
|
||||
className={cn(
|
||||
`fixed inset-y-10 right-10 z-infinity mb-0 mt-auto box-border grid max-h-[min(calc(100vh_-60px),_1000px)] grid-flow-col grid-rows-[auto_1fr_auto] rounded-xl bg-slate-50 dark:bg-slate-950 font-sans shadow-2xl`,
|
||||
className,
|
||||
{ 'transition-transform': isAnimationComplete }
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
<div
|
||||
ref={setRef}
|
||||
className={cn(
|
||||
'absolute inset-y-3 z-infinity w-1 dark:bg-slate-600 cursor-ew-resize rounded-xl bg-slate-100 opacity-0 shadow transition-opacity duration-200 ease-in hover:opacity-100',
|
||||
isOnRightSide ? '-left-0.5' : '-right-0.5'
|
||||
)}
|
||||
></div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
AppMain.displayName = 'AppMain'
|
||||
|
||||
export default AppMain
|
|
@ -1,367 +1,50 @@
|
|||
import { ChangeEvent, useMemo, useRef, useState, KeyboardEvent, type FC, ClipboardEvent } from 'react'
|
||||
import { useRef, type FC } 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, WEB_RTC_MAX_MESSAGE_SIZE } from '@/constants/config'
|
||||
import ChatRoomDomain from '@/domain/ChatRoom'
|
||||
import useCursorPosition from '@/hooks/useCursorPosition'
|
||||
import useShareRef from '@/hooks/useShareRef'
|
||||
import { Presence } from '@radix-ui/react-presence'
|
||||
import { Portal } from '@radix-ui/react-portal'
|
||||
import useTriggerAway from '@/hooks/useTriggerAway'
|
||||
import { ScrollArea } from '@/components/ui/ScrollArea'
|
||||
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso'
|
||||
import UserInfoDomain from '@/domain/UserInfo'
|
||||
import { blobToBase64, cn, compressImage, getRootNode, getTextByteSize, getTextSimilarity } from '@/utils'
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/Avatar'
|
||||
import { AvatarImage } from '@radix-ui/react-avatar'
|
||||
import ToastDomain from '@/domain/Toast'
|
||||
import ImageButton from '../../components/ImageButton'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { MESSAGE_MAX_LENGTH } from '@/constants/config'
|
||||
import RoomDomain from '@/domain/Room'
|
||||
|
||||
const Footer: FC = () => {
|
||||
const send = useRemeshSend()
|
||||
const toastDomain = useRemeshDomain(ToastDomain())
|
||||
const chatRoomDomain = useRemeshDomain(ChatRoomDomain())
|
||||
const roomDomain = useRemeshDomain(RoomDomain())
|
||||
const messageInputDomain = useRemeshDomain(MessageInputDomain())
|
||||
const message = useRemeshQuery(messageInputDomain.query.MessageQuery())
|
||||
const userInfoDomain = useRemeshDomain(UserInfoDomain())
|
||||
const userInfo = useRemeshQuery(userInfoDomain.query.UserInfoQuery())
|
||||
const userList = useRemeshQuery(chatRoomDomain.query.UserListQuery())
|
||||
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null)
|
||||
const { x, y, selectionStart, selectionEnd, setRef } = useCursorPosition()
|
||||
|
||||
const [autoCompleteListShow, setAutoCompleteListShow] = useState(false)
|
||||
const [scrollParentRef, setScrollParentRef] = useState<HTMLDivElement | null>(null)
|
||||
const autoCompleteListRef = useRef<HTMLDivElement>(null)
|
||||
const { setRef: setAutoCompleteListRef } = useTriggerAway(['click'], () => setAutoCompleteListShow(false))
|
||||
const shareAutoCompleteListRef = useShareRef(setAutoCompleteListRef, autoCompleteListRef)
|
||||
const isComposing = useRef(false)
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null)
|
||||
const [inputLoading, setInputLoading] = useState(false)
|
||||
|
||||
const shareRef = useShareRef(inputRef, setRef)
|
||||
|
||||
/**
|
||||
* When inserting a username using the @ syntax, record the username's position information and the mapping relationship between the position information and userId to distinguish between users with the same name.
|
||||
*/
|
||||
const atUserRecord = useRef<Map<string, Set<[number, number]>>>(new Map())
|
||||
const imageRecord = useRef<Map<string, string>>(new Map())
|
||||
|
||||
const updateAtUserAtRecord = useMemo(
|
||||
() => (message: string, start: number, end: number, offset: number, atUserId?: string) => {
|
||||
const positions: [number, number] = [start, end]
|
||||
|
||||
// If the editing position is before the end position of @user, update the editing position.
|
||||
// "@user" => "E@user"
|
||||
// "@user" => "@useEr"
|
||||
// "@user" => "@user @user"
|
||||
atUserRecord.current.forEach((item, userId) => {
|
||||
const positionList = [...item].map<[number, number]>((item) => {
|
||||
const inBefore = Math.min(start, end) <= item[1]
|
||||
return inBefore ? [item[0] + offset + (end - start), item[1] + offset + (end - start)] : item
|
||||
})
|
||||
atUserRecord.current.set(userId, new Set(positionList))
|
||||
})
|
||||
|
||||
// Insert a new @user record
|
||||
if (atUserId) {
|
||||
atUserRecord.current.set(atUserId, atUserRecord.current.get(atUserId)?.add(positions) ?? new Set([positions]))
|
||||
}
|
||||
|
||||
// After moving, check if the @user in the message matches the saved position record. If not, it means the @user has been edited, so delete that record.
|
||||
// Filter out records where the stored position does not match the actual position.
|
||||
atUserRecord.current.forEach((item, userId) => {
|
||||
// Pre-calculate the offset after InputCommand
|
||||
const positionList = [...item].filter((item) => {
|
||||
const username = message.slice(item[0], item[1] + 1)
|
||||
return username === `@${userList.find((user) => user.userId === userId)?.username}`
|
||||
})
|
||||
if (positionList.length) {
|
||||
atUserRecord.current.set(userId, new Set(positionList))
|
||||
} else {
|
||||
atUserRecord.current.delete(userId)
|
||||
}
|
||||
})
|
||||
},
|
||||
[userList]
|
||||
)
|
||||
|
||||
const [selectedUserIndex, setSelectedUserIndex] = useState(0)
|
||||
const [searchNameKeyword, setSearchNameKeyword] = useState('')
|
||||
|
||||
const autoCompleteList = useMemo(() => {
|
||||
return userList
|
||||
.filter((user) => user.userId !== userInfo?.id)
|
||||
.map((item) => ({
|
||||
...item,
|
||||
similarity: getTextSimilarity(searchNameKeyword.toLowerCase(), item.username.toLowerCase())
|
||||
}))
|
||||
.toSorted((a, b) => b.similarity - a.similarity)
|
||||
}, [searchNameKeyword, userList, userInfo])
|
||||
|
||||
const selectedUser = autoCompleteList.find((_, index) => index === selectedUserIndex)!
|
||||
|
||||
// Replace the hash URL in  with base64 and update the atUserRecord.
|
||||
const transformMessage = async (message: string) => {
|
||||
let newMessage = message
|
||||
const matchList = [...message.matchAll(/!\[Image\]\(hash:([^\s)]+)\)/g)]
|
||||
matchList?.forEach((match) => {
|
||||
const base64 = imageRecord.current.get(match[1])
|
||||
if (base64) {
|
||||
const base64Syntax = ``
|
||||
const hashSyntax = match[0]
|
||||
const startIndex = match.index
|
||||
const endIndex = startIndex + base64Syntax.length - hashSyntax.length
|
||||
newMessage = newMessage.replace(hashSyntax, base64Syntax)
|
||||
updateAtUserAtRecord(newMessage, startIndex, endIndex, 0)
|
||||
}
|
||||
})
|
||||
return newMessage
|
||||
const handleInput = (value: string) => {
|
||||
send(messageInputDomain.command.InputCommand(value))
|
||||
}
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!`${message}`.trim()) {
|
||||
return send(toastDomain.command.WarningCommand('Message cannot be empty.'))
|
||||
}
|
||||
const transformedMessage = await transformMessage(message)
|
||||
const atUsers = [...atUserRecord.current]
|
||||
.map(([userId, positions]) => {
|
||||
const user = userList.find((user) => user.userId === userId)
|
||||
return (user ? { ...user, positions: [...positions] } : undefined)!
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
const newMessage = { body: transformedMessage, atUsers }
|
||||
const byteSize = getTextByteSize(JSON.stringify(newMessage))
|
||||
|
||||
if (byteSize > WEB_RTC_MAX_MESSAGE_SIZE) {
|
||||
return send(toastDomain.command.WarningCommand('Message size cannot exceed 256KiB.'))
|
||||
}
|
||||
|
||||
send(chatRoomDomain.command.SendTextMessageCommand({ body: transformedMessage, atUsers }))
|
||||
const handleSend = () => {
|
||||
if (!message.trim()) return
|
||||
send(roomDomain.command.SendTextMessageCommand(message.trim()))
|
||||
send(messageInputDomain.command.ClearCommand())
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (autoCompleteListShow && autoCompleteList.length) {
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
const length = autoCompleteList.length
|
||||
const prevIndex = selectedUserIndex
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
const index = (prevIndex + 1) % length
|
||||
setSelectedUserIndex(index)
|
||||
virtuosoRef.current?.scrollIntoView({ index })
|
||||
e.preventDefault()
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
const index = (prevIndex - 1 + length) % length
|
||||
setSelectedUserIndex(index)
|
||||
virtuosoRef.current?.scrollIntoView({ index })
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
if (['Escape', 'Backspace', 'Delete', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
|
||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
const isDeleteAt = message.at(selectionStart - 1) === '@'
|
||||
setAutoCompleteListShow(!isDeleteAt)
|
||||
} else {
|
||||
setAutoCompleteListShow(false)
|
||||
}
|
||||
setSelectedUserIndex(0)
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === 'Enter' && !(e.shiftKey || e.ctrlKey || e.altKey || e.metaKey)) {
|
||||
if (isComposing.current) return
|
||||
|
||||
if (autoCompleteListShow && autoCompleteList.length) {
|
||||
handleInjectAtSyntax(selectedUser.username)
|
||||
} else {
|
||||
handleSend()
|
||||
}
|
||||
e.preventDefault()
|
||||
}
|
||||
const handleEmojiSelect = (emoji: string) => {
|
||||
send(messageInputDomain.command.InputCommand(`${message}${emoji}`))
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
|
||||
const handleInput = (e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const currentMessage = e.target.value
|
||||
|
||||
if (autoCompleteListShow) {
|
||||
const target = e.target as HTMLTextAreaElement
|
||||
if (target.value) {
|
||||
const atIndex = target.value.lastIndexOf('@', selectionEnd - 1)
|
||||
if (atIndex !== -1) {
|
||||
const keyword = target.value.slice(atIndex + 1, selectionEnd)
|
||||
setSearchNameKeyword(keyword)
|
||||
setSelectedUserIndex(0)
|
||||
virtuosoRef.current?.scrollIntoView({ index: 0 })
|
||||
}
|
||||
} else {
|
||||
setAutoCompleteListShow(false)
|
||||
}
|
||||
}
|
||||
|
||||
const event = e.nativeEvent as InputEvent
|
||||
|
||||
if (event.data === '@' && autoCompleteList.length) {
|
||||
setAutoCompleteListShow(true)
|
||||
}
|
||||
|
||||
// Pre-calculate the offset after InputCommand
|
||||
const start = selectionStart
|
||||
const end = selectionStart + currentMessage.length - message.length
|
||||
|
||||
updateAtUserAtRecord(currentMessage, start, end, 0)
|
||||
|
||||
send(messageInputDomain.command.InputCommand(currentMessage))
|
||||
}
|
||||
|
||||
const handlePaste = async (e: ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
const file = e.nativeEvent.clipboardData?.files[0]
|
||||
if (['image/png', 'image/jpeg', 'image/webp'].includes(file?.type ?? '')) {
|
||||
handleInjectImage(file!)
|
||||
}
|
||||
}
|
||||
|
||||
const handleInjectEmoji = (emoji: string) => {
|
||||
const newMessage = `${message.slice(0, selectionEnd)}${emoji}${message.slice(selectionEnd)}`
|
||||
|
||||
// Pre-calculate the offset after InputCommand
|
||||
const start = selectionStart
|
||||
const end = selectionEnd + newMessage.length - message.length
|
||||
|
||||
updateAtUserAtRecord(newMessage, start, end, 0)
|
||||
|
||||
send(messageInputDomain.command.InputCommand(newMessage))
|
||||
|
||||
requestIdleCallback(() => {
|
||||
inputRef.current?.setSelectionRange(end, end)
|
||||
inputRef.current?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
const handleInjectImage = async (file: File) => {
|
||||
try {
|
||||
setInputLoading(true)
|
||||
|
||||
const blob = await compressImage({
|
||||
input: file,
|
||||
targetSize: 30 * 1024,
|
||||
outputType: file.size > 30 * 1024 ? 'image/webp' : undefined
|
||||
})
|
||||
|
||||
const base64 = await blobToBase64(blob)
|
||||
const hash = nanoid()
|
||||
const newMessage = `${message.slice(0, selectionEnd)}${message.slice(selectionEnd)}`
|
||||
|
||||
const start = selectionStart
|
||||
const end = selectionEnd + newMessage.length - message.length
|
||||
|
||||
updateAtUserAtRecord(newMessage, start, end, 0)
|
||||
send(messageInputDomain.command.InputCommand(newMessage))
|
||||
|
||||
imageRecord.current.set(hash, base64)
|
||||
|
||||
requestIdleCallback(() => {
|
||||
inputRef.current?.setSelectionRange(end, end)
|
||||
inputRef.current?.focus()
|
||||
})
|
||||
} catch (error) {
|
||||
send(toastDomain.command.ErrorCommand((error as Error).message))
|
||||
} finally {
|
||||
setInputLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleInjectAtSyntax = (username: string) => {
|
||||
const atIndex = message.lastIndexOf('@', selectionEnd - 1)
|
||||
// Determine if there is a space before @
|
||||
const hasBeforeSpace = message.slice(atIndex - 1, atIndex) === ' '
|
||||
const hasAfterSpace = message.slice(selectionEnd, selectionEnd + 1) === ' '
|
||||
|
||||
const atText = `${hasBeforeSpace ? '' : ' '}@${username}${hasAfterSpace ? '' : ' '}`
|
||||
const newMessage = message.slice(0, atIndex) + `${atText}` + message.slice(selectionEnd)
|
||||
|
||||
setAutoCompleteListShow(false)
|
||||
|
||||
// Pre-calculate the offset after InputCommand
|
||||
const start = atIndex
|
||||
const end = selectionStart + newMessage.length - message.length
|
||||
|
||||
const atUserPosition: [number, number] = [start + (hasBeforeSpace ? 0 : +1), end - 1 + (hasAfterSpace ? 0 : -1)]
|
||||
|
||||
// Calculate the difference after replacing @text with @user
|
||||
const offset = newMessage.length - message.length - (atUserPosition[1] - atUserPosition[0])
|
||||
|
||||
updateAtUserAtRecord(newMessage, ...atUserPosition, offset, selectedUser.userId)
|
||||
|
||||
send(messageInputDomain.command.InputCommand(newMessage))
|
||||
requestIdleCallback(() => {
|
||||
inputRef.current!.setSelectionRange(end, end)
|
||||
inputRef.current!.focus()
|
||||
})
|
||||
}
|
||||
|
||||
const root = getRootNode()
|
||||
|
||||
return (
|
||||
<div className="relative z-10 grid gap-y-2 rounded-b-xl px-4 pb-4 pt-2 before:pointer-events-none before:absolute before:inset-x-4 before:-top-2 before:h-2 before:bg-gradient-to-t before:from-slate-50 before:from-30% before:to-transparent dark:bg-slate-900 before:dark:from-slate-900">
|
||||
<Presence present={autoCompleteListShow}>
|
||||
<Portal
|
||||
container={root}
|
||||
ref={shareAutoCompleteListRef}
|
||||
className="fixed z-infinity w-36 -translate-y-full overflow-hidden rounded-lg border bg-popover text-popover-foreground shadow-md duration-300 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95"
|
||||
style={{ left: `min(${x}px, 100vw - 160px)`, top: `${y}px` }}
|
||||
>
|
||||
<ScrollArea className="max-h-[204px] min-h-9 p-1" ref={setScrollParentRef}>
|
||||
<Virtuoso
|
||||
ref={virtuosoRef}
|
||||
data={autoCompleteList}
|
||||
defaultItemHeight={28}
|
||||
context={{ currentItemIndex: selectedUserIndex }}
|
||||
customScrollParent={scrollParentRef!}
|
||||
itemContent={(index, user) => (
|
||||
<div
|
||||
key={user.userId}
|
||||
onClick={() => handleInjectAtSyntax(user.username)}
|
||||
onMouseEnter={() => setSelectedUserIndex(index)}
|
||||
className={cn(
|
||||
'flex cursor-pointer select-none items-center gap-x-2 rounded-md px-2 py-1.5 outline-none',
|
||||
{
|
||||
'bg-accent text-accent-foreground': index === selectedUserIndex
|
||||
}
|
||||
)}
|
||||
>
|
||||
<Avatar className="size-4 shrink-0">
|
||||
<AvatarImage className="size-full" src={user.userAvatar} alt="avatar" />
|
||||
<AvatarFallback>{user.username.at(0)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 truncate text-xs text-slate-500 dark:text-slate-50">{user.username}</div>
|
||||
</div>
|
||||
)}
|
||||
></Virtuoso>
|
||||
</ScrollArea>
|
||||
</Portal>
|
||||
</Presence>
|
||||
<div className="relative z-10 grid gap-y-2 px-4 pb-4 pt-2 before:pointer-events-none before:absolute before:inset-x-4 before:-top-2 before:h-2 before:bg-gradient-to-t before:from-slate-50 before:from-30% before:to-transparent">
|
||||
<MessageInput
|
||||
ref={shareRef}
|
||||
ref={inputRef}
|
||||
value={message}
|
||||
onEnter={handleSend}
|
||||
onInput={handleInput}
|
||||
loading={inputLoading}
|
||||
onPaste={handlePaste}
|
||||
onKeyDown={handleKeyDown}
|
||||
maxLength={MESSAGE_MAX_LENGTH}
|
||||
></MessageInput>
|
||||
<div className="flex items-center">
|
||||
<EmojiButton onSelect={handleInjectEmoji}></EmojiButton>
|
||||
<ImageButton disabled={inputLoading} onSelect={handleInjectImage}></ImageButton>
|
||||
<EmojiButton onSelect={handleEmojiSelect}></EmojiButton>
|
||||
{/* <Button variant="ghost" size="icon">
|
||||
<ImageIcon size={20} />
|
||||
</Button> */}
|
||||
<Button className="ml-auto" size="sm" onClick={handleSend}>
|
||||
<span className="mr-2">Send</span>
|
||||
<CornerDownLeftIcon className="text-slate-400" size={12}></CornerDownLeftIcon>
|
||||
|
|
|
@ -1,48 +1,22 @@
|
|||
import { useState, type FC } from 'react'
|
||||
import { type FC } from 'react'
|
||||
import { Globe2Icon } from 'lucide-react'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/Avatar'
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/HoverCard'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { cn, getSiteInfo } from '@/utils'
|
||||
import { useRemeshDomain, useRemeshQuery } from 'remesh-react'
|
||||
import 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'
|
||||
import RoomDomain from '@/domain/Room'
|
||||
|
||||
const Header: FC = () => {
|
||||
const siteInfo = getSiteInfo()
|
||||
const chatRoomDomain = useRemeshDomain(ChatRoomDomain())
|
||||
const virtualRoomDomain = useRemeshDomain(VirtualRoomDomain())
|
||||
const chatUserList = useRemeshQuery(chatRoomDomain.query.UserListQuery())
|
||||
const virtualUserList = useRemeshQuery(virtualRoomDomain.query.UserListQuery())
|
||||
const chatOnlineCount = chatUserList.length
|
||||
|
||||
const virtualOnlineGroup = virtualUserList
|
||||
.flatMap((user) => user.fromInfos.map((from) => ({ from, user })))
|
||||
.reduce<(FromInfo & { users: RoomUser[] })[]>((acc, item) => {
|
||||
const existSite = acc.find((group) => group.origin === item.from.origin)
|
||||
if (existSite) {
|
||||
const existUser = existSite.users.find((user) => user.userId === item.user.userId)
|
||||
!existUser && existSite.users.push(item.user)
|
||||
} else {
|
||||
acc.push({ ...item.from, users: [item.user] })
|
||||
}
|
||||
return acc
|
||||
}, [])
|
||||
.sort((a, b) => b.users.length - a.users.length)
|
||||
|
||||
const [chatUserListScrollParentRef, setChatUserListScrollParentRef] = useState<HTMLDivElement | null>(null)
|
||||
const [virtualOnlineGroupScrollParentRef, setVirtualOnlineGroupScrollParentRef] = useState<HTMLDivElement | null>(
|
||||
null
|
||||
)
|
||||
const roomDomain = useRemeshDomain(RoomDomain())
|
||||
const userList = useRemeshQuery(roomDomain.query.UserListQuery())
|
||||
const peerId = useRemeshQuery(roomDomain.query.PeerIdQuery())
|
||||
const onlineCount = userList.length
|
||||
|
||||
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 rounded-sm">
|
||||
<div className="z-10 grid h-12 grid-flow-col grid-cols-[theme('spacing.20')_auto_theme('spacing.20')] items-center justify-between rounded-t-xl bg-white px-4 backdrop-blur-lg">
|
||||
<Avatar className="size-8">
|
||||
<AvatarImage src={siteInfo.icon} alt="favicon" />
|
||||
<AvatarFallback>
|
||||
<Globe2Icon size="100%" className="text-gray-400" />
|
||||
|
@ -50,123 +24,47 @@ const Header: FC = () => {
|
|||
</Avatar>
|
||||
<HoverCard>
|
||||
<HoverCardTrigger asChild>
|
||||
<Button className="overflow-hidden rounded-md p-2" variant="link">
|
||||
<span className="truncate text-lg font-semibold text-slate-600 dark:text-slate-50">
|
||||
<Button className="overflow-hidden" variant="link">
|
||||
<span className="truncate text-lg font-semibold text-slate-600">
|
||||
{siteInfo.hostname.replace(/^www\./i, '')}
|
||||
{/* {peerId} */}
|
||||
</span>
|
||||
</Button>
|
||||
</HoverCardTrigger>
|
||||
<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>
|
||||
)}
|
||||
></Virtuoso>
|
||||
</ScrollArea>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
<HoverCard>
|
||||
<HoverCardTrigger asChild>
|
||||
<Button className=" rounded-md p-0 hover:no-underline" variant="link">
|
||||
<div className="relative flex items-center gap-x-1 text-nowrap text-xs text-slate-500 hover:after:absolute hover:after:bottom-0 hover:after:left-0 hover:after:h-px hover:after:w-full hover:after:bg-black">
|
||||
<div className="flex items-center gap-x-1 pt-px">
|
||||
<span className="relative flex size-2">
|
||||
<span
|
||||
className={cn(
|
||||
'absolute inline-flex size-full animate-ping rounded-full opacity-75',
|
||||
chatOnlineCount > 1 ? 'bg-green-400' : 'bg-orange-400'
|
||||
)}
|
||||
></span>
|
||||
<span
|
||||
className={cn(
|
||||
'relative inline-flex size-full rounded-full',
|
||||
chatOnlineCount > 1 ? 'bg-green-500' : 'bg-orange-500'
|
||||
)}
|
||||
></span>
|
||||
</span>
|
||||
<span className="flex items-center leading-none dark:text-slate-50">
|
||||
<span className="py-[0.25em]">ONLINE</span>
|
||||
</span>
|
||||
</div>
|
||||
{import.meta.env.FIREFOX ? (
|
||||
<span className="tabular-nums">{Math.min(chatUserList.length, 99)}</span>
|
||||
) : (
|
||||
<span className="tabular-nums">
|
||||
<NumberFlow className="tabular-nums" willChange value={Math.min(chatUserList.length, 99)} />
|
||||
{chatUserList.length > 99 && <span className="text-xs">+</span>}
|
||||
</span>
|
||||
<HoverCardContent className="w-80">
|
||||
<div className="grid grid-cols-[auto_1fr] gap-x-4">
|
||||
<Avatar className="size-14">
|
||||
<AvatarImage src={siteInfo.icon} alt="favicon" />
|
||||
<AvatarFallback>
|
||||
<Globe2Icon size="100%" className="text-gray-400" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid items-center">
|
||||
<h4 className="truncate text-sm font-semibold">{siteInfo.title}</h4>
|
||||
{siteInfo.description && (
|
||||
<p className="line-clamp-2 max-h-8 text-xs text-slate-500">{siteInfo.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="w-36 rounded-lg p-0">
|
||||
<ScrollArea type="scroll" className="max-h-[204px] min-h-9 p-1" ref={setChatUserListScrollParentRef}>
|
||||
<Virtuoso
|
||||
data={chatUserList}
|
||||
defaultItemHeight={28}
|
||||
customScrollParent={chatUserListScrollParentRef!}
|
||||
itemContent={(_index, user) => (
|
||||
<div className={cn('flex items-center gap-x-2 rounded-md px-2 py-1.5 outline-none')}>
|
||||
<Avatar className="size-4 shrink-0">
|
||||
<AvatarImage className="size-full" src={user.userAvatar} alt="avatar" />
|
||||
<AvatarFallback>{user.username.at(0)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 truncate text-xs text-slate-500 dark:text-slate-50">{user.username}</div>
|
||||
</div>
|
||||
)}
|
||||
></Virtuoso>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
<div className="flex items-center gap-x-1 text-sm text-slate-500">
|
||||
<span className="relative flex size-2">
|
||||
<span
|
||||
className={cn(
|
||||
'absolute inline-flex size-full animate-ping rounded-full opacity-75',
|
||||
onlineCount > 1 ? 'bg-green-400' : 'bg-orange-400'
|
||||
)}
|
||||
></span>
|
||||
<span
|
||||
className={cn(
|
||||
'relative inline-flex size-2 rounded-full',
|
||||
onlineCount > 1 ? 'bg-green-500' : 'bg-orange-500'
|
||||
)}
|
||||
></span>
|
||||
</span>
|
||||
<span>ONLINE {onlineCount > 99 ? '99+' : onlineCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -5,56 +5,58 @@ import MessageList from '../../components/MessageList'
|
|||
import MessageItem from '../../components/MessageItem'
|
||||
import PromptItem from '../../components/PromptItem'
|
||||
import UserInfoDomain from '@/domain/UserInfo'
|
||||
import ChatRoomDomain, { MessageType } from '@/domain/ChatRoom'
|
||||
import RoomDomain, { MessageType } from '@/domain/Room'
|
||||
import MessageListDomain from '@/domain/MessageList'
|
||||
import BlurFade from '@/components/magicui/blur-fade'
|
||||
|
||||
const Main: FC = () => {
|
||||
const send = useRemeshSend()
|
||||
const messageListDomain = useRemeshDomain(MessageListDomain())
|
||||
const chatRoomDomain = useRemeshDomain(ChatRoomDomain())
|
||||
const roomDomain = useRemeshDomain(RoomDomain())
|
||||
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
|
||||
})
|
||||
.toSorted((a, b) => a.sendTime - b.sendTime)
|
||||
}
|
||||
return message
|
||||
})
|
||||
|
||||
const handleLikeChange = (messageId: string) => {
|
||||
send(chatRoomDomain.command.SendLikeMessageCommand(messageId))
|
||||
send(roomDomain.command.SendLikeMessageCommand(messageId))
|
||||
}
|
||||
|
||||
const handleHateChange = (messageId: string) => {
|
||||
send(chatRoomDomain.command.SendHateMessageCommand(messageId))
|
||||
send(roomDomain.command.SendHateMessageCommand(messageId))
|
||||
}
|
||||
|
||||
return (
|
||||
<MessageList>
|
||||
{messageList.map((message, index) =>
|
||||
message.type === MessageType.Normal ? (
|
||||
<MessageItem
|
||||
key={message.id}
|
||||
data={message}
|
||||
like={message.like}
|
||||
hate={message.hate}
|
||||
onLikeChange={() => handleLikeChange(message.id)}
|
||||
onHateChange={() => handleHateChange(message.id)}
|
||||
className="duration-300 animate-in fade-in-0"
|
||||
></MessageItem>
|
||||
<BlurFade key={message.id} duration={0.1} yOffset={0}>
|
||||
<MessageItem
|
||||
key={message.id}
|
||||
data={message}
|
||||
like={message.like}
|
||||
hate={message.hate}
|
||||
onLikeChange={() => handleLikeChange(message.id)}
|
||||
onHateChange={() => handleHateChange(message.id)}
|
||||
></MessageItem>
|
||||
</BlurFade>
|
||||
) : (
|
||||
<PromptItem
|
||||
key={message.id}
|
||||
data={message}
|
||||
className={`${index === 0 ? 'pt-4' : ''} ${index === messageList.length - 1 ? 'pb-4' : ''}`}
|
||||
></PromptItem>
|
||||
<BlurFade key={message.id} duration={0.1} yOffset={0}>
|
||||
<PromptItem
|
||||
key={message.id}
|
||||
data={message}
|
||||
className={`${index === 0 ? 'pt-4' : ''} ${index === messageList.length - 1 ? 'pb-4' : ''}`}
|
||||
></PromptItem>
|
||||
</BlurFade>
|
||||
)
|
||||
)}
|
||||
</MessageList>
|
||||
|
|
|
@ -2,16 +2,16 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/Avatar'
|
|||
import { MAX_AVATAR_SIZE } from '@/constants/config'
|
||||
import MessageListDomain, { Message, MessageType } from '@/domain/MessageList'
|
||||
import UserInfoDomain, { UserInfo } from '@/domain/UserInfo'
|
||||
import { generateRandomAvatar, generateRandomName } from '@/utils'
|
||||
import { checkSystemDarkMode, generateRandomAvatar, generateRandomName } from '@/utils'
|
||||
import { UserIcon } from 'lucide-react'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { FC, useEffect, useState } from 'react'
|
||||
import { useRemeshDomain, useRemeshSend } from 'remesh-react'
|
||||
import Timer from '@resreq/timer'
|
||||
import ExampleImage from '@/assets/images/example.jpg'
|
||||
import PulsatingButton from '@/components/magicui/PulsatingButton'
|
||||
import BlurFade from '@/components/magicui/BlurFade'
|
||||
import WordPullUp from '@/components/magicui/WordPullUp'
|
||||
import PulsatingButton from '@/components/magicui/pulsating-button'
|
||||
import BlurFade from '@/components/magicui/blur-fade'
|
||||
import WordPullUp from '@/components/magicui/word-pull-up'
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
const mockTextList = [
|
||||
|
@ -33,16 +33,15 @@ const mockTextList = [
|
|||
``
|
||||
]
|
||||
|
||||
let printTextList = [...mockTextList]
|
||||
|
||||
const generateUserInfo = async (): Promise<UserInfo> => {
|
||||
return {
|
||||
id: nanoid(),
|
||||
name: generateRandomName(),
|
||||
avatar: await generateRandomAvatar(MAX_AVATAR_SIZE),
|
||||
createTime: Date.now(),
|
||||
themeMode: 'system',
|
||||
danmakuEnabled: true,
|
||||
notificationEnabled: true,
|
||||
notificationType: 'all'
|
||||
themeMode: checkSystemDarkMode() ? 'dark' : 'system'
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -50,16 +49,14 @@ const generateMessage = async (userInfo: UserInfo): Promise<Message> => {
|
|||
const { name: username, avatar: userAvatar, id: userId } = userInfo
|
||||
return {
|
||||
id: nanoid(),
|
||||
body: mockTextList.shift()!,
|
||||
sendTime: Date.now(),
|
||||
receiveTime: Date.now(),
|
||||
body: printTextList.shift()!,
|
||||
date: Date.now(),
|
||||
type: MessageType.Normal,
|
||||
userId,
|
||||
username,
|
||||
userAvatar,
|
||||
likeUsers: mockTextList.length ? [] : [{ userId, username, userAvatar }],
|
||||
hateUsers: [],
|
||||
atUsers: []
|
||||
hateUsers: []
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -69,10 +66,9 @@ const Setup: FC = () => {
|
|||
const messageListDomain = useRemeshDomain(MessageListDomain())
|
||||
|
||||
const [userInfo, setUserInfo] = useState<UserInfo>()
|
||||
|
||||
const handleSetup = () => {
|
||||
send(messageListDomain.command.ClearListCommand())
|
||||
send(userInfoDomain.command.UpdateUserInfoCommand(userInfo!))
|
||||
send(messageListDomain.command.ClearListCommand())
|
||||
}
|
||||
|
||||
const refreshUserInfo = async () => {
|
||||
|
@ -86,16 +82,18 @@ const Setup: FC = () => {
|
|||
}
|
||||
|
||||
useEffect(() => {
|
||||
printTextList.length === 0 && (printTextList = [...mockTextList])
|
||||
const timer = new Timer(
|
||||
async () => {
|
||||
await createMessage(await refreshUserInfo())
|
||||
},
|
||||
{ delay: 2000, immediate: true, limit: mockTextList.length }
|
||||
{ delay: 2000, immediate: true, limit: printTextList.length }
|
||||
)
|
||||
|
||||
timer.start()
|
||||
return () => {
|
||||
timer.stop()
|
||||
send(messageListDomain.command.ClearListCommand())
|
||||
printTextList.length === 0 && send(messageListDomain.command.ClearListCommand())
|
||||
}
|
||||
}, [])
|
||||
|
||||
|
@ -104,7 +102,7 @@ const Setup: FC = () => {
|
|||
<div className="m-auto flex flex-col items-center justify-center gap-y-8 pb-40 drop-shadow-lg">
|
||||
<BlurFade key={userInfo?.avatar} inView>
|
||||
<Avatar className="size-24 cursor-pointer border-4 border-white ">
|
||||
<AvatarImage src={userInfo?.avatar} className="size-full" alt="avatar" />
|
||||
<AvatarImage src={userInfo?.avatar} alt="avatar" />
|
||||
<AvatarFallback>
|
||||
<UserIcon size={30} className="text-slate-400" />
|
||||
</AvatarFallback>
|
||||
|
|
|
@ -3,32 +3,16 @@ import Main from './components/Main'
|
|||
import ProfileForm from './components/ProfileForm'
|
||||
import BadgeList from './components/BadgeList'
|
||||
import Layout from './components/Layout'
|
||||
import VersionLink from './components/VersionLink'
|
||||
import { useRemeshDomain, useRemeshQuery } from 'remesh-react'
|
||||
import UserInfoDomain from '@/domain/UserInfo'
|
||||
|
||||
function App() {
|
||||
const userInfoDomain = useRemeshDomain(UserInfoDomain())
|
||||
const userInfo = useRemeshQuery(userInfoDomain.query.UserInfoQuery())
|
||||
return (
|
||||
<div className={userInfo?.themeMode}>
|
||||
<Layout>
|
||||
<VersionLink></VersionLink>
|
||||
<Main>
|
||||
<ProfileForm></ProfileForm>
|
||||
<Toaster
|
||||
richColors
|
||||
position="top-center"
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast: 'dark:bg-slate-950 border dark:border-slate-600'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Main>
|
||||
<BadgeList></BadgeList>
|
||||
</Layout>
|
||||
</div>
|
||||
<Layout>
|
||||
<Main>
|
||||
<ProfileForm></ProfileForm>
|
||||
<Toaster richColors position="top-center" />
|
||||
</Main>
|
||||
<BadgeList></BadgeList>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -3,7 +3,7 @@ import { type ChangeEvent } from 'react'
|
|||
import { ImagePlusIcon } from 'lucide-react'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/Avatar'
|
||||
import { Label } from '@/components/ui/Label'
|
||||
import { blobToBase64, cn, compressImage } from '@/utils'
|
||||
import { cn, compressImage } from '@/utils'
|
||||
|
||||
export interface AvatarSelectProps {
|
||||
value?: string
|
||||
|
@ -31,10 +31,15 @@ const AvatarSelect = React.forwardRef<HTMLInputElement, AvatarSelectProps>(
|
|||
* In chrome storage.sync, each key-value pair supports a maximum storage of 8kb
|
||||
* and all key-value pairs support a maximum storage of 100kb.
|
||||
*/
|
||||
const blob = await compressImage({ input: file, targetSize: compressSize, outputType: 'image/webp' })
|
||||
const base64 = await blobToBase64(blob)
|
||||
onSuccess?.(base64)
|
||||
onChange?.(base64)
|
||||
const blob = await compressImage({ input: file, targetSize: compressSize })
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
const base64 = e.target?.result as string
|
||||
onSuccess?.(base64)
|
||||
onChange?.(base64)
|
||||
}
|
||||
reader.onerror = () => onError?.(new Error('Failed to read image file.'))
|
||||
reader.readAsDataURL(blob)
|
||||
} catch (error) {
|
||||
onError?.(error as Error)
|
||||
}
|
||||
|
@ -53,19 +58,12 @@ const AvatarSelect = React.forwardRef<HTMLInputElement, AvatarSelectProps>(
|
|||
className
|
||||
)}
|
||||
>
|
||||
<AvatarImage src={value} className="size-full" alt="avatar" />
|
||||
<AvatarImage src={value} alt="avatar" />
|
||||
<AvatarFallback>
|
||||
<ImagePlusIcon size={30} className="text-slate-400 group-hover:text-slate-500" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<input
|
||||
ref={ref}
|
||||
hidden
|
||||
disabled={disabled}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<input ref={ref} hidden disabled={disabled} type="file" accept="image/png,image/jpeg" onChange={handleChange} />
|
||||
</Label>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,16 +1,15 @@
|
|||
import { FC } from 'react'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { GitHubLogoIcon } from '@radix-ui/react-icons'
|
||||
import Link from '@/components/Link'
|
||||
|
||||
const BadgeList: FC = () => {
|
||||
return (
|
||||
<div className="fixed inset-x-1 bottom-4 mx-auto flex w-fit">
|
||||
<div className="fixed inset-x-1 bottom-6 mx-auto flex w-fit">
|
||||
<Button asChild size="lg" variant="ghost" className="rounded-full px-3 text-xl font-semibold text-primary">
|
||||
<Link href="https://github.com/molvqingtai/WebChat">
|
||||
<a href="https://github.com/molvqingtai/WebChat" target="https://github.com/molvqingtai/WebChat">
|
||||
<GitHubLogoIcon className="mr-1 size-6"></GitHubLogoIcon>
|
||||
Github
|
||||
</Link>
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import Meteors from '@/components/magicui/Meteors'
|
||||
import Meteors from '@/components/magicui/meteors'
|
||||
import { FC, ReactNode } from 'react'
|
||||
|
||||
export interface LayoutProps {
|
||||
|
@ -7,7 +7,7 @@ export interface LayoutProps {
|
|||
|
||||
const Layout: FC<LayoutProps> = ({ children }) => {
|
||||
return (
|
||||
<div className={`h-screen w-screen bg-gray-50 bg-[url(@/assets/images/texture.png)] font-sans dark:bg-slate-950`}>
|
||||
<div className="h-screen w-screen bg-gray-50 bg-[url(@/assets/images/texture.png)] font-sans">
|
||||
<div className="fixed left-0 top-0 h-full w-screen overflow-hidden">
|
||||
<Meteors number={30} />
|
||||
</div>
|
||||
|
|
|
@ -7,7 +7,7 @@ export interface MainProps {
|
|||
const Main: FC<MainProps> = ({ children }) => {
|
||||
return (
|
||||
<main className="grid min-h-screen min-w-screen items-center justify-center">
|
||||
<div className="relative rounded-xl bg-slate-50 shadow-lg dark:bg-slate-900 dark:text-slate-50">{children}</div>
|
||||
<div className="relative rounded-xl bg-slate-50 shadow-lg">{children}</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -3,41 +3,41 @@ import { useForm } from 'react-hook-form'
|
|||
import { valibotResolver } from '@hookform/resolvers/valibot'
|
||||
import { useRemeshDomain, useRemeshQuery, useRemeshSend } from 'remesh-react'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { useEffect, type FC } from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import AvatarSelect from './AvatarSelect'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/Form'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import UserInfoDomain, { type UserInfo } from '@/domain/UserInfo'
|
||||
import { cn, generateRandomAvatar } from '@/utils'
|
||||
import { checkSystemDarkMode, generateRandomAvatar } from '@/utils'
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/RadioGroup'
|
||||
import { Label } from '@/components/ui/Label'
|
||||
import { RefreshCcwIcon } from 'lucide-react'
|
||||
import { MAX_AVATAR_SIZE } from '@/constants/config'
|
||||
import { ToastImpl } from '@/domain/impls/Toast'
|
||||
import BlurFade from '@/components/magicui/BlurFade'
|
||||
import { Checkbox } from '@/components/ui/Checkbox'
|
||||
import Link from '@/components/Link'
|
||||
import ToastDomain from '@/domain/Toast'
|
||||
import BlurFade from '@/components/magicui/blur-fade'
|
||||
import debounce from './../../../utils/debounce'
|
||||
|
||||
const defaultUserInfo: UserInfo = {
|
||||
id: nanoid(),
|
||||
name: '',
|
||||
avatar: '',
|
||||
createTime: Date.now(),
|
||||
themeMode: 'system',
|
||||
danmakuEnabled: true,
|
||||
notificationEnabled: true,
|
||||
notificationType: 'all'
|
||||
themeMode: checkSystemDarkMode() ? 'dark' : 'system'
|
||||
}
|
||||
|
||||
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.string([
|
||||
// // toTrimmed(),
|
||||
// v.minBytes(1, 'Please enter your username.'),
|
||||
// v.maxBytes(20, 'Your username cannot exceed 20 bytes.')
|
||||
// ]),
|
||||
name: v.pipe(
|
||||
v.string(),
|
||||
v.trim(),
|
||||
v.minBytes(1, 'Please enter your username.'),
|
||||
v.maxBytes(20, 'Your username cannot exceed 20 bytes.')
|
||||
),
|
||||
|
@ -49,14 +49,12 @@ const formSchema = v.object({
|
|||
themeMode: v.pipe(
|
||||
v.string(),
|
||||
v.union([v.literal('system'), v.literal('light'), v.literal('dark')], 'Please select extension theme mode.')
|
||||
),
|
||||
danmakuEnabled: v.boolean(),
|
||||
notificationEnabled: v.boolean(),
|
||||
notificationType: v.pipe(v.string(), v.union([v.literal('all'), v.literal('at')], 'Please select notification type.'))
|
||||
)
|
||||
})
|
||||
const ProfileForm: FC = () => {
|
||||
|
||||
const ProfileForm = () => {
|
||||
const send = useRemeshSend()
|
||||
const toast = ToastImpl.value
|
||||
const toastDomain = useRemeshDomain(ToastDomain())
|
||||
|
||||
const userInfoDomain = useRemeshDomain(UserInfoDomain())
|
||||
const userInfo = useRemeshQuery(userInfoDomain.query.UserInfoQuery())
|
||||
|
@ -73,15 +71,15 @@ const ProfileForm: FC = () => {
|
|||
|
||||
const handleSubmit = (userInfo: UserInfo) => {
|
||||
send(userInfoDomain.command.UpdateUserInfoCommand(userInfo))
|
||||
toast.success('Saved successfully!')
|
||||
send(toastDomain.command.SuccessCommand('Saved successfully!'))
|
||||
}
|
||||
|
||||
const handleWarning = (error: Error) => {
|
||||
toast.warning(error.message)
|
||||
send(toastDomain.command.WarningCommand(error.message))
|
||||
}
|
||||
|
||||
const handleError = (error: Error) => {
|
||||
toast.error(error.message)
|
||||
send(toastDomain.command.ErrorCommand(error.message))
|
||||
}
|
||||
|
||||
const handleRefreshAvatar = async () => {
|
||||
|
@ -91,49 +89,37 @@ const ProfileForm: FC = () => {
|
|||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
autoComplete="off"
|
||||
className="relative w-[450px] space-y-8 p-14 pt-20"
|
||||
>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} autoComplete="off" className="relative w-96 space-y-8 p-10">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="avatar"
|
||||
render={({ field }) => (
|
||||
<FormItem className="absolute inset-x-1 top-0 mx-auto grid w-fit -translate-y-1/3 justify-items-center">
|
||||
<FormItem className="absolute inset-x-1 top-0 mx-auto grid w-fit -translate-y-1/2 justify-items-center">
|
||||
<FormControl>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<BlurFade key={form.getValues().avatar} duration={0.1}>
|
||||
<AvatarSelect
|
||||
compressSize={MAX_AVATAR_SIZE}
|
||||
onError={handleError}
|
||||
onWarning={handleWarning}
|
||||
className="shadow-lg"
|
||||
{...field}
|
||||
></AvatarSelect>
|
||||
</BlurFade>
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
className="mx-auto flex items-center gap-x-2"
|
||||
onClick={handleRefreshAvatar}
|
||||
>
|
||||
<RefreshCcwIcon size={14} />
|
||||
Ugly Avatar
|
||||
</Button>
|
||||
</div>
|
||||
<BlurFade key={form.getValues().avatar} duration={0.1}>
|
||||
<AvatarSelect
|
||||
compressSize={MAX_AVATAR_SIZE}
|
||||
onError={handleError}
|
||||
onWarning={handleWarning}
|
||||
className="shadow-lg"
|
||||
{...field}
|
||||
></AvatarSelect>
|
||||
</BlurFade>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type="button" size="xs" className="mx-auto flex items-center gap-x-2" onClick={handleRefreshAvatar}>
|
||||
<RefreshCcwIcon size={14} />
|
||||
Ugly Avatar
|
||||
</Button>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="font-semibold">Username</FormLabel>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Please enter your username" {...field} />
|
||||
</FormControl>
|
||||
|
@ -142,125 +128,25 @@ const ProfileForm: FC = () => {
|
|||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="danmakuEnabled"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
defaultChecked={false}
|
||||
id="enable-danmaku"
|
||||
onCheckedChange={field.onChange}
|
||||
checked={field.value}
|
||||
/>
|
||||
<FormLabel className="cursor-pointer font-semibold" htmlFor="enable-danmaku">
|
||||
Enable Danmaku
|
||||
</FormLabel>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Enabling this option will display scrolling messages on the website.
|
||||
<Link className="ml-2 text-primary" href="https://en.wikipedia.org/wiki/Danmaku_subtitling">
|
||||
Wikipedia
|
||||
</Link>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="notificationType"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="notificationEnabled"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
defaultChecked={false}
|
||||
id="enable-notification"
|
||||
onCheckedChange={field.onChange}
|
||||
checked={field.value}
|
||||
/>
|
||||
<FormLabel className="cursor-pointer font-semibold" htmlFor="enable-notification">
|
||||
Enable Notification
|
||||
</FormLabel>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormControl className="pl-6">
|
||||
<RadioGroup
|
||||
disabled={!form.getValues('notificationEnabled')}
|
||||
className="flex gap-x-4"
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="all" id="all" />
|
||||
<Label
|
||||
className={cn(
|
||||
'cursor-pointer',
|
||||
!form.getValues('notificationEnabled') && 'cursor-not-allowed opacity-50'
|
||||
)}
|
||||
htmlFor="all"
|
||||
>
|
||||
All message
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="at" id="at" />
|
||||
<Label
|
||||
className={cn(
|
||||
'cursor-pointer',
|
||||
!form.getValues('notificationEnabled') && 'cursor-not-allowed opacity-50'
|
||||
)}
|
||||
htmlFor="at"
|
||||
>
|
||||
Only @self
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<FormDescription>Enabling this option will display desktop notifications for messages.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="themeMode"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="font-semibold">Theme Mode</FormLabel>
|
||||
<FormLabel>Theme Mode</FormLabel>
|
||||
<FormControl>
|
||||
<RadioGroup className="flex gap-x-4" onValueChange={field.onChange} value={field.value}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="system" id="system" />
|
||||
<Label className="cursor-pointer" htmlFor="system">
|
||||
System
|
||||
</Label>
|
||||
<RadioGroupItem value="system" id="r1" />
|
||||
<Label htmlFor="r1">System</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="light" id="light" />
|
||||
<Label className="cursor-pointer" htmlFor="light">
|
||||
Light
|
||||
</Label>
|
||||
<RadioGroupItem value="light" id="r2" />
|
||||
<Label htmlFor="r2">Light</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="dark" id="dark" />
|
||||
<Label className="cursor-pointer" htmlFor="dark">
|
||||
Dark
|
||||
</Label>
|
||||
<RadioGroupItem value="dark" id="r3" />
|
||||
<Label htmlFor="r3">Dark</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
|
|
|
@ -1,20 +0,0 @@
|
|||
import { FC } from 'react'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import Link from '@/components/Link'
|
||||
import { version } from '@/../package.json'
|
||||
|
||||
const VersionLink: FC = () => {
|
||||
return (
|
||||
<Button
|
||||
size="lg"
|
||||
variant="ghost"
|
||||
className="fixed right-4 top-2 rounded-full px-3 text-base font-medium text-primary"
|
||||
>
|
||||
<Link href="https://github.com/molvqingtai/WebChat/releases">Version: v{version}</Link>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
VersionLink.displayName = 'VersionLink'
|
||||
|
||||
export default VersionLink
|
|
@ -2,6 +2,7 @@ import React from 'react'
|
|||
import ReactDOM from 'react-dom/client'
|
||||
import { Remesh } from 'remesh'
|
||||
import { RemeshRoot } from 'remesh-react'
|
||||
import { RemeshLogger } from 'remesh-logger'
|
||||
import App from './App'
|
||||
import { BrowserSyncStorageImpl } from '@/domain/impls/Storage'
|
||||
import '@/assets/styles/tailwind.css'
|
||||
|
@ -9,7 +10,8 @@ import '@/assets/styles/tailwind.css'
|
|||
import { ToastImpl } from '@/domain/impls/Toast'
|
||||
|
||||
const store = Remesh.store({
|
||||
externs: [BrowserSyncStorageImpl, ToastImpl]
|
||||
externs: [BrowserSyncStorageImpl, ToastImpl],
|
||||
inspectors: [RemeshLogger()]
|
||||
})
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
|
|
|
@ -1,12 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
|
||||
<radialGradient id="a4" cx=".66" fx=".66" cy=".3125" fy=".3125" gradientTransform="scale(1.5)">
|
||||
<stop offset="0" stop-color="currentColor"></stop><stop offset=".3" stop-color="currentColor" stop-opacity=".9"></stop>
|
||||
<stop offset=".6" stop-color="currentColor" stop-opacity=".6"></stop>
|
||||
<stop offset=".8" stop-color="currentColor" stop-opacity=".3"></stop>
|
||||
<stop offset="1" stop-color="currentColor" stop-opacity="0"></stop>
|
||||
</radialGradient>
|
||||
<circle transform-origin="center" fill="none" stroke="url(#a4)" stroke-width="15" stroke-linecap="round" stroke-dasharray="200 1000" stroke-dashoffset="0" cx="100" cy="100" r="70">
|
||||
<animateTransform type="rotate" attributeName="transform" calcMode="spline" dur="2" values="360;0" keyTimes="0;1" keySplines="0 0 1 1" repeatCount="indefinite"></animateTransform>
|
||||
</circle>
|
||||
<circle transform-origin="center" fill="none" opacity=".2" stroke="currentColor" stroke-width="15" stroke-linecap="round" cx="100" cy="100" r="70"></circle>
|
||||
</svg>
|
Before Width: | Height: | Size: 1 KiB |
|
@ -1,17 +0,0 @@
|
|||
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;
|
||||
}
|
|
@ -61,17 +61,6 @@
|
|||
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']) {
|
||||
|
@ -245,6 +234,7 @@
|
|||
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);
|
||||
|
@ -257,10 +247,6 @@
|
|||
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),
|
||||
|
@ -373,10 +359,6 @@
|
|||
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;
|
||||
|
@ -680,7 +662,23 @@
|
|||
transform 200ms;
|
||||
}
|
||||
|
||||
section:has([data-sonner-toaster]) {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.sonner-loader[data-visible='false'] {
|
||||
opacity: 0;
|
||||
transform: scale(0.8) translate(-50%, -50%);
|
||||
}
|
||||
|
||||
/* Custom styles */
|
||||
:where([data-sonner-toaster]) {
|
||||
width: 200px;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
:where([data-sonner-toast][data-styled='true']) {
|
||||
width: 200px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
|
|
@ -73,7 +73,6 @@
|
|||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
:host,
|
||||
:root {
|
||||
@apply !bg-background !text-foreground !text-base !visible;
|
||||
|
@ -82,10 +81,11 @@
|
|||
all: initial !important;
|
||||
direction: ltr !important;
|
||||
}
|
||||
/**
|
||||
* Fix: scroll area dispay: table
|
||||
* @see https://github.com/radix-ui/primitives/issues/3129
|
||||
*/
|
||||
[data-radix-scroll-area-viewport] > div {
|
||||
display: block !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* @property --shimmer-angle {
|
||||
syntax: '<angle>';
|
||||
inherits: false;
|
||||
initial-value: 0deg;
|
||||
} */
|
||||
|
|
|
@ -1,26 +0,0 @@
|
|||
import { cn } from '@/utils'
|
||||
import { forwardRef, ReactNode } from 'react'
|
||||
|
||||
export interface LinkProps {
|
||||
href: string
|
||||
className?: string
|
||||
children: ReactNode
|
||||
underline?: boolean
|
||||
}
|
||||
|
||||
const Link = forwardRef<HTMLAnchorElement, LinkProps>(({ href, className, children, underline = true }, ref) => {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target={href}
|
||||
rel="noopener noreferrer"
|
||||
className={cn(underline && 'hover:underline', className)}
|
||||
ref={ref}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
})
|
||||
|
||||
Link.displayName = 'Link'
|
||||
export default Link
|
|
@ -1,69 +0,0 @@
|
|||
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
|
|
@ -1,24 +1,26 @@
|
|||
'use client'
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { cn } from '@/utils/index'
|
||||
import { cn } from "@/utils/index";
|
||||
|
||||
interface MeteorsProps {
|
||||
number?: number
|
||||
number?: number;
|
||||
}
|
||||
export const Meteors = ({ number = 20 }: MeteorsProps) => {
|
||||
const [meteorStyles, setMeteorStyles] = useState<Array<React.CSSProperties>>([])
|
||||
const [meteorStyles, setMeteorStyles] = useState<Array<React.CSSProperties>>(
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const styles = [...new Array(number)].map(() => ({
|
||||
top: -5,
|
||||
left: Math.floor(Math.random() * window.innerWidth) + 'px',
|
||||
animationDelay: Math.random() * 1 + 0.2 + 's',
|
||||
animationDuration: Math.floor(Math.random() * 8 + 2) + 's'
|
||||
}))
|
||||
setMeteorStyles(styles)
|
||||
}, [number])
|
||||
left: Math.floor(Math.random() * window.innerWidth) + "px",
|
||||
animationDelay: Math.random() * 1 + 0.2 + "s",
|
||||
animationDuration: Math.floor(Math.random() * 8 + 2) + "s",
|
||||
}));
|
||||
setMeteorStyles(styles);
|
||||
}, [number]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -27,7 +29,7 @@ export const Meteors = ({ number = 20 }: MeteorsProps) => {
|
|||
<span
|
||||
key={idx}
|
||||
className={cn(
|
||||
'pointer-events-none absolute left-1/2 top-1/2 size-0.5 rotate-[215deg] animate-meteor rounded-full bg-slate-500 shadow-[0_0_0_1px_#ffffff10]'
|
||||
"pointer-events-none absolute left-1/2 top-1/2 size-0.5 rotate-[215deg] animate-meteor rounded-full bg-slate-500 shadow-[0_0_0_1px_#ffffff10]",
|
||||
)}
|
||||
style={style}
|
||||
>
|
||||
|
@ -36,7 +38,7 @@ export const Meteors = ({ number = 20 }: MeteorsProps) => {
|
|||
</span>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default Meteors
|
||||
export default Meteors;
|
|
@ -29,7 +29,7 @@ const AvatarFallback = React.forwardRef<
|
|||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Fallback
|
||||
ref={ref}
|
||||
className={cn('flex h-full w-full items-center justify-center rounded-full bg-muted dark:text-slate-400', className)}
|
||||
className={cn('flex h-full w-full items-center justify-center rounded-full bg-muted', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
|
|
@ -11,8 +11,7 @@ const buttonVariants = cva(
|
|||
variant: {
|
||||
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
|
||||
destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
||||
outline:
|
||||
'border border-input text-primary bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
|
||||
outline: 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline'
|
||||
|
|
|
@ -1,26 +0,0 @@
|
|||
import * as React from 'react'
|
||||
import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
|
||||
import { CheckIcon } from '@radix-ui/react-icons'
|
||||
|
||||
import { cn } from '@/utils/index'
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator className={cn('flex items-center justify-center text-current')}>
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
))
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||
|
||||
export { Checkbox }
|
|
@ -3,7 +3,7 @@ import ReactMarkdown from 'react-markdown'
|
|||
import remarkGfm from 'remark-gfm'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
import { cn } from '@/utils'
|
||||
import { ScrollArea, ScrollBar } from '@/components/ui/ScrollArea'
|
||||
import { ScrollArea, ScrollBar } from './ScrollArea'
|
||||
|
||||
export interface MarkdownProps {
|
||||
children?: string
|
||||
|
@ -46,21 +46,12 @@ const Markdown: FC<MarkdownProps> = ({ children = '', className }) => {
|
|||
urlTransform={urlTransform}
|
||||
components={{
|
||||
h1: ({ 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} />
|
||||
<h1 className={cn('my-2 mt-0 font-semibold text-2xl', 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)}
|
||||
|
@ -76,7 +67,7 @@ const Markdown: FC<MarkdownProps> = ({ children = '', className }) => {
|
|||
input: ({ className, ...props }) => <input className={cn('my-0', className)} {...props} />,
|
||||
table: ({ className, ...props }) => (
|
||||
<div className="my-2 w-full">
|
||||
<ScrollArea scrollLock={false}>
|
||||
<ScrollArea>
|
||||
<table className={cn('my-0 w-full rounded-md', className)} {...props} />
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
|
@ -115,14 +106,14 @@ const Markdown: FC<MarkdownProps> = ({ children = '', className }) => {
|
|||
*
|
||||
*/
|
||||
code: ({ className, ...props }) => (
|
||||
<ScrollArea className="overscroll-y-auto" scrollLock={false}>
|
||||
<ScrollArea>
|
||||
<code className={cn('text-sm', className)} {...props}></code>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
)
|
||||
}}
|
||||
remarkPlugins={[remarkGfm, remarkBreaks]}
|
||||
className={cn(className, 'prose prose-sm prose-slate break-words dark:text-slate-50')}
|
||||
className={cn(className, 'prose prose-sm prose-slate break-words')}
|
||||
>
|
||||
{children}
|
||||
</ReactMarkdown>
|
|
@ -1,6 +1,6 @@
|
|||
import * as React from 'react'
|
||||
import * as PopoverPrimitive from '@radix-ui/react-popover'
|
||||
import { cn, getRootNode } from '@/utils'
|
||||
import { cn } from '@/utils/index'
|
||||
|
||||
const Popover = PopoverPrimitive.Root
|
||||
|
||||
|
@ -10,9 +10,9 @@ const PopoverContent = React.forwardRef<
|
|||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => {
|
||||
const root = getRootNode()
|
||||
const shadowRoot = document.querySelector(__NAME__)!.shadowRoot! as unknown as HTMLElement
|
||||
return (
|
||||
<PopoverPrimitive.Portal container={root}>
|
||||
<PopoverPrimitive.Portal container={shadowRoot}>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
|
|
|
@ -5,13 +5,10 @@ import { cn } from '@/utils/index'
|
|||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> & { scrollLock?: boolean }
|
||||
>(({ className, children, scrollLock = true, ...props }, ref) => (
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root className={cn('relative grid grid-rows-[1fr] overflow-hidden', className)} {...props}>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
ref={ref}
|
||||
className={cn('size-full rounded-[inherit]', scrollLock ? 'overscroll-none' : 'overscroll-auto')}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport ref={ref} className="size-full overscroll-none rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
|
|
29
src/components/ui/Sonner.tsx
Normal file
29
src/components/ui/Sonner.tsx
Normal file
|
@ -0,0 +1,29 @@
|
|||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner } from "sonner"
|
||||
|
||||
type ToasterProps = React.ComponentProps<typeof Sonner>
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast:
|
||||
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||
description: "group-[.toast]:text-muted-foreground",
|
||||
actionButton:
|
||||
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
|
||||
cancelButton:
|
||||
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
|
@ -8,7 +8,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(({ classNa
|
|||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
'flex min-h-[60px] w-full rounded-md border border-input text-primary bg-transparent p-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import { version } from '@/../package.json'
|
||||
// https://www.webfx.com/tools/emoji-cheat-sheet/
|
||||
|
||||
export const EMOJI_LIST = [
|
||||
'😀',
|
||||
'😃',
|
||||
'😄',
|
||||
'😁',
|
||||
'😆',
|
||||
|
@ -112,7 +112,6 @@ export const EMOJI_LIST = [
|
|||
'👽',
|
||||
'👾',
|
||||
'🤖',
|
||||
'👀',
|
||||
'😺',
|
||||
'😸',
|
||||
'😹',
|
||||
|
@ -186,26 +185,16 @@ export const BREAKPOINTS = {
|
|||
|
||||
export const MESSAGE_MAX_LENGTH = 500 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 STORAGE_NAME = 'WEB_CHAT' 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
|
||||
export const USER_INFO_STORAGE_KEY = 'WEB_CHAT_USER_INFO' as const
|
||||
|
||||
export const APP_OPEN_STATUS_STORAGE_KEY = 'WEB_CHAT_APP_OPEN_STATUS' as const
|
||||
/**
|
||||
* In chrome storage.sync, each key-value pair supports a maximum storage of 8kb
|
||||
* Image is encoded as base64, and the size is increased by about 33%.
|
||||
* 8kb * (1 - 0.33) = 5488 bytes
|
||||
*/
|
||||
export const MAX_AVATAR_SIZE = 5120 as const
|
||||
|
||||
export const SYNC_HISTORY_MAX_DAYS = 30 as const
|
||||
|
||||
/**
|
||||
* https://lgrahl.de/articles/demystifying-webrtc-dc-size-limit.html
|
||||
* Message max size is 256KiB; if the message is too large, it will cause the connection to drop.
|
||||
*/
|
||||
export const WEB_RTC_MAX_MESSAGE_SIZE = 262144 as const
|
||||
|
||||
export const VIRTUAL_ROOM_ID = 'WEB_CHAT_VIRTUAL_ROOM' as const
|
||||
|
|
|
@ -1,6 +1,3 @@
|
|||
export enum EVENT {
|
||||
OPTIONS_PAGE_OPEN = `WEB_CHAT_OPTIONS_PAGE_OPEN`,
|
||||
APP_OPEN = 'WEB_CHAT_APP_OPEN',
|
||||
NOTIFICATION_PUSH = 'WEB_CHAT_NOTIFICATION_PUSH',
|
||||
NOTIFICATION_CLEAR = 'WEB_CHAT_NOTIFICATION_CLEAR'
|
||||
OPEN_OPTIONS_PAGE = 'OPEN_OPTIONS_PAGE'
|
||||
}
|
||||
|
|
|
@ -1,167 +0,0 @@
|
|||
import { Remesh } from 'remesh'
|
||||
import StatusModule from './modules/Status'
|
||||
import { LocalStorageExtern } from './externs/Storage'
|
||||
import { APP_STATUS_STORAGE_KEY } from '@/constants/config'
|
||||
import StorageEffect from './modules/StorageEffect'
|
||||
import ChatRoomDomain, { SendType } from '@/domain/ChatRoom'
|
||||
import { map } from 'rxjs'
|
||||
|
||||
export interface AppStatus {
|
||||
open: boolean
|
||||
unread: number
|
||||
position: { x: number; y: number }
|
||||
}
|
||||
|
||||
export const defaultStatusState = {
|
||||
open: false,
|
||||
unread: 0,
|
||||
position: { x: window.innerWidth - 50, y: window.innerHeight - 22 }
|
||||
}
|
||||
|
||||
const AppStatusDomain = Remesh.domain({
|
||||
name: 'AppStatusDomain',
|
||||
impl: (domain) => {
|
||||
const storageEffect = new StorageEffect({
|
||||
domain,
|
||||
extern: LocalStorageExtern,
|
||||
key: APP_STATUS_STORAGE_KEY
|
||||
})
|
||||
const chatRoomDomain = domain.getDomain(ChatRoomDomain())
|
||||
|
||||
const StatusLoadModule = StatusModule(domain, {
|
||||
name: 'AppStatus.LoadStatusModule'
|
||||
})
|
||||
|
||||
const StatusLoadIsFinishedQuery = domain.query({
|
||||
name: 'AppStatus.StatusLoadIsFinishedQuery',
|
||||
impl: ({ get }) => {
|
||||
return get(StatusLoadModule.query.IsFinishedQuery())
|
||||
}
|
||||
})
|
||||
|
||||
const StatusState = domain.state<AppStatus>({
|
||||
name: 'AppStatus.StatusState',
|
||||
default: defaultStatusState
|
||||
})
|
||||
|
||||
const OpenQuery = domain.query({
|
||||
name: 'AppStatus.IsOpenQuery',
|
||||
impl: ({ get }) => {
|
||||
return get(StatusState()).open
|
||||
}
|
||||
})
|
||||
|
||||
const UnreadQuery = domain.query({
|
||||
name: 'AppStatus.UnreadQuery',
|
||||
impl: ({ get }) => {
|
||||
return get(StatusState()).unread
|
||||
}
|
||||
})
|
||||
|
||||
const PositionQuery = domain.query({
|
||||
name: 'AppStatus.PositionQuery',
|
||||
impl: ({ get }) => {
|
||||
return get(StatusState()).position
|
||||
}
|
||||
})
|
||||
|
||||
const HasUnreadQuery = domain.query({
|
||||
name: 'AppStatus.HasUnreadQuery',
|
||||
impl: ({ get }) => {
|
||||
return get(StatusState()).unread > 0
|
||||
}
|
||||
})
|
||||
|
||||
const UpdateOpenCommand = domain.command({
|
||||
name: 'AppStatus.UpdateOpenCommand',
|
||||
impl: ({ get }, value: boolean) => {
|
||||
const status = get(StatusState())
|
||||
return UpdateStatusCommand({
|
||||
...status,
|
||||
unread: value ? 0 : status.unread,
|
||||
open: value
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const UpdateUnreadCommand = domain.command({
|
||||
name: 'AppStatus.UpdateUnreadCommand',
|
||||
impl: ({ get }, value: number) => {
|
||||
const status = get(StatusState())
|
||||
return UpdateStatusCommand({
|
||||
...status,
|
||||
unread: value
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const UpdatePositionCommand = domain.command({
|
||||
name: 'AppStatus.UpdatePositionCommand',
|
||||
impl: ({ get }, value: { x: number; y: number }) => {
|
||||
const status = get(StatusState())
|
||||
return UpdateStatusCommand({
|
||||
...status,
|
||||
position: value
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const UpdateStatusCommand = domain.command({
|
||||
name: 'AppStatus.UpdateStatusCommand',
|
||||
impl: (_, value: AppStatus) => {
|
||||
return [StatusState().new(value), SyncToStorageEvent()]
|
||||
}
|
||||
})
|
||||
|
||||
const SyncToStorageEvent = domain.event({
|
||||
name: 'UserInfo.SyncToStorageEvent',
|
||||
impl: ({ get }) => {
|
||||
return get(StatusState())
|
||||
}
|
||||
})
|
||||
|
||||
storageEffect
|
||||
.set(SyncToStorageEvent)
|
||||
.get<AppStatus>((value) => [
|
||||
UpdateStatusCommand(value ?? defaultStatusState),
|
||||
StatusLoadModule.command.SetFinishedCommand()
|
||||
])
|
||||
.watch<AppStatus>((value) => [UpdateStatusCommand(value ?? defaultStatusState)])
|
||||
|
||||
domain.effect({
|
||||
name: 'OnMessageEffect',
|
||||
impl: ({ fromEvent, get }) => {
|
||||
const onMessage$ = fromEvent(chatRoomDomain.event.OnMessageEvent).pipe(
|
||||
map((message) => {
|
||||
const status = get(StatusState())
|
||||
if (!status.open && message.type === SendType.Text) {
|
||||
return UpdateUnreadCommand(status.unread + 1)
|
||||
}
|
||||
return null
|
||||
})
|
||||
)
|
||||
return onMessage$
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
query: {
|
||||
OpenQuery,
|
||||
UnreadQuery,
|
||||
HasUnreadQuery,
|
||||
PositionQuery,
|
||||
StatusLoadIsFinishedQuery
|
||||
},
|
||||
command: {
|
||||
UpdateOpenCommand,
|
||||
UpdateUnreadCommand,
|
||||
UpdatePositionCommand
|
||||
},
|
||||
event: {
|
||||
SyncToStorageEvent
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export default AppStatusDomain
|
|
@ -1,690 +0,0 @@
|
|||
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
|
|
@ -1,162 +0,0 @@
|
|||
import { Remesh } from 'remesh'
|
||||
import { DanmakuExtern } from './externs/Danmaku'
|
||||
import ChatRoomDomain, { TextMessage } from '@/domain/ChatRoom'
|
||||
import UserInfoDomain from './UserInfo'
|
||||
import { map, merge } from 'rxjs'
|
||||
|
||||
const DanmakuDomain = Remesh.domain({
|
||||
name: 'DanmakuDomain',
|
||||
impl: (domain) => {
|
||||
const danmakuExtern = domain.getExtern(DanmakuExtern)
|
||||
const userInfoDomain = domain.getDomain(UserInfoDomain())
|
||||
const chatRoomDomain = domain.getDomain(ChatRoomDomain())
|
||||
|
||||
const MountState = domain.state({
|
||||
name: 'Danmaku.MountState',
|
||||
default: false
|
||||
})
|
||||
const DanmakuEnabledState = domain.state<boolean>({
|
||||
name: 'Danmaku.EnabledState',
|
||||
default: false
|
||||
})
|
||||
|
||||
const IsEnabledQuery = domain.query({
|
||||
name: 'Danmaku.IsOpenQuery',
|
||||
impl: ({ get }) => {
|
||||
return get(DanmakuEnabledState())
|
||||
}
|
||||
})
|
||||
|
||||
const EnableCommand = domain.command({
|
||||
name: 'Danmaku.EnableCommand',
|
||||
impl: () => {
|
||||
return DanmakuEnabledState().new(true)
|
||||
}
|
||||
})
|
||||
|
||||
const DisableCommand = domain.command({
|
||||
name: 'Danmaku.DisableCommand',
|
||||
impl: () => {
|
||||
return DanmakuEnabledState().new(false)
|
||||
}
|
||||
})
|
||||
|
||||
const IsMountedQuery = domain.query({
|
||||
name: 'Danmaku.IsMountedQuery',
|
||||
impl: ({ get }) => get(MountState())
|
||||
})
|
||||
|
||||
const PushCommand = domain.command({
|
||||
name: 'Danmaku.PushCommand',
|
||||
impl: (_, message: TextMessage) => {
|
||||
danmakuExtern.push(message)
|
||||
return [PushEvent(message)]
|
||||
}
|
||||
})
|
||||
|
||||
const UnshiftCommand = domain.command({
|
||||
name: 'Danmaku.UnshiftCommand',
|
||||
impl: (_, message: TextMessage) => {
|
||||
danmakuExtern.unshift(message)
|
||||
return [UnshiftEvent(message)]
|
||||
}
|
||||
})
|
||||
|
||||
const ClearCommand = domain.command({
|
||||
name: 'Danmaku.ClearCommand',
|
||||
impl: () => {
|
||||
danmakuExtern.clear()
|
||||
return [ClearEvent()]
|
||||
}
|
||||
})
|
||||
|
||||
const MountCommand = domain.command({
|
||||
name: 'Danmaku.ClearCommand',
|
||||
impl: (_, container: HTMLElement) => {
|
||||
danmakuExtern.mount(container)
|
||||
return [MountEvent(container)]
|
||||
}
|
||||
})
|
||||
|
||||
const UnmountCommand = domain.command({
|
||||
name: 'Danmaku.UnmountCommand',
|
||||
impl: () => {
|
||||
danmakuExtern.unmount()
|
||||
return [UnmountEvent()]
|
||||
}
|
||||
})
|
||||
|
||||
const PushEvent = domain.event<TextMessage>({
|
||||
name: 'Danmaku.PushEvent'
|
||||
})
|
||||
|
||||
const UnshiftEvent = domain.event<TextMessage>({
|
||||
name: 'Danmaku.UnshiftEvent'
|
||||
})
|
||||
|
||||
const ClearEvent = domain.event({
|
||||
name: 'Danmaku.ClearEvent'
|
||||
})
|
||||
|
||||
const MountEvent = domain.event<HTMLElement>({
|
||||
name: 'Danmaku.MountEvent'
|
||||
})
|
||||
|
||||
const UnmountEvent = domain.event({
|
||||
name: 'Danmaku.UnmountEvent'
|
||||
})
|
||||
|
||||
domain.effect({
|
||||
name: 'Danmaku.OnUserInfoEffect',
|
||||
impl: ({ fromEvent }) => {
|
||||
const onUserInfo$ = fromEvent(userInfoDomain.event.UpdateUserInfoEvent)
|
||||
return onUserInfo$.pipe(
|
||||
map((userInfo) => {
|
||||
return userInfo?.danmakuEnabled ? EnableCommand() : DisableCommand()
|
||||
})
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
domain.effect({
|
||||
name: 'Danmaku.OnRoomMessageEffect',
|
||||
impl: ({ fromEvent, get }) => {
|
||||
const sendTextMessage$ = fromEvent(chatRoomDomain.event.SendTextMessageEvent)
|
||||
const onTextMessage$ = fromEvent(chatRoomDomain.event.OnTextMessageEvent)
|
||||
|
||||
const onMessage$ = merge(sendTextMessage$, onTextMessage$).pipe(
|
||||
map((message) => {
|
||||
const danmakuEnabled = get(IsEnabledQuery())
|
||||
return danmakuEnabled ? PushCommand(message) : null
|
||||
})
|
||||
)
|
||||
return onMessage$
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
query: {
|
||||
IsMountedQuery,
|
||||
IsEnabledQuery
|
||||
},
|
||||
command: {
|
||||
EnableCommand,
|
||||
DisableCommand,
|
||||
PushCommand,
|
||||
UnshiftCommand,
|
||||
ClearCommand,
|
||||
MountCommand,
|
||||
UnmountCommand
|
||||
},
|
||||
event: {
|
||||
PushEvent,
|
||||
UnshiftEvent,
|
||||
ClearEvent,
|
||||
MountEvent,
|
||||
UnmountEvent
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export default DanmakuDomain
|
|
@ -16,27 +16,20 @@ export interface MessageUser {
|
|||
userAvatar: string
|
||||
}
|
||||
|
||||
export interface AtUser extends MessageUser {
|
||||
positions: [number, number][]
|
||||
}
|
||||
|
||||
export interface NormalMessage extends MessageUser {
|
||||
type: MessageType.Normal
|
||||
id: string
|
||||
body: string
|
||||
sendTime: number
|
||||
receiveTime: number
|
||||
date: number
|
||||
likeUsers: MessageUser[]
|
||||
hateUsers: MessageUser[]
|
||||
atUsers: AtUser[]
|
||||
}
|
||||
|
||||
export interface PromptMessage extends MessageUser {
|
||||
type: MessageType.Prompt
|
||||
id: string
|
||||
body: string
|
||||
sendTime: number
|
||||
receiveTime: number
|
||||
date: number
|
||||
}
|
||||
|
||||
export type Message = NormalMessage | PromptMessage
|
||||
|
@ -55,8 +48,8 @@ const MessageListDomain = Remesh.domain({
|
|||
key: (message) => message.id
|
||||
})
|
||||
|
||||
const LoadStatusModule = StatusModule(domain, {
|
||||
name: 'Message.ListLoadStatusModule'
|
||||
const MessageListLoadStatusModule = StatusModule(domain, {
|
||||
name: 'MessageListLoadStatusModule'
|
||||
})
|
||||
|
||||
const ListQuery = MessageListModule.query.ItemListQuery
|
||||
|
@ -65,8 +58,6 @@ const MessageListDomain = Remesh.domain({
|
|||
|
||||
const HasItemQuery = MessageListModule.query.HasItemByKeyQuery
|
||||
|
||||
const LoadIsFinishedQuery = LoadStatusModule.query.IsFinishedQuery
|
||||
|
||||
const ChangeListEvent = domain.event({
|
||||
name: 'MessageList.ChangeListEvent',
|
||||
impl: ({ get }) => {
|
||||
|
@ -122,38 +113,6 @@ 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'
|
||||
})
|
||||
|
@ -185,31 +144,29 @@ const MessageListDomain = Remesh.domain({
|
|||
|
||||
storageEffect
|
||||
.set(SyncToStorageEvent)
|
||||
.get<Message[]>((value) => [SyncToStateCommand(value ?? []), LoadStatusModule.command.SetFinishedCommand()])
|
||||
.get<
|
||||
Message[]
|
||||
>((value) => [SyncToStateCommand(value ?? []), MessageListLoadStatusModule.command.SetFinishedCommand()])
|
||||
|
||||
return {
|
||||
query: {
|
||||
HasItemQuery,
|
||||
ItemQuery,
|
||||
ListQuery,
|
||||
LoadIsFinishedQuery
|
||||
MessageListLoadIsFinishedQuery: MessageListLoadStatusModule.query.IsFinishedQuery
|
||||
},
|
||||
command: {
|
||||
CreateItemCommand,
|
||||
UpdateItemCommand,
|
||||
DeleteItemCommand,
|
||||
UpsertItemCommand,
|
||||
ClearListCommand,
|
||||
ResetListCommand
|
||||
ClearListCommand
|
||||
},
|
||||
event: {
|
||||
ChangeListEvent,
|
||||
CreateItemEvent,
|
||||
UpdateItemEvent,
|
||||
DeleteItemEvent,
|
||||
UpsertItemEvent,
|
||||
ClearListEvent,
|
||||
ResetListEvent,
|
||||
SyncToStateEvent,
|
||||
SyncToStorageEvent
|
||||
}
|
||||
|
|
|
@ -1,117 +0,0 @@
|
|||
import { Remesh } from 'remesh'
|
||||
import { NotificationExtern } from './externs/Notification'
|
||||
import ChatRoomDomain, { TextMessage } from '@/domain/ChatRoom'
|
||||
import UserInfoDomain from './UserInfo'
|
||||
import { map, merge } from 'rxjs'
|
||||
|
||||
const NotificationDomain = Remesh.domain({
|
||||
name: 'NotificationDomain',
|
||||
impl: (domain) => {
|
||||
const notificationExtern = domain.getExtern(NotificationExtern)
|
||||
const userInfoDomain = domain.getDomain(UserInfoDomain())
|
||||
const chatRoomDomain = domain.getDomain(ChatRoomDomain())
|
||||
|
||||
const NotificationEnabledState = domain.state<boolean>({
|
||||
name: 'Notification.EnabledState',
|
||||
default: false
|
||||
})
|
||||
|
||||
const IsEnabledQuery = domain.query({
|
||||
name: 'Notification.IsOpenQuery',
|
||||
impl: ({ get }) => {
|
||||
return get(NotificationEnabledState())
|
||||
}
|
||||
})
|
||||
|
||||
const EnableCommand = domain.command({
|
||||
name: 'Notification.EnableCommand',
|
||||
impl: () => {
|
||||
return NotificationEnabledState().new(true)
|
||||
}
|
||||
})
|
||||
|
||||
const DisableCommand = domain.command({
|
||||
name: 'Notification.DisableCommand',
|
||||
impl: () => {
|
||||
return NotificationEnabledState().new(false)
|
||||
}
|
||||
})
|
||||
|
||||
const PushCommand = domain.command({
|
||||
name: 'Notification.PushCommand',
|
||||
impl: (_, message: TextMessage) => {
|
||||
notificationExtern.push(message)
|
||||
return [PushEvent(message)]
|
||||
}
|
||||
})
|
||||
|
||||
const PushEvent = domain.event<TextMessage>({
|
||||
name: 'Notification.PushEvent'
|
||||
})
|
||||
|
||||
const ClearEvent = domain.event<string>({
|
||||
name: 'Notification.ClearEvent'
|
||||
})
|
||||
|
||||
domain.effect({
|
||||
name: 'Notification.OnUserInfoEffect',
|
||||
impl: ({ fromEvent }) => {
|
||||
const onUserInfo$ = fromEvent(userInfoDomain.event.UpdateUserInfoEvent)
|
||||
return onUserInfo$.pipe(
|
||||
map((userInfo) => {
|
||||
return userInfo?.notificationEnabled ? EnableCommand() : DisableCommand()
|
||||
})
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
domain.effect({
|
||||
name: 'Notification.OnRoomMessageEffect',
|
||||
impl: ({ fromEvent, get }) => {
|
||||
const onTextMessage$ = fromEvent(chatRoomDomain.event.OnTextMessageEvent)
|
||||
const onMessage$ = merge(onTextMessage$).pipe(
|
||||
map((message) => {
|
||||
const notificationEnabled = get(IsEnabledQuery())
|
||||
if (notificationEnabled) {
|
||||
// Compatible with old versions, without the atUsers field
|
||||
if (message.atUsers) {
|
||||
const userInfo = get(userInfoDomain.query.UserInfoQuery())
|
||||
const hasAtSelf = message.atUsers.find((user) => user.userId === userInfo?.id)
|
||||
if (userInfo?.notificationType === 'all') {
|
||||
return PushCommand(message)
|
||||
}
|
||||
if (userInfo?.notificationType === 'at' && hasAtSelf) {
|
||||
return PushCommand(message)
|
||||
}
|
||||
return null
|
||||
} else {
|
||||
return PushCommand(message)
|
||||
}
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return onMessage$
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
query: {
|
||||
IsEnabledQuery
|
||||
},
|
||||
command: {
|
||||
EnableCommand,
|
||||
DisableCommand,
|
||||
PushCommand
|
||||
},
|
||||
event: {
|
||||
PushEvent,
|
||||
ClearEvent
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export default NotificationDomain
|
450
src/domain/Room.ts
Normal file
450
src/domain/Room.ts
Normal file
|
@ -0,0 +1,450 @@
|
|||
import { Remesh } from 'remesh'
|
||||
import { map, merge, of, EMPTY, mergeMap, fromEvent, Observable, tap, fromEventPattern } from 'rxjs'
|
||||
import { NormalMessage, type MessageUser } from './MessageList'
|
||||
import { PeerRoomExtern } from '@/domain/externs/PeerRoom'
|
||||
import MessageListDomain, { MessageType } from '@/domain/MessageList'
|
||||
import UserInfoDomain from '@/domain/UserInfo'
|
||||
import { desert, upsert } from '@/utils'
|
||||
import { nanoid } from 'nanoid'
|
||||
import StatusModule from '@/domain/modules/Status'
|
||||
import { ToastExtern } from './externs/Toast'
|
||||
|
||||
export { MessageType }
|
||||
|
||||
export enum SendType {
|
||||
Like = 'like',
|
||||
Hate = 'hate',
|
||||
Text = 'text',
|
||||
Join = 'join'
|
||||
}
|
||||
|
||||
export interface SyncUserMessage extends MessageUser {
|
||||
type: SendType.Join
|
||||
id: string
|
||||
peerId: string
|
||||
joinTime: number
|
||||
}
|
||||
|
||||
export interface LikeMessage extends MessageUser {
|
||||
type: SendType.Like
|
||||
id: string
|
||||
}
|
||||
|
||||
export interface HateMessage extends MessageUser {
|
||||
type: SendType.Hate
|
||||
id: string
|
||||
}
|
||||
|
||||
export interface TextMessage extends MessageUser {
|
||||
type: SendType.Text
|
||||
id: string
|
||||
body: string
|
||||
}
|
||||
|
||||
export type RoomMessage = SyncUserMessage | LikeMessage | HateMessage | TextMessage
|
||||
|
||||
export type RoomUser = MessageUser & { peerId: string; joinTime: number }
|
||||
|
||||
const RoomDomain = Remesh.domain({
|
||||
name: 'RoomDomain',
|
||||
impl: (domain) => {
|
||||
const messageListDomain = domain.getDomain(MessageListDomain())
|
||||
const userInfoDomain = domain.getDomain(UserInfoDomain())
|
||||
const peerRoom = domain.getExtern(PeerRoomExtern)
|
||||
const toast = domain.getExtern(ToastExtern)
|
||||
|
||||
const PeerIdState = domain.state<string>({
|
||||
name: 'Room.PeerIdState',
|
||||
default: peerRoom.peerId
|
||||
})
|
||||
|
||||
const PeerIdQuery = domain.query({
|
||||
name: 'Room.PeerIdQuery',
|
||||
impl: ({ get }) => {
|
||||
return get(PeerIdState())
|
||||
}
|
||||
})
|
||||
|
||||
const RoomJoinStatusModule = StatusModule(domain, {
|
||||
name: 'RoomJoinStatusModule'
|
||||
})
|
||||
|
||||
const UserListState = domain.state<RoomUser[]>({
|
||||
name: 'RoomUserListState',
|
||||
default: []
|
||||
})
|
||||
|
||||
const UserListQuery = domain.query({
|
||||
name: 'Room.UserListQuery',
|
||||
impl: ({ get }) => {
|
||||
return get(UserListState())
|
||||
}
|
||||
})
|
||||
|
||||
const JoinRoomCommand = domain.command({
|
||||
name: 'RoomJoinRoomCommand',
|
||||
impl: ({ get }) => {
|
||||
peerRoom.joinRoom()
|
||||
const { id: userId, name: username, avatar: userAvatar } = get(userInfoDomain.query.UserInfoQuery())!
|
||||
|
||||
return [
|
||||
UpdateUserListCommand({
|
||||
type: 'create',
|
||||
user: { peerId: peerRoom.peerId, joinTime: Date.now(), userId, username, userAvatar }
|
||||
}),
|
||||
messageListDomain.command.CreateItemCommand({
|
||||
id: nanoid(),
|
||||
userId,
|
||||
username,
|
||||
userAvatar,
|
||||
body: `"${username}" joined the chat`,
|
||||
type: MessageType.Prompt,
|
||||
date: Date.now()
|
||||
}),
|
||||
RoomJoinStatusModule.command.SetFinishedCommand(),
|
||||
JoinRoomEvent(peerRoom.roomId)
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const LeaveRoomCommand = domain.command({
|
||||
name: 'RoomLeaveRoomCommand',
|
||||
impl: ({ get }) => {
|
||||
peerRoom.leaveRoom()
|
||||
const { id: userId, name: username, avatar: userAvatar } = get(userInfoDomain.query.UserInfoQuery())!
|
||||
return [
|
||||
messageListDomain.command.CreateItemCommand({
|
||||
id: nanoid(),
|
||||
userId,
|
||||
username,
|
||||
userAvatar,
|
||||
body: `"${username}" left the chat`,
|
||||
type: MessageType.Prompt,
|
||||
date: Date.now()
|
||||
}),
|
||||
UpdateUserListCommand({
|
||||
type: 'delete',
|
||||
user: { peerId: peerRoom.peerId, joinTime: Date.now(), userId, username, userAvatar }
|
||||
}),
|
||||
RoomJoinStatusModule.command.SetInitialCommand(),
|
||||
LeaveRoomEvent(peerRoom.roomId)
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const SendTextMessageCommand = domain.command({
|
||||
name: 'RoomSendTextMessageCommand',
|
||||
impl: ({ get }, message: string) => {
|
||||
const { id: userId, name: username, avatar: userAvatar } = get(userInfoDomain.query.UserInfoQuery())!
|
||||
|
||||
const textMessage: TextMessage = {
|
||||
id: nanoid(),
|
||||
type: SendType.Text,
|
||||
body: message,
|
||||
userId,
|
||||
username,
|
||||
userAvatar
|
||||
}
|
||||
|
||||
const listMessage: NormalMessage = {
|
||||
...textMessage,
|
||||
type: MessageType.Normal,
|
||||
date: Date.now(),
|
||||
likeUsers: [],
|
||||
hateUsers: []
|
||||
}
|
||||
|
||||
peerRoom.sendMessage<RoomMessage>(textMessage)
|
||||
return [messageListDomain.command.CreateItemCommand(listMessage), SendTextMessageEvent(textMessage)]
|
||||
}
|
||||
})
|
||||
|
||||
const SendLikeMessageCommand = domain.command({
|
||||
name: 'RoomSendLikeMessageCommand',
|
||||
impl: ({ get }, messageId: string) => {
|
||||
const { id: userId, name: username, avatar: userAvatar } = get(userInfoDomain.query.UserInfoQuery())!
|
||||
const localMessage = get(messageListDomain.query.ItemQuery(messageId)) as NormalMessage
|
||||
|
||||
const likeMessage: LikeMessage = {
|
||||
id: messageId,
|
||||
userId,
|
||||
username,
|
||||
userAvatar,
|
||||
type: SendType.Like
|
||||
}
|
||||
const listMessage: NormalMessage = {
|
||||
...localMessage,
|
||||
likeUsers: desert(localMessage.likeUsers, likeMessage, 'userId')
|
||||
}
|
||||
peerRoom.sendMessage<RoomMessage>(likeMessage)
|
||||
return [messageListDomain.command.UpdateItemCommand(listMessage), SendLikeMessageEvent(likeMessage)]
|
||||
}
|
||||
})
|
||||
|
||||
const SendHateMessageCommand = domain.command({
|
||||
name: 'RoomSendHateMessageCommand',
|
||||
impl: ({ get }, messageId: string) => {
|
||||
const { id: userId, name: username, avatar: userAvatar } = get(userInfoDomain.query.UserInfoQuery())!
|
||||
const localMessage = get(messageListDomain.query.ItemQuery(messageId)) as NormalMessage
|
||||
|
||||
const hateMessage: HateMessage = {
|
||||
id: messageId,
|
||||
userId,
|
||||
username,
|
||||
userAvatar,
|
||||
type: SendType.Hate
|
||||
}
|
||||
const listMessage: NormalMessage = {
|
||||
...localMessage,
|
||||
hateUsers: desert(localMessage.hateUsers, hateMessage, 'userId')
|
||||
}
|
||||
peerRoom.sendMessage<RoomMessage>(hateMessage)
|
||||
return [messageListDomain.command.UpdateItemCommand(listMessage), SendHateMessageEvent(hateMessage)]
|
||||
}
|
||||
})
|
||||
|
||||
const SendJoinMessageCommand = domain.command({
|
||||
name: 'RoomSendJoinMessageCommand',
|
||||
impl: ({ get }, targetPeerId: string) => {
|
||||
const self = get(UserListQuery()).find((user) => user.peerId === peerRoom.peerId)!
|
||||
|
||||
const syncUserMessage: SyncUserMessage = {
|
||||
...self,
|
||||
id: nanoid(),
|
||||
type: SendType.Join
|
||||
}
|
||||
|
||||
peerRoom.sendMessage<RoomMessage>(syncUserMessage, targetPeerId)
|
||||
return [SendJoinMessageEvent(syncUserMessage)]
|
||||
}
|
||||
})
|
||||
|
||||
const UpdateUserListCommand = domain.command({
|
||||
name: 'RoomUpdateUserListCommand',
|
||||
impl: ({ get }, action: { type: 'create' | 'delete'; user: RoomUser }) => {
|
||||
const userList = get(UserListState())
|
||||
if (action.type === 'create') {
|
||||
return [UserListState().new(upsert(userList, action.user, 'peerId'))]
|
||||
} else {
|
||||
return [UserListState().new(userList.filter(({ peerId }) => peerId !== action.user.peerId))]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const SendJoinMessageEvent = domain.event<SyncUserMessage>({
|
||||
name: 'RoomSendJoinMessageEvent'
|
||||
})
|
||||
|
||||
const SendTextMessageEvent = domain.event<TextMessage>({
|
||||
name: 'RoomSendTextMessageEvent'
|
||||
})
|
||||
|
||||
const SendLikeMessageEvent = domain.event<LikeMessage>({
|
||||
name: 'RoomSendLikeMessageEvent'
|
||||
})
|
||||
|
||||
const SendHateMessageEvent = domain.event<HateMessage>({
|
||||
name: 'RoomSendHateMessageEvent'
|
||||
})
|
||||
|
||||
const JoinRoomEvent = domain.event<string>({
|
||||
name: 'RoomJoinRoomEvent'
|
||||
})
|
||||
|
||||
const LeaveRoomEvent = domain.event<string>({
|
||||
name: 'RoomLeaveRoomEvent'
|
||||
})
|
||||
|
||||
const OnMessageEvent = domain.event<RoomMessage>({
|
||||
name: 'RoomOnMessageEvent'
|
||||
})
|
||||
|
||||
const OnJoinRoomEvent = domain.event<string>({
|
||||
name: 'RoomOnJoinRoomEvent'
|
||||
})
|
||||
|
||||
const OnLeaveRoomEvent = domain.event<string>({
|
||||
name: 'RoomOnLeaveRoomEvent'
|
||||
})
|
||||
|
||||
domain.effect({
|
||||
name: 'RoomOnJoinRoomEffect',
|
||||
impl: () => {
|
||||
const onJoinRoom$ = fromEventPattern<string>(peerRoom.onJoinRoom).pipe(
|
||||
mergeMap((peerId) => {
|
||||
// console.log('onJoinRoom', peerId)
|
||||
if (peerRoom.peerId === peerId) {
|
||||
return [OnJoinRoomEvent(peerId)]
|
||||
} else {
|
||||
return [SendJoinMessageCommand(peerId), OnJoinRoomEvent(peerId)]
|
||||
}
|
||||
})
|
||||
)
|
||||
return onJoinRoom$
|
||||
}
|
||||
})
|
||||
|
||||
domain.effect({
|
||||
name: 'RoomOnMessageEffect',
|
||||
impl: ({ get }) => {
|
||||
const onMessage$ = fromEventPattern<RoomMessage>(peerRoom.onMessage).pipe(
|
||||
mergeMap((message) => {
|
||||
// console.log('onMessage', message)
|
||||
const messageEvent$ = of(OnMessageEvent(message))
|
||||
|
||||
const commandEvent$ = (() => {
|
||||
switch (message.type) {
|
||||
case SendType.Join: {
|
||||
const userList = get(UserListQuery())
|
||||
const selfUser = userList.find((user) => user.peerId === peerRoom.peerId)!
|
||||
// If the browser has multiple tabs open, it can cause the same user to join multiple times with the same peerId but different userId
|
||||
const isSelfJoinEvent = !!userList.find((user) => user.userId === message.userId)
|
||||
// When a new user joins, it triggers join events for all users, i.e., newUser join event and oldUser join event
|
||||
// Use joinTime to determine if it's a new user
|
||||
const isNewJoinEvent = selfUser.joinTime < message.joinTime
|
||||
|
||||
return isSelfJoinEvent
|
||||
? EMPTY
|
||||
: of(
|
||||
UpdateUserListCommand({ type: 'create', user: message }),
|
||||
isNewJoinEvent
|
||||
? messageListDomain.command.CreateItemCommand({
|
||||
...message,
|
||||
id: nanoid(),
|
||||
body: `"${message.username}" joined the chat`,
|
||||
type: MessageType.Prompt,
|
||||
date: Date.now()
|
||||
})
|
||||
: null
|
||||
)
|
||||
}
|
||||
case SendType.Text:
|
||||
return of(
|
||||
messageListDomain.command.CreateItemCommand({
|
||||
...message,
|
||||
type: MessageType.Normal,
|
||||
date: Date.now(),
|
||||
likeUsers: [],
|
||||
hateUsers: []
|
||||
})
|
||||
)
|
||||
case SendType.Like:
|
||||
case SendType.Hate: {
|
||||
if (!get(messageListDomain.query.HasItemQuery(message.id))) {
|
||||
return EMPTY
|
||||
}
|
||||
const _message = get(messageListDomain.query.ItemQuery(message.id)) as NormalMessage
|
||||
const type = message.type === 'like' ? 'likeUsers' : 'hateUsers'
|
||||
return of(
|
||||
messageListDomain.command.UpdateItemCommand({
|
||||
..._message,
|
||||
[type]: desert(
|
||||
_message[type],
|
||||
{
|
||||
userId: message.userId,
|
||||
username: message.username,
|
||||
userAvatar: message.userAvatar
|
||||
},
|
||||
'userId'
|
||||
)
|
||||
})
|
||||
)
|
||||
}
|
||||
default:
|
||||
console.warn('Unsupported message type', message)
|
||||
return EMPTY
|
||||
}
|
||||
})()
|
||||
return merge(messageEvent$, commandEvent$)
|
||||
})
|
||||
)
|
||||
return onMessage$
|
||||
}
|
||||
})
|
||||
|
||||
domain.effect({
|
||||
name: 'RoomOnLeaveRoomEffect',
|
||||
impl: ({ get }) => {
|
||||
const onLeaveRoom$ = fromEventPattern<string>(peerRoom.onLeaveRoom).pipe(
|
||||
map((peerId) => {
|
||||
// console.log('onLeaveRoom', peerId)
|
||||
const user = get(UserListQuery()).find((user) => user.peerId === peerId)
|
||||
|
||||
if (user) {
|
||||
return [
|
||||
UpdateUserListCommand({ type: 'delete', user }),
|
||||
messageListDomain.command.CreateItemCommand({
|
||||
...user,
|
||||
id: nanoid(),
|
||||
body: `"${user.username}" left the chat`,
|
||||
type: MessageType.Prompt,
|
||||
date: Date.now()
|
||||
}),
|
||||
OnLeaveRoomEvent(peerId)
|
||||
]
|
||||
} else {
|
||||
return [OnLeaveRoomEvent(peerId)]
|
||||
}
|
||||
})
|
||||
)
|
||||
return onLeaveRoom$
|
||||
}
|
||||
})
|
||||
|
||||
domain.effect({
|
||||
name: 'RoomOnErrorEffect',
|
||||
impl: () => {
|
||||
const onRoomError$ = fromEventPattern<Error>(peerRoom.onError).pipe(
|
||||
map((error) => {
|
||||
console.error(error)
|
||||
toast.error(error.message)
|
||||
return null
|
||||
})
|
||||
)
|
||||
return onRoomError$
|
||||
}
|
||||
})
|
||||
|
||||
// TODO: Move this to a service worker in the future, so we don't need to send a leave room message every time the page refreshes
|
||||
domain.effect({
|
||||
name: 'RoomOnUnloadEffect',
|
||||
impl: () => {
|
||||
const beforeUnload$ = fromEvent(window, 'beforeunload').pipe(
|
||||
map(() => {
|
||||
return [LeaveRoomCommand()]
|
||||
})
|
||||
)
|
||||
return beforeUnload$
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
query: {
|
||||
PeerIdQuery,
|
||||
UserListQuery,
|
||||
RoomJoinIsFinishedQuery: RoomJoinStatusModule.query.IsFinishedQuery
|
||||
},
|
||||
command: {
|
||||
JoinRoomCommand,
|
||||
LeaveRoomCommand,
|
||||
SendTextMessageCommand,
|
||||
SendLikeMessageCommand,
|
||||
SendHateMessageCommand,
|
||||
SendJoinMessageCommand
|
||||
},
|
||||
event: {
|
||||
SendTextMessageEvent,
|
||||
SendLikeMessageEvent,
|
||||
SendHateMessageEvent,
|
||||
SendJoinMessageEvent,
|
||||
JoinRoomEvent,
|
||||
LeaveRoomEvent,
|
||||
OnMessageEvent,
|
||||
OnJoinRoomEvent,
|
||||
OnLeaveRoomEvent
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export default RoomDomain
|
|
@ -1,56 +1,10 @@
|
|||
import { Remesh } from 'remesh'
|
||||
import ToastModule from './modules/Toast'
|
||||
import ChatRoomDomain, { SendType } from './ChatRoom'
|
||||
import VirtualRoomDomain from './VirtualRoom'
|
||||
import { filter, map, merge } from 'rxjs'
|
||||
|
||||
const ToastDomain = Remesh.domain({
|
||||
name: 'ToastDomain',
|
||||
impl: (domain) => {
|
||||
const chatRoomDomain = domain.getDomain(ChatRoomDomain())
|
||||
const virtualRoomDomain = domain.getDomain(VirtualRoomDomain())
|
||||
const toastModule = ToastModule(domain)
|
||||
|
||||
domain.effect({
|
||||
name: 'Toast.OnRoomSelfJoinRoomEffect',
|
||||
impl: ({ fromEvent }) => {
|
||||
const onRoomJoin$ = fromEvent(chatRoomDomain.event.SelfJoinRoomEvent).pipe(
|
||||
map(() => toastModule.command.LoadingCommand({ message: 'Connected to the chat.', duration: 3000 }))
|
||||
)
|
||||
|
||||
return onRoomJoin$
|
||||
}
|
||||
})
|
||||
|
||||
domain.effect({
|
||||
name: 'Toast.OnRoomErrorEffect',
|
||||
impl: ({ fromEvent }) => {
|
||||
const onRoomError$ = merge(
|
||||
fromEvent(chatRoomDomain.event.OnErrorEvent),
|
||||
fromEvent(virtualRoomDomain.event.OnErrorEvent)
|
||||
).pipe(
|
||||
map((error) => {
|
||||
return toastModule.command.ErrorCommand(error.message)
|
||||
})
|
||||
)
|
||||
|
||||
return onRoomError$
|
||||
}
|
||||
})
|
||||
|
||||
domain.effect({
|
||||
name: 'Toast.OnSyncHistoryEffect',
|
||||
impl: ({ fromEvent }) => {
|
||||
const onSyncHistory$ = fromEvent(chatRoomDomain.event.OnMessageEvent).pipe(
|
||||
filter((message) => message.type === SendType.SyncHistory),
|
||||
map(() => toastModule.command.SuccessCommand('Syncing history messages.'))
|
||||
)
|
||||
|
||||
return onSyncHistory$
|
||||
}
|
||||
})
|
||||
|
||||
return toastModule
|
||||
return ToastModule(domain)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
@ -10,9 +10,6 @@ export interface UserInfo {
|
|||
avatar: string
|
||||
createTime: number
|
||||
themeMode: 'system' | 'light' | 'dark'
|
||||
danmakuEnabled: boolean
|
||||
notificationEnabled: boolean
|
||||
notificationType: 'all' | 'at'
|
||||
}
|
||||
|
||||
const UserInfoDomain = Remesh.domain({
|
||||
|
@ -30,10 +27,10 @@ const UserInfoDomain = Remesh.domain({
|
|||
})
|
||||
|
||||
const UserInfoLoadStatusModule = StatusModule(domain, {
|
||||
name: 'UserInfo.LoadStatusModule'
|
||||
name: 'UserInfoLoadStatusModule'
|
||||
})
|
||||
const UserInfoSetStatusModule = StatusModule(domain, {
|
||||
name: 'UserInfo.SetStatusModule'
|
||||
name: 'UserInfoSetStatusModule'
|
||||
})
|
||||
|
||||
const UserInfoQuery = domain.query({
|
||||
|
@ -82,16 +79,16 @@ const UserInfoDomain = Remesh.domain({
|
|||
UserInfoState().new(userInfo),
|
||||
UpdateUserInfoEvent(),
|
||||
SyncToStateEvent(userInfo),
|
||||
userInfo
|
||||
? UserInfoSetStatusModule.command.SetFinishedCommand()
|
||||
: UserInfoSetStatusModule.command.SetInitialCommand()
|
||||
userInfo && UserInfoSetStatusModule.command.SetFinishedCommand()
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
storageEffect
|
||||
.set(SyncToStorageEvent)
|
||||
.get<UserInfo>((value) => [SyncToStateCommand(value), UserInfoLoadStatusModule.command.SetFinishedCommand()])
|
||||
.get<UserInfo>((value) => {
|
||||
return [SyncToStateCommand(value), UserInfoLoadStatusModule.command.SetFinishedCommand()]
|
||||
})
|
||||
.watch<UserInfo>((value) => [SyncToStateCommand(value)])
|
||||
|
||||
return {
|
||||
|
|
|
@ -1,381 +0,0 @@
|
|||
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
|
|
@ -1,30 +0,0 @@
|
|||
import { Remesh } from 'remesh'
|
||||
import { TextMessage } from '@/domain/ChatRoom'
|
||||
|
||||
export interface Danmaku {
|
||||
push: (message: TextMessage) => void
|
||||
unshift: (message: TextMessage) => void
|
||||
clear: () => void
|
||||
mount: (root: HTMLElement) => void
|
||||
unmount: () => void
|
||||
}
|
||||
|
||||
export const DanmakuExtern = Remesh.extern<Danmaku>({
|
||||
default: {
|
||||
mount: () => {
|
||||
throw new Error('"mount" not implemented.')
|
||||
},
|
||||
unmount() {
|
||||
throw new Error('"unmount" not implemented.')
|
||||
},
|
||||
clear: () => {
|
||||
throw new Error('"clear" not implemented.')
|
||||
},
|
||||
push: () => {
|
||||
throw new Error('"push" not implemented.')
|
||||
},
|
||||
unshift: () => {
|
||||
throw new Error('"unshift" not implemented.')
|
||||
}
|
||||
}
|
||||
})
|
|
@ -1,14 +0,0 @@
|
|||
import { Remesh } from 'remesh'
|
||||
import { TextMessage } from '@/domain/ChatRoom'
|
||||
|
||||
export interface Notification {
|
||||
push: (message: TextMessage) => Promise<string>
|
||||
}
|
||||
|
||||
export const NotificationExtern = Remesh.extern<Notification>({
|
||||
default: {
|
||||
push: () => {
|
||||
throw new Error('"push" not implemented.')
|
||||
}
|
||||
}
|
||||
})
|
|
@ -1,19 +1,20 @@
|
|||
import { Remesh } from 'remesh'
|
||||
import { RoomMessage } from '../ChatRoom'
|
||||
|
||||
export interface ChatRoom {
|
||||
export type PeerMessage = object | Blob | ArrayBuffer | ArrayBufferView
|
||||
|
||||
export interface PeerRoom {
|
||||
readonly peerId: string
|
||||
readonly roomId: string
|
||||
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
|
||||
joinRoom: () => PeerRoom
|
||||
sendMessage: <T extends PeerMessage>(message: T, id?: string) => PeerRoom
|
||||
onMessage: <T extends PeerMessage>(callback: (message: T) => void) => PeerRoom
|
||||
leaveRoom: () => PeerRoom
|
||||
onJoinRoom: (callback: (id: string) => void) => PeerRoom
|
||||
onLeaveRoom: (callback: (id: string) => void) => PeerRoom
|
||||
onError: (callback: (error: Error) => void) => PeerRoom
|
||||
}
|
||||
|
||||
export const ChatRoomExtern = Remesh.extern<ChatRoom>({
|
||||
export const PeerRoomExtern = Remesh.extern<PeerRoom>({
|
||||
default: {
|
||||
peerId: '',
|
||||
roomId: '',
|
|
@ -1,8 +1,10 @@
|
|||
import { Remesh } from 'remesh'
|
||||
import { type Promisable } from 'type-fest'
|
||||
|
||||
export type StorageValue = null | string | number | boolean | object
|
||||
export type WatchCallback = () => any
|
||||
export type Unwatch = () => Promise<void>
|
||||
export type WatchEvent = 'update' | 'remove'
|
||||
export type WatchCallback = (event: WatchEvent, key: string) => any
|
||||
export type Unwatch = () => Promisable<void>
|
||||
|
||||
export interface Storage {
|
||||
name: string
|
||||
|
@ -14,30 +16,6 @@ export interface Storage {
|
|||
unwatch: Unwatch
|
||||
}
|
||||
|
||||
export const LocalStorageExtern = Remesh.extern<Storage>({
|
||||
default: {
|
||||
name: 'STORAGE',
|
||||
get: async () => {
|
||||
throw new Error('"get" not implemented.')
|
||||
},
|
||||
set: async () => {
|
||||
throw new Error('"set" not implemented.')
|
||||
},
|
||||
remove: async () => {
|
||||
throw new Error('"remove" not implemented.')
|
||||
},
|
||||
clear: async () => {
|
||||
throw new Error('"clear" not implemented.')
|
||||
},
|
||||
watch: async () => {
|
||||
throw new Error('"watch" not implemented.')
|
||||
},
|
||||
unwatch: async () => {
|
||||
throw new Error('"unwatch" not implemented.')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const IndexDBStorageExtern = Remesh.extern<Storage>({
|
||||
default: {
|
||||
name: 'STORAGE',
|
||||
|
@ -53,10 +31,10 @@ export const IndexDBStorageExtern = Remesh.extern<Storage>({
|
|||
clear: async () => {
|
||||
throw new Error('"clear" not implemented.')
|
||||
},
|
||||
watch: async () => {
|
||||
watch: () => {
|
||||
throw new Error('"watch" not implemented.')
|
||||
},
|
||||
unwatch: async () => {
|
||||
unwatch: () => {
|
||||
throw new Error('"unwatch" not implemented.')
|
||||
}
|
||||
}
|
||||
|
@ -77,10 +55,10 @@ export const BrowserSyncStorageExtern = Remesh.extern<Storage>({
|
|||
clear: async () => {
|
||||
throw new Error('"clear" not implemented.')
|
||||
},
|
||||
watch: async () => {
|
||||
watch: () => {
|
||||
throw new Error('"watch" not implemented.')
|
||||
},
|
||||
unwatch: async () => {
|
||||
unwatch: () => {
|
||||
throw new Error('"unwatch" not implemented.')
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,12 +1,10 @@
|
|||
import { Remesh } from 'remesh'
|
||||
|
||||
export interface Toast {
|
||||
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
|
||||
success: (message: string) => void
|
||||
error: (message: string) => void
|
||||
info: (message: string) => void
|
||||
warning: (message: string) => void
|
||||
}
|
||||
|
||||
export const ToastExtern = Remesh.extern<Toast>({
|
||||
|
@ -22,12 +20,6 @@ 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.')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
@ -1,42 +0,0 @@
|
|||
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.')
|
||||
}
|
||||
}
|
||||
})
|
|
@ -1,74 +0,0 @@
|
|||
import { DanmakuExtern } from '@/domain/externs/Danmaku'
|
||||
|
||||
import { TextMessage } from '@/domain/ChatRoom'
|
||||
import { createElement } from 'react'
|
||||
import DanmakuMessage from '@/app/content/components/DanmakuMessage'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { create, Manager } from 'danmu'
|
||||
import { LocalStorageImpl } from './Storage'
|
||||
import { AppStatus } from '../AppStatus'
|
||||
import { APP_STATUS_STORAGE_KEY } from '@/constants/config'
|
||||
import { EVENT } from '@/constants/event'
|
||||
|
||||
export class Danmaku {
|
||||
private container?: Element
|
||||
private manager?: Manager<TextMessage>
|
||||
constructor() {
|
||||
this.manager = create<TextMessage>({
|
||||
durationRange: [7000, 10000],
|
||||
plugin: {
|
||||
$createNode(manager) {
|
||||
if (!manager.node) return
|
||||
createRoot(manager.node).render(
|
||||
createElement(DanmakuMessage, {
|
||||
data: manager.data,
|
||||
onClick: async () => {
|
||||
const appStatus = await LocalStorageImpl.value.get<AppStatus>(APP_STATUS_STORAGE_KEY)
|
||||
LocalStorageImpl.value.set<AppStatus>(APP_STATUS_STORAGE_KEY, { ...appStatus!, open: true, unread: 0 })
|
||||
dispatchEvent(new CustomEvent(EVENT.APP_OPEN))
|
||||
},
|
||||
onMouseEnter: () => manager.pause(),
|
||||
onMouseLeave: () => manager.resume()
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
mount(container: HTMLElement) {
|
||||
this.container = container
|
||||
this.manager!.mount(container)
|
||||
this.manager!.startPlaying()
|
||||
}
|
||||
|
||||
unmount() {
|
||||
if (!this.container) {
|
||||
throw new Error('Danmaku not mounted')
|
||||
}
|
||||
this.manager!.unmount()
|
||||
}
|
||||
|
||||
push(message: TextMessage) {
|
||||
if (!this.container) {
|
||||
throw new Error('Danmaku not mounted')
|
||||
}
|
||||
this.manager!.push(message)
|
||||
}
|
||||
|
||||
unshift(message: TextMessage) {
|
||||
if (!this.container) {
|
||||
throw new Error('Danmaku not mounted')
|
||||
}
|
||||
this.manager!.unshift(message)
|
||||
}
|
||||
|
||||
clear() {
|
||||
if (!this.container) {
|
||||
throw new Error('Danmaku not mounted')
|
||||
}
|
||||
this.manager!.clear()
|
||||
}
|
||||
}
|
||||
|
||||
export const DanmakuImpl = DanmakuExtern.impl(new Danmaku())
|
|
@ -1,13 +0,0 @@
|
|||
import { NotificationExtern } from '@/domain/externs/Notification'
|
||||
import { TextMessage } from '@/domain/ChatRoom'
|
||||
import { EVENT } from '@/constants/event'
|
||||
import { messenger } from '@/messenger'
|
||||
|
||||
class Notification {
|
||||
async push(message: TextMessage) {
|
||||
await messenger.sendMessage(EVENT.NOTIFICATION_PUSH, message)
|
||||
return message.id
|
||||
}
|
||||
}
|
||||
|
||||
export const NotificationImpl = NotificationExtern.impl(new Notification())
|
|
@ -1,22 +0,0 @@
|
|||
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
|
||||
}
|
||||
}
|
|
@ -1,29 +1,26 @@
|
|||
import { Room } from '@rtco/client'
|
||||
import { type DataPayload, type Room, joinRoom, selfId } from 'trystero'
|
||||
|
||||
import { VirtualRoomExtern } from '@/domain/externs/VirtualRoom'
|
||||
// import { joinRoom } from 'trystero/firebase'
|
||||
|
||||
import { PeerRoomExtern, type PeerMessage } from '@/domain/externs/PeerRoom'
|
||||
import { stringToHex } from '@/utils'
|
||||
import EventHub from '@resreq/event-hub'
|
||||
import { RoomMessage } from '@/domain/VirtualRoom'
|
||||
import { JSONR } from '@/utils'
|
||||
import { VIRTUAL_ROOM_ID } from '@/constants/config'
|
||||
import Peer from './Peer'
|
||||
|
||||
export interface Config {
|
||||
peer: Peer
|
||||
peerId?: string
|
||||
roomId: string
|
||||
}
|
||||
|
||||
class VirtualRoom extends EventHub {
|
||||
readonly peer: Peer
|
||||
class PeerRoom extends EventHub {
|
||||
readonly appId: string
|
||||
private room?: Room
|
||||
readonly roomId: string
|
||||
readonly peerId: string
|
||||
private room?: Room
|
||||
|
||||
constructor(config: Config) {
|
||||
super()
|
||||
this.peer = config.peer
|
||||
this.appId = __NAME__
|
||||
this.roomId = config.roomId
|
||||
this.peerId = config.peer.id
|
||||
this.peerId = selfId
|
||||
this.joinRoom = this.joinRoom.bind(this)
|
||||
this.sendMessage = this.sendMessage.bind(this)
|
||||
this.onMessage = this.onMessage.bind(this)
|
||||
|
@ -34,48 +31,50 @@ class VirtualRoom extends EventHub {
|
|||
}
|
||||
|
||||
joinRoom() {
|
||||
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')
|
||||
})
|
||||
}
|
||||
}
|
||||
this.room = joinRoom({ appId: this.appId }, this.roomId)
|
||||
/**
|
||||
* If we wait to join, it will result in not being able to listen to our own join event.
|
||||
* This might be related to the fact that:
|
||||
* (If called more than once, only the latest callback registered is ever called.)
|
||||
* Multiple listeners may overwrite each other.
|
||||
* @see: https://github.com/dmotz/trystero?tab=readme-ov-file#onpeerjoincallback
|
||||
*/
|
||||
// this.room.onPeerJoin(() => this.emit('action'))
|
||||
this.emit('action')
|
||||
return this
|
||||
}
|
||||
|
||||
sendMessage(message: RoomMessage, id?: string | string[]) {
|
||||
sendMessage<T extends PeerMessage>(message: T, id?: string) {
|
||||
if (!this.room) {
|
||||
this.once('action', () => {
|
||||
if (!this.room) {
|
||||
this.emit('error', new Error('Room not joined'))
|
||||
} else {
|
||||
this.room.send(JSONR.stringify(message)!, id)
|
||||
const [send] = this.room.makeAction('MESSAGE')
|
||||
send(message as DataPayload, id)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
this.room.send(JSONR.stringify(message)!, id)
|
||||
const [send] = this.room.makeAction('MESSAGE')
|
||||
send(message as DataPayload, id)
|
||||
}
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
onMessage(callback: (message: RoomMessage) => void) {
|
||||
onMessage<T extends PeerMessage>(callback: (message: T) => void) {
|
||||
if (!this.room) {
|
||||
this.once('action', () => {
|
||||
if (!this.room) {
|
||||
this.emit('error', new Error('Room not joined'))
|
||||
} else {
|
||||
this.room.on('message', (message) => callback(JSONR.parse(message) as RoomMessage))
|
||||
const [, on] = this.room.makeAction('MESSAGE')
|
||||
on((message) => callback(message as T))
|
||||
}
|
||||
})
|
||||
} else {
|
||||
this.room.on('message', (message) => callback(JSONR.parse(message) as RoomMessage))
|
||||
const [, on] = this.room.makeAction('MESSAGE')
|
||||
on((message) => callback(message as T))
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
@ -86,11 +85,15 @@ class VirtualRoom extends EventHub {
|
|||
if (!this.room) {
|
||||
this.emit('error', new Error('Room not joined'))
|
||||
} else {
|
||||
this.room.on('join', (id) => callback(id))
|
||||
this.room.onPeerJoin((peerId) => {
|
||||
callback(peerId)
|
||||
})
|
||||
}
|
||||
})
|
||||
} else {
|
||||
this.room.on('join', (id) => callback(id))
|
||||
this.room.onPeerJoin((peerId) => {
|
||||
callback(peerId)
|
||||
})
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
@ -101,11 +104,11 @@ class VirtualRoom extends EventHub {
|
|||
if (!this.room) {
|
||||
this.emit('error', new Error('Room not joined'))
|
||||
} else {
|
||||
this.room.on('leave', (id) => callback(id))
|
||||
this.room.onPeerLeave((peerId) => callback(peerId))
|
||||
}
|
||||
})
|
||||
} else {
|
||||
this.room.on('leave', (id) => callback(id))
|
||||
this.room.onPeerLeave((peerId) => callback(peerId))
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
@ -126,15 +129,18 @@ class VirtualRoom 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(VIRTUAL_ROOM_ID)
|
||||
const hostRoomId = stringToHex(document.location.host)
|
||||
const peerRoom = new PeerRoom({ roomId: hostRoomId })
|
||||
|
||||
const virtualRoom = new VirtualRoom({ roomId: hostRoomId, peer: Peer.createInstance() })
|
||||
export const PeerRoomImpl = PeerRoomExtern.impl(peerRoom)
|
||||
|
||||
export const VirtualRoomImpl = VirtualRoomExtern.impl(virtualRoom)
|
||||
// https://github.com/w3c/webextensions/issues/72
|
||||
// https://issues.chromium.org/issues/40251342
|
||||
// https://github.com/w3c/webrtc-extensions/issues/77
|
|
@ -1,28 +1,24 @@
|
|||
import { Room } from '@rtco/client'
|
||||
import { Artico, Room } from '@rtco/client'
|
||||
|
||||
import { ChatRoomExtern } from '@/domain/externs/ChatRoom'
|
||||
import { PeerRoomExtern, type PeerMessage } from '@/domain/externs/PeerRoom'
|
||||
import { stringToHex } from '@/utils'
|
||||
import { nanoid } from 'nanoid'
|
||||
import EventHub from '@resreq/event-hub'
|
||||
import { RoomMessage } from '@/domain/ChatRoom'
|
||||
import { JSONR } from '@/utils'
|
||||
import Peer from './Peer'
|
||||
|
||||
export interface Config {
|
||||
peer: Peer
|
||||
peerId?: string
|
||||
roomId: string
|
||||
}
|
||||
|
||||
class ChatRoom extends EventHub {
|
||||
readonly peer: Peer
|
||||
class PeerRoom extends EventHub {
|
||||
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.peer.id
|
||||
this.peerId = config.peerId || nanoid()
|
||||
this.joinRoom = this.joinRoom.bind(this)
|
||||
this.sendMessage = this.sendMessage.bind(this)
|
||||
this.onMessage = this.onMessage.bind(this)
|
||||
|
@ -33,48 +29,46 @@ class ChatRoom extends EventHub {
|
|||
}
|
||||
|
||||
joinRoom() {
|
||||
if (!this.rtco) {
|
||||
this.rtco = new Artico({ id: this.peerId })
|
||||
}
|
||||
if (this.room) {
|
||||
this.room = this.peer.join(this.roomId)
|
||||
this.room = this.rtco.join(this.roomId)
|
||||
} else {
|
||||
if (this.peer.state === 'ready') {
|
||||
this.room = this.peer.join(this.roomId)
|
||||
this.rtco!.on('open', () => {
|
||||
this.room = this.rtco!.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 | string[]) {
|
||||
sendMessage<T extends PeerMessage>(message: T, id?: string) {
|
||||
if (!this.room) {
|
||||
this.once('action', () => {
|
||||
if (!this.room) {
|
||||
this.emit('error', new Error('Room not joined'))
|
||||
} else {
|
||||
this.room.send(JSONR.stringify(message)!, id)
|
||||
this.room.send(JSON.stringify(message), id)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
this.room.send(JSONR.stringify(message)!, id)
|
||||
this.room.send(JSON.stringify(message), id)
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
onMessage(callback: (message: RoomMessage) => void) {
|
||||
onMessage<T extends PeerMessage>(callback: (message: T) => void) {
|
||||
if (!this.room) {
|
||||
this.once('action', () => {
|
||||
if (!this.room) {
|
||||
this.emit('error', new Error('Room not joined'))
|
||||
} else {
|
||||
this.room.on('message', (message) => callback(JSONR.parse(message) as RoomMessage))
|
||||
this.room.on('message', (message) => callback(JSON.parse(message) as T))
|
||||
}
|
||||
})
|
||||
} else {
|
||||
this.room.on('message', (message) => callback(JSONR.parse(message) as RoomMessage))
|
||||
this.room.on('message', (message) => callback(JSON.parse(message) as T))
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
@ -126,7 +120,7 @@ class ChatRoom extends EventHub {
|
|||
return this
|
||||
}
|
||||
onError(callback: (error: Error) => void) {
|
||||
this.peer?.on('error', (error) => callback(error))
|
||||
this.rtco?.on('error', (error) => callback(error))
|
||||
this.on('error', (error: Error) => callback(error))
|
||||
return this
|
||||
}
|
||||
|
@ -134,11 +128,10 @@ class ChatRoom extends EventHub {
|
|||
|
||||
const hostRoomId = stringToHex(document.location.host)
|
||||
|
||||
const chatRoom = new ChatRoom({ roomId: hostRoomId, peer: Peer.createInstance() })
|
||||
const peerRoom = new PeerRoom({ roomId: hostRoomId })
|
||||
|
||||
export const ChatRoomImpl = ChatRoomExtern.impl(chatRoom)
|
||||
export const PeerRoomImpl = PeerRoomExtern.impl(peerRoom)
|
||||
|
||||
// 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
|
|
@ -1,22 +1,9 @@
|
|||
import { createStorage } from 'unstorage'
|
||||
import indexedDbDriver from 'unstorage/drivers/indexedb'
|
||||
import localStorageDriver from 'unstorage/drivers/localstorage'
|
||||
import { LocalStorageExtern, IndexDBStorageExtern, BrowserSyncStorageExtern } from '@/domain/externs/Storage'
|
||||
import { IndexDBStorageExtern, BrowserSyncStorageExtern } from '@/domain/externs/Storage'
|
||||
import { STORAGE_NAME } from '@/constants/config'
|
||||
import { webExtensionDriver } from '@/utils/webExtensionDriver'
|
||||
|
||||
import { Storage } from '@/domain/externs/Storage'
|
||||
import { EVENT } from '@/constants/event'
|
||||
|
||||
/**
|
||||
* Waiting to be resolved
|
||||
* @see https://github.com/unjs/unstorage/issues/277
|
||||
* */
|
||||
|
||||
export const localStorage = createStorage({
|
||||
driver: localStorageDriver({ base: `${STORAGE_NAME}:` })
|
||||
})
|
||||
|
||||
export const indexDBStorage = createStorage({
|
||||
driver: indexedDbDriver({ base: `${STORAGE_NAME}:` })
|
||||
})
|
||||
|
@ -25,36 +12,13 @@ export const browserSyncStorage = createStorage({
|
|||
driver: webExtensionDriver({ storageArea: 'sync' })
|
||||
})
|
||||
|
||||
export const LocalStorageImpl = LocalStorageExtern.impl({
|
||||
name: STORAGE_NAME,
|
||||
get: localStorage.getItem,
|
||||
set: localStorage.setItem,
|
||||
remove: localStorage.removeItem,
|
||||
clear: localStorage.clear,
|
||||
watch: async (callback) => {
|
||||
const unwatch = await localStorage.watch(callback)
|
||||
|
||||
/**
|
||||
* Because the storage event cannot be triggered in the same browsing context
|
||||
* it is necessary to listen for click events from DanmukuMessage.
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/API/Window/storage_event
|
||||
*/
|
||||
addEventListener(EVENT.APP_OPEN, callback)
|
||||
return async () => {
|
||||
removeEventListener(EVENT.APP_OPEN, callback)
|
||||
return unwatch()
|
||||
}
|
||||
},
|
||||
unwatch: localStorage.unwatch
|
||||
})
|
||||
|
||||
export const IndexDBStorageImpl = IndexDBStorageExtern.impl({
|
||||
name: STORAGE_NAME,
|
||||
get: indexDBStorage.getItem,
|
||||
set: indexDBStorage.setItem,
|
||||
remove: indexDBStorage.removeItem,
|
||||
clear: indexDBStorage.clear,
|
||||
watch: indexDBStorage.watch as Storage['watch'],
|
||||
watch: indexDBStorage.watch,
|
||||
unwatch: indexDBStorage.unwatch
|
||||
})
|
||||
|
||||
|
@ -64,6 +28,6 @@ export const BrowserSyncStorageImpl = BrowserSyncStorageExtern.impl({
|
|||
set: browserSyncStorage.setItem,
|
||||
remove: browserSyncStorage.removeItem,
|
||||
clear: browserSyncStorage.clear,
|
||||
watch: browserSyncStorage.watch as Storage['watch'],
|
||||
watch: browserSyncStorage.watch,
|
||||
unwatch: browserSyncStorage.unwatch
|
||||
})
|
||||
|
|
|
@ -2,24 +2,16 @@ import { toast } from 'sonner'
|
|||
import { ToastExtern } from '@/domain/externs/Toast'
|
||||
|
||||
export const ToastImpl = ToastExtern.impl({
|
||||
success: (message: string, duration: number = 4000) => {
|
||||
return toast.success(message, { duration })
|
||||
success: (message: string) => {
|
||||
toast.success(message)
|
||||
},
|
||||
error: (message: string, duration: number = 4000) => {
|
||||
return toast.error(message, { duration })
|
||||
error: (message: string) => {
|
||||
toast.error(message)
|
||||
},
|
||||
info: (message: string, duration: number = 4000) => {
|
||||
return toast.info(message, { duration })
|
||||
info: (message: string) => {
|
||||
toast.info(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)
|
||||
warning: (message: string) => {
|
||||
toast.warning(message)
|
||||
}
|
||||
})
|
||||
|
|
|
@ -1,7 +1,18 @@
|
|||
import { type RemeshEvent, type RemeshAction, type RemeshDomainContext, type RemeshExtern } from 'remesh'
|
||||
import { from, map, Observable, switchMap } from 'rxjs'
|
||||
import { defer, from, fromEventPattern, map, Observable, switchMap } from 'rxjs'
|
||||
import { type Promisable } from 'type-fest'
|
||||
|
||||
import { Storage, StorageValue } from '@/domain/externs/Storage'
|
||||
export type StorageValue = null | string | number | boolean | object
|
||||
export type WatchEvent = 'update' | 'remove'
|
||||
export type WatchCallback = (event: WatchEvent, key: string) => any
|
||||
export type Unwatch = () => Promisable<void>
|
||||
|
||||
export interface Storage {
|
||||
get: <T extends StorageValue>(key: string) => Promise<T | null>
|
||||
set: <T extends StorageValue>(key: string, value: T) => Promise<void>
|
||||
watch: (callback: WatchCallback) => Promise<Unwatch>
|
||||
unwatch?: Unwatch
|
||||
}
|
||||
|
||||
export interface Options {
|
||||
domain: RemeshDomainContext
|
||||
|
@ -49,14 +60,20 @@ export default class StorageEffect {
|
|||
this.domain.effect({
|
||||
name: 'WatchStorageToStateEffect',
|
||||
impl: () => {
|
||||
// TODO: Report the bug to https://github.com/unjs/unstorage
|
||||
return new Observable((observer) => {
|
||||
const unwatchPromise = this.storage.watch(() => observer.next())
|
||||
return () => unwatchPromise.then((unwatch) => unwatch())
|
||||
}).pipe(
|
||||
switchMap(() => from(this.storage.get<T | null>(this.key))),
|
||||
map(callback)
|
||||
)
|
||||
return defer(() => {
|
||||
let unwatch: Unwatch
|
||||
return new Observable<void>((observer) => {
|
||||
this.storage
|
||||
.watch(() => observer.next())
|
||||
.then((_unwatch) => {
|
||||
unwatch = _unwatch
|
||||
})
|
||||
return () => unwatch?.()
|
||||
}).pipe(
|
||||
switchMap(() => from(this.storage.get<T | null>(this.key))),
|
||||
map(callback)
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
||||
return this
|
||||
|
|
|
@ -6,92 +6,53 @@ export interface ToastOptions {
|
|||
}
|
||||
|
||||
const ToastModule = (domain: RemeshDomainContext, options: ToastOptions = { name: 'MessageToastModule' }) => {
|
||||
const toastExtern = domain.getExtern(ToastExtern)
|
||||
const toast = domain.getExtern(ToastExtern)
|
||||
|
||||
const SuccessEvent = domain.event<number | string>({
|
||||
const SuccessEvent = domain.event({
|
||||
name: `${options.name}.SuccessEvent`
|
||||
})
|
||||
|
||||
const SuccessCommand = domain.command({
|
||||
name: `${options.name}.SuccessCommand`,
|
||||
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)]
|
||||
impl: (_, message: string) => {
|
||||
toast.success(message)
|
||||
return [SuccessEvent()]
|
||||
}
|
||||
})
|
||||
|
||||
const ErrorEvent = domain.event<number | string>({
|
||||
const ErrorEvent = domain.event({
|
||||
name: `${options.name}.ErrorEvent`
|
||||
})
|
||||
|
||||
const ErrorCommand = domain.command({
|
||||
name: `${options.name}.ErrorCommand`,
|
||||
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)]
|
||||
impl: (_, message: string) => {
|
||||
toast.error(message)
|
||||
return [ErrorEvent()]
|
||||
}
|
||||
})
|
||||
|
||||
const InfoEvent = domain.event<number | string>({
|
||||
const InfoEvent = domain.event({
|
||||
name: `${options.name}.InfoEvent`
|
||||
})
|
||||
|
||||
const InfoCommand = domain.command({
|
||||
name: `${options.name}.InfoCommand`,
|
||||
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)]
|
||||
impl: (_, message: string) => {
|
||||
toast.info(message)
|
||||
return [InfoEvent()]
|
||||
}
|
||||
})
|
||||
|
||||
const WarningEvent = domain.event<number | string>({
|
||||
const WarningEvent = domain.event({
|
||||
name: `${options.name}.WarningEvent`
|
||||
})
|
||||
|
||||
const WarningCommand = domain.command({
|
||||
name: `${options.name}.WarningCommand`,
|
||||
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)]
|
||||
impl: (_, message: string) => {
|
||||
toast.warning(message)
|
||||
return [WarningEvent()]
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -100,17 +61,13 @@ const ToastModule = (domain: RemeshDomainContext, options: ToastOptions = { name
|
|||
SuccessEvent,
|
||||
ErrorEvent,
|
||||
InfoEvent,
|
||||
WarningEvent,
|
||||
LoadingEvent,
|
||||
CancelEvent
|
||||
WarningEvent
|
||||
},
|
||||
command: {
|
||||
SuccessCommand,
|
||||
ErrorCommand,
|
||||
InfoCommand,
|
||||
WarningCommand,
|
||||
LoadingCommand,
|
||||
CancelCommand
|
||||
WarningCommand
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
51
src/hooks/useClickAway.ts
Normal file
51
src/hooks/useClickAway.ts
Normal file
|
@ -0,0 +1,51 @@
|
|||
import { type RefObject, useEffect, useRef } from 'react'
|
||||
|
||||
export type Events = Array<keyof GlobalEventHandlersEventMap>
|
||||
|
||||
/**
|
||||
* Waiting for PR merge
|
||||
* @see https://github.com/streamich/react-use/pull/2528
|
||||
*/
|
||||
const useClickAway = <E extends Event = Event>(
|
||||
ref: RefObject<HTMLElement | null>,
|
||||
onClickAway: (event: E) => void,
|
||||
events: Events = ['mousedown', 'touchstart']
|
||||
) => {
|
||||
const savedCallback = useRef(onClickAway)
|
||||
useEffect(() => {
|
||||
savedCallback.current = onClickAway
|
||||
}, [onClickAway])
|
||||
useEffect(() => {
|
||||
const { current: el } = ref
|
||||
if (!el) return
|
||||
|
||||
const rootNode = el.getRootNode()
|
||||
const isInShadow = rootNode instanceof ShadowRoot
|
||||
|
||||
/**
|
||||
* When events are captured outside the component, events that occur in shadow DOM will target the host element
|
||||
* so additional event listeners need to be added for shadowDom
|
||||
*
|
||||
* document shadowDom target
|
||||
* | | |
|
||||
* |- on(document) -|- on(shadowRoot) -|
|
||||
*/
|
||||
const handler = (event: SafeAny) => {
|
||||
!el.contains(event.target) && event.target.shadowRoot !== rootNode && savedCallback.current(event)
|
||||
}
|
||||
for (const eventName of events) {
|
||||
// eslint-disable-next-line @eslint-react/web-api/no-leaked-event-listener
|
||||
document.addEventListener(eventName, handler)
|
||||
// eslint-disable-next-line @eslint-react/web-api/no-leaked-event-listener
|
||||
isInShadow && rootNode.addEventListener(eventName, handler)
|
||||
}
|
||||
return () => {
|
||||
for (const eventName of events) {
|
||||
document.removeEventListener(eventName, handler)
|
||||
isInShadow && rootNode.removeEventListener(eventName, handler)
|
||||
}
|
||||
}
|
||||
}, [events, ref])
|
||||
}
|
||||
|
||||
export default useClickAway
|
|
@ -1,43 +0,0 @@
|
|||
import { RefCallback, useCallback, useRef, useState } from 'react'
|
||||
import getCursorPosition, { Position } from '@/utils/getCursorPosition'
|
||||
|
||||
const useCursorPosition = () => {
|
||||
const [position, setPosition] = useState<Position>({ x: 0, y: 0, selectionStart: 0, selectionEnd: 0 })
|
||||
|
||||
const handler = async (e: Event) => {
|
||||
const newPosition = await getCursorPosition(e.target as HTMLInputElement | HTMLTextAreaElement)
|
||||
if (JSON.stringify(newPosition) !== JSON.stringify(position)) {
|
||||
setPosition(newPosition)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRef = useRef<HTMLInputElement | HTMLTextAreaElement | null>(null)
|
||||
|
||||
const setRef: RefCallback<HTMLInputElement | HTMLTextAreaElement | null> = useCallback(
|
||||
(node) => {
|
||||
if (handleRef.current) {
|
||||
handleRef.current.removeEventListener('click', handler)
|
||||
handleRef.current.removeEventListener('input', handler)
|
||||
handleRef.current.removeEventListener('keydown', handler)
|
||||
handleRef.current.removeEventListener('keyup', handler)
|
||||
handleRef.current.removeEventListener('focus', handler)
|
||||
}
|
||||
if (node) {
|
||||
node.addEventListener('click', handler)
|
||||
node.addEventListener('input', handler)
|
||||
node.addEventListener('keydown', handler)
|
||||
node.addEventListener('keyup', handler)
|
||||
node.addEventListener('focus', handler)
|
||||
}
|
||||
handleRef.current = node
|
||||
},
|
||||
[handler]
|
||||
)
|
||||
|
||||
return {
|
||||
...position,
|
||||
setRef
|
||||
}
|
||||
}
|
||||
|
||||
export default useCursorPosition
|
|
@ -1,94 +0,0 @@
|
|||
import { clamp, isInRange } from '@/utils'
|
||||
import { useCallback, useLayoutEffect, useRef, useState } from 'react'
|
||||
|
||||
export interface DargOptions {
|
||||
initX: number
|
||||
initY: number
|
||||
maxX: number
|
||||
minX: number
|
||||
maxY: number
|
||||
minY: number
|
||||
}
|
||||
|
||||
const useDraggable = (options: DargOptions) => {
|
||||
const { initX, initY, maxX = 0, minX = 0, maxY = 0, minY = 0 } = options
|
||||
|
||||
const mousePosition = useRef({ x: 0, y: 0 })
|
||||
|
||||
const [position, setPosition] = useState({ x: clamp(initX, minX, maxX), y: clamp(initY, minY, maxY) })
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const newPosition = { x: clamp(initX, minX, maxX), y: clamp(initY, minY, maxY) }
|
||||
if (JSON.stringify(newPosition) !== JSON.stringify(position)) {
|
||||
setPosition(newPosition)
|
||||
}
|
||||
}, [initX, initY, maxX, minX, maxY, minY])
|
||||
|
||||
const isMove = useRef(false)
|
||||
|
||||
const handleMove = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
if (isMove.current) {
|
||||
const { clientX, clientY } = e
|
||||
const delta = {
|
||||
x: position.x + clientX - mousePosition.current.x,
|
||||
y: position.y + clientY - mousePosition.current.y
|
||||
}
|
||||
|
||||
const hasChanged = delta.x !== position.x || delta.y !== position.y
|
||||
|
||||
if (isInRange(delta.x, minX, maxX)) {
|
||||
mousePosition.current.x = clientX
|
||||
}
|
||||
if (isInRange(delta.y, minY, maxY)) {
|
||||
mousePosition.current.y = clientY
|
||||
}
|
||||
if (hasChanged) {
|
||||
setPosition(() => {
|
||||
const x = clamp(delta.x, minX, maxX)
|
||||
const y = clamp(delta.y, minY, maxY)
|
||||
return { x, y }
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
[minX, maxX, minY, maxY, position]
|
||||
)
|
||||
|
||||
const handleEnd = useCallback(() => {
|
||||
isMove.current = false
|
||||
document.documentElement.style.cursor = ''
|
||||
document.documentElement.style.userSelect = ''
|
||||
}, [])
|
||||
|
||||
const handleStart = useCallback((e: MouseEvent) => {
|
||||
const { clientX, clientY } = e
|
||||
mousePosition.current = { x: clientX, y: clientY }
|
||||
isMove.current = true
|
||||
document.documentElement.style.userSelect = 'none'
|
||||
document.documentElement.style.cursor = 'grab'
|
||||
}, [])
|
||||
|
||||
const handleRef = useRef<HTMLElement | null>(null)
|
||||
|
||||
const setRef = useCallback(
|
||||
(node: HTMLElement | null) => {
|
||||
if (handleRef.current) {
|
||||
handleRef.current.removeEventListener('mousedown', handleStart)
|
||||
document.removeEventListener('mouseup', handleEnd)
|
||||
document.removeEventListener('mousemove', handleMove)
|
||||
}
|
||||
if (node) {
|
||||
node.addEventListener('mousedown', handleStart)
|
||||
document.addEventListener('mouseup', handleEnd)
|
||||
document.addEventListener('mousemove', handleMove)
|
||||
}
|
||||
handleRef.current = node
|
||||
},
|
||||
[handleEnd, handleMove, handleStart]
|
||||
)
|
||||
|
||||
return { setRef, ...position }
|
||||
}
|
||||
|
||||
export default useDraggable
|
|
@ -1,4 +1,4 @@
|
|||
import { RefCallback, useCallback, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { clamp, isInRange } from '@/utils'
|
||||
|
||||
export interface ResizableOptions {
|
||||
|
@ -11,14 +11,7 @@ export interface ResizableOptions {
|
|||
const useResizable = (options: ResizableOptions) => {
|
||||
const { minSize, maxSize, initSize = 0, direction } = options
|
||||
|
||||
const [size, setSize] = useState(clamp(initSize, minSize, maxSize))
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const newSize = clamp(initSize, minSize, maxSize)
|
||||
if (newSize !== size) {
|
||||
setSize(newSize)
|
||||
}
|
||||
}, [initSize, minSize, maxSize])
|
||||
const [size, setSize] = useState(initSize)
|
||||
|
||||
const position = useRef(0)
|
||||
|
||||
|
@ -74,13 +67,13 @@ const useResizable = (options: ResizableOptions) => {
|
|||
[isHorizontal]
|
||||
)
|
||||
|
||||
const handlerRef = useRef<HTMLElement | null>(null)
|
||||
const ref = useRef<HTMLElement | null>(null)
|
||||
|
||||
// Watch ref: https://medium.com/@teh_builder/ref-objects-inside-useeffect-hooks-eb7c15198780
|
||||
const setRef: RefCallback<HTMLElement | null> = useCallback(
|
||||
(node) => {
|
||||
if (handlerRef.current) {
|
||||
handlerRef.current.removeEventListener('mousedown', handleStart)
|
||||
const setRef = useCallback(
|
||||
(node: HTMLElement | null) => {
|
||||
if (ref.current) {
|
||||
ref.current.removeEventListener('mousedown', handleStart)
|
||||
document.removeEventListener('mouseup', handleEnd)
|
||||
document.removeEventListener('mousemove', handleMove)
|
||||
}
|
||||
|
@ -89,12 +82,12 @@ const useResizable = (options: ResizableOptions) => {
|
|||
document.addEventListener('mouseup', handleEnd)
|
||||
document.addEventListener('mousemove', handleMove)
|
||||
}
|
||||
handlerRef.current = node
|
||||
ref.current = node
|
||||
},
|
||||
[handleEnd, handleMove, handleStart]
|
||||
)
|
||||
|
||||
return { size, setRef }
|
||||
return { size, ref: setRef }
|
||||
}
|
||||
|
||||
export default useResizable
|
||||
|
|
|
@ -1,21 +0,0 @@
|
|||
import { ForwardedRef, MutableRefObject, RefCallback, useCallback } from 'react'
|
||||
|
||||
const useShareRef = <T extends HTMLElement | null>(
|
||||
...refs: (MutableRefObject<T> | ForwardedRef<T> | RefCallback<T>)[]
|
||||
) => {
|
||||
const setRef = useCallback(
|
||||
(node: T) =>
|
||||
refs.forEach((ref) => {
|
||||
if (typeof ref === 'function') {
|
||||
ref(node)
|
||||
} else if (ref) {
|
||||
ref.current = node
|
||||
}
|
||||
}),
|
||||
[...refs]
|
||||
)
|
||||
|
||||
return setRef
|
||||
}
|
||||
|
||||
export default useShareRef
|
|
@ -1,52 +0,0 @@
|
|||
import { RefCallback, useCallback, useRef } from 'react'
|
||||
|
||||
export type Events = Array<keyof GlobalEventHandlersEventMap>
|
||||
|
||||
/**
|
||||
* @see https://github.com/streamich/react-use/pull/2528
|
||||
*/
|
||||
const useTriggerAway = <E extends Event = Event>(events: Events, callback: (event: E) => void) => {
|
||||
const handleRef = useRef<HTMLElement | null>(null)
|
||||
|
||||
const handler = (event: SafeAny) => {
|
||||
const rootNode = handleRef.current?.getRootNode()
|
||||
!handleRef.current?.contains(event.target) && event.target.shadowRoot !== rootNode && callback(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* When events are captured outside the component, events that occur in shadow DOM will target the host element
|
||||
* so additional event listeners need to be added for shadowDom
|
||||
*
|
||||
* document shadowDom target
|
||||
* | | |
|
||||
* |- on(document) -|- on(shadowRoot) -|
|
||||
*/
|
||||
const setRef: RefCallback<HTMLElement | null> = useCallback(
|
||||
(node) => {
|
||||
if (handleRef.current) {
|
||||
const rootNode = handleRef.current.getRootNode()
|
||||
const isInShadow = rootNode instanceof ShadowRoot
|
||||
events.forEach(() => {
|
||||
for (const eventName of events) {
|
||||
document.removeEventListener(eventName, handler)
|
||||
isInShadow && rootNode.removeEventListener(eventName, handler)
|
||||
}
|
||||
})
|
||||
}
|
||||
if (node) {
|
||||
const rootNode = node.getRootNode()
|
||||
const isInShadow = rootNode instanceof ShadowRoot
|
||||
events.forEach((eventName) => {
|
||||
document.addEventListener(eventName, handler)
|
||||
isInShadow && rootNode.addEventListener(eventName, handler)
|
||||
})
|
||||
}
|
||||
handleRef.current = node
|
||||
},
|
||||
[handler]
|
||||
)
|
||||
|
||||
return { setRef }
|
||||
}
|
||||
|
||||
export default useTriggerAway
|
|
@ -1,22 +0,0 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
|
||||
const useWindowResize = (callback?: ({ width, height }: { width: number; height: number }) => void) => {
|
||||
const [size, setSize] = useState({ width: window.innerWidth, height: window.innerHeight })
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
const width = window.innerWidth
|
||||
const height = window.innerHeight
|
||||
setSize({ width, height })
|
||||
callback?.({ width, height })
|
||||
}
|
||||
window.addEventListener('resize', handler)
|
||||
return () => {
|
||||
window.removeEventListener('resize', handler)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return size
|
||||
}
|
||||
|
||||
export default useWindowResize
|
|
@ -1,11 +0,0 @@
|
|||
import { EVENT } from '@/constants/event'
|
||||
import { defineExtensionMessaging } from '@webext-core/messaging'
|
||||
import { TextMessage } from '@/domain/ChatRoom'
|
||||
|
||||
interface ProtocolMap {
|
||||
[EVENT.OPTIONS_PAGE_OPEN]: () => void
|
||||
[EVENT.NOTIFICATION_PUSH]: (message: TextMessage) => void
|
||||
[EVENT.NOTIFICATION_CLEAR]: (id: string) => void
|
||||
}
|
||||
|
||||
export const messenger = defineExtensionMessaging<ProtocolMap>()
|
BIN
src/public/Example.png
Normal file
BIN
src/public/Example.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 987 KiB |
22
src/types/shim.d.ts
vendored
22
src/types/shim.d.ts
vendored
|
@ -1,7 +1,17 @@
|
|||
declare module '*.svg' {
|
||||
import * as React from 'react'
|
||||
|
||||
const ReactComponent: React.FunctionComponent<React.ComponentProps<'svg'> & { title?: string }>
|
||||
|
||||
export default ReactComponent
|
||||
// issues: https://github.com/facebook/react/issues/17157
|
||||
// issues: https://github.com/facebook/react/pull/24730
|
||||
declare module 'react' {
|
||||
interface HTMLAttributes<T> extends AriaAttributes, DOMAttributes<T> {
|
||||
inert?: boolean | undefined | ''
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
namespace JSX {
|
||||
interface IntrinsicAttributes {
|
||||
inert?: boolean | undefined | ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
|
|
|
@ -1,9 +0,0 @@
|
|||
const asyncMap = async <T = any, U = any>(list: T[], run: (arg: T, index: number, list: T[]) => Promise<U>) => {
|
||||
const task: U[] = []
|
||||
for (let index = 0; index < list.length; index++) {
|
||||
task.push(await run(list[index], index, list))
|
||||
}
|
||||
return task
|
||||
}
|
||||
|
||||
export default asyncMap
|
|
@ -1,10 +0,0 @@
|
|||
const blobToBase64 = (blob: Blob) => {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => resolve(e.target?.result as string)
|
||||
reader.onerror = () => reject(new Error('Failed to read file.'))
|
||||
reader.readAsDataURL(blob)
|
||||
})
|
||||
}
|
||||
|
||||
export default blobToBase64
|
|
@ -1,11 +0,0 @@
|
|||
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
|
3
src/utils/checkSystemDarkMode.ts
Normal file
3
src/utils/checkSystemDarkMode.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
const checkSystemDarkMode = () => window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
|
||||
export default checkSystemDarkMode
|
|
@ -59,21 +59,15 @@ 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.')
|
||||
throw new Error('Invalid input type, only support image/jpeg, image/png, image/webp')
|
||||
}
|
||||
|
||||
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) {
|
||||
if (input.size <= targetSize) {
|
||||
return input
|
||||
}
|
||||
|
||||
const outputType = options.outputType || (input.type as ImageType)
|
||||
const imageBitmap = await createImageBitmap(input)
|
||||
|
||||
// Initialize quality range
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import generateUglyAvatar from '@/lib/uglyAvatar'
|
||||
import compressImage, { ImageType } from './compressImage'
|
||||
import compressImage from './compressImage'
|
||||
|
||||
const generateRandomAvatar = async (targetSize: number, outputType: ImageType = 'image/webp') => {
|
||||
const generateRandomAvatar = async (targetSize: number) => {
|
||||
const svgBlob = generateUglyAvatar()
|
||||
|
||||
// compressImage can't directly compress svg, need to convert to jpeg first
|
||||
|
@ -11,13 +11,13 @@ const generateRandomAvatar = async (targetSize: number, outputType: ImageType =
|
|||
const canvas = new OffscreenCanvas(image.width, image.height)
|
||||
const ctx = canvas.getContext('2d')
|
||||
ctx?.drawImage(image, 0, 0)
|
||||
const blob = await canvas.convertToBlob({ type: outputType })
|
||||
const blob = await canvas.convertToBlob({ type: 'image/jpeg' })
|
||||
resolve(blob)
|
||||
}
|
||||
image.onerror = () => reject(new Error('Failed to load SVG'))
|
||||
image.src = URL.createObjectURL(svgBlob)
|
||||
})
|
||||
const miniAvatarBlob = await compressImage({ input: imageBlob, targetSize, outputType })
|
||||
const miniAvatarBlob = await compressImage({ input: imageBlob, targetSize })
|
||||
const miniAvatarBase64 = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => resolve(e.target?.result as string)
|
||||
|
|
|
@ -1,74 +0,0 @@
|
|||
import { createElement } from '@/utils'
|
||||
|
||||
export interface Position {
|
||||
x: number
|
||||
y: number
|
||||
selectionStart: number
|
||||
selectionEnd: number
|
||||
}
|
||||
|
||||
const getCursorPosition = (target: HTMLInputElement | HTMLTextAreaElement) => {
|
||||
return new Promise<Position>((resolve, reject) =>
|
||||
requestIdleCallback(() => {
|
||||
try {
|
||||
const value = target.value
|
||||
|
||||
const inputWrapper = createElement<HTMLDivElement>(
|
||||
`<div style="position: fixed; z-index: calc(-infinity); width: 0; height: 0; overflow: hidden; visibility: hidden; pointer-events: none;"></div>`
|
||||
// `<div id="input-wrapper" style="position: fixed"></div>`
|
||||
)
|
||||
const copyInput = createElement<HTMLDivElement>(`<div contenteditable></div>`)
|
||||
|
||||
inputWrapper.appendChild(copyInput)
|
||||
target.ownerDocument.body.appendChild(inputWrapper)
|
||||
|
||||
const { left, top, width, height } = target.getBoundingClientRect()
|
||||
|
||||
const isEmptyOrBreakEnd = /(\n|\s*$)/.test(value)
|
||||
copyInput.textContent = isEmptyOrBreakEnd ? `${value}\u200b` : value
|
||||
|
||||
const copyStyle = getComputedStyle(target)
|
||||
|
||||
for (const key of copyStyle) {
|
||||
Reflect.set(copyInput.style, key, copyStyle[key as keyof CSSStyleDeclaration])
|
||||
}
|
||||
|
||||
if (target.tagName === 'INPUT') {
|
||||
copyInput.style.lineHeight = copyStyle.height
|
||||
}
|
||||
|
||||
copyInput.style.overflow = 'auto'
|
||||
|
||||
copyInput.style.width = `${width}px`
|
||||
copyInput.style.height = `${height}px`
|
||||
copyInput.style.boxSizing = 'border-box'
|
||||
copyInput.style.margin = '0'
|
||||
copyInput.style.position = 'fixed'
|
||||
copyInput.style.top = `${top}px`
|
||||
copyInput.style.left = `${left}px`
|
||||
copyInput.style.pointerEvents = 'none'
|
||||
|
||||
// sync scroll
|
||||
copyInput.scrollTop = target.scrollTop
|
||||
copyInput.scrollLeft = target.scrollLeft
|
||||
|
||||
const selectionStart = target.selectionStart!
|
||||
const selectionEnd = target.selectionEnd!
|
||||
|
||||
const range = new Range()
|
||||
range.setStart(copyInput.childNodes[0], selectionStart)
|
||||
range.setEnd(copyInput.childNodes[0], isEmptyOrBreakEnd ? selectionEnd + 1 : selectionEnd)
|
||||
|
||||
const { x, y } = range.getBoundingClientRect()
|
||||
|
||||
target.ownerDocument.body.removeChild(inputWrapper)
|
||||
|
||||
resolve({ x, y, selectionStart, selectionEnd })
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export default getCursorPosition
|
|
@ -1,5 +0,0 @@
|
|||
export const getRootNode = () => {
|
||||
return document.querySelector(__NAME__)?.shadowRoot?.querySelector('#app') || document.body
|
||||
}
|
||||
|
||||
export default getRootNode
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue